feat: Implement Scheduler Worker Options and Planner Loop
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added `SchedulerWorkerOptions` class to encapsulate configuration for the scheduler worker.
- Introduced `PlannerBackgroundService` to manage the planner loop, fetching and processing planning runs.
- Created `PlannerExecutionService` to handle the execution logic for planning runs, including impact targeting and run persistence.
- Developed `PlannerExecutionResult` and `PlannerExecutionStatus` to standardize execution outcomes.
- Implemented validation logic within `SchedulerWorkerOptions` to ensure proper configuration.
- Added documentation for the planner loop and impact targeting features.
- Established health check endpoints and authentication mechanisms for the Signals service.
- Created unit tests for the Signals API to ensure proper functionality and response handling.
- Configured options for authority integration and fallback authentication methods.
This commit is contained in:
2025-10-27 09:46:31 +02:00
parent 96d52884e8
commit 730354a1af
135 changed files with 10721 additions and 946 deletions

View File

@@ -14,4 +14,9 @@ public static class StellaOpsServiceIdentities
/// Service identity used by Cartographer when constructing and maintaining graph projections.
/// </summary>
public const string Cartographer = "cartographer";
/// <summary>
/// Service identity used by Vuln Explorer when issuing scoped permalink requests.
/// </summary>
public const string VulnExplorer = "vuln-explorer";
}

View File

@@ -6,6 +6,7 @@ using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Configuration;
@@ -389,6 +390,106 @@ public class ClientCredentialsHandlersTests
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_RejectsAdvisoryScopes_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "concelier-ingestor",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "advisory:ingest advisory:read");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Advisory scopes require a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsVexScopes_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "excitor-ingestor",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "vex:ingest vex:read");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "vex:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("VEX scopes require a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_AllowsAdvisoryScopes_WithTenant()
{
var clientDocument = CreateClient(
clientId: "concelier-ingestor",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "advisory:ingest advisory:read",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "advisory:read" }, grantedScopes);
}
[Fact]
public async Task ValidateClientCredentials_AllowsGraphWrite_ForCartographerServiceIdentity()
{
@@ -992,6 +1093,206 @@ public class TokenValidationHandlersTests
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
}
[Fact]
public async Task ValidateAccessTokenHandler_AddsTenantClaim_FromTokenDocument()
{
var clientDocument = CreateClient(tenant: "tenant-alpha");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId,
Tenant = "tenant-alpha"
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin);
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
}
[Fact]
public async Task ValidateAccessTokenHandler_Rejects_WhenTenantDiffersFromToken()
{
var clientDocument = CreateClient(tenant: "tenant-alpha");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId,
Tenant = "tenant-alpha"
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin);
principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-beta"));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
Assert.Equal("The token tenant does not match the issued tenant.", context.ErrorDescription);
}
[Fact]
public async Task ValidateAccessTokenHandler_AssignsTenant_FromClientWhenTokenMissing()
{
var clientDocument = CreateClient(tenant: "tenant-alpha");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin);
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
}
[Fact]
public async Task ValidateAccessTokenHandler_Rejects_WhenClientTenantDiffers()
{
var clientDocument = CreateClient(tenant: "tenant-beta");
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullMongoSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestActivitySource,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", clientDocument.Plugin);
principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha"));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
Assert.Equal("The token tenant does not match the registered client tenant.", context.ErrorDescription);
}
[Fact]
public async Task ValidateAccessTokenHandler_EnrichesClaims_WhenProviderAvailable()
{

View File

@@ -283,6 +283,11 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
var hasGraphExport = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphExport) >= 0;
var hasGraphSimulate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphSimulate) >= 0;
var graphScopesRequested = hasGraphRead || hasGraphWrite || hasGraphExport || hasGraphSimulate;
var hasAdvisoryIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryIngest) >= 0;
var hasAdvisoryRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryRead) >= 0;
var hasVexIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexIngest) >= 0;
var hasVexRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexRead) >= 0;
var hasVulnRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnRead) >= 0;
var tenantScopeForAudit = hasGraphWrite
? StellaOpsScopes.GraphWrite
@@ -302,6 +307,38 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
if ((hasAdvisoryIngest || hasAdvisoryRead) && !EnsureTenantAssigned())
{
var advisoryScope = hasAdvisoryIngest ? StellaOpsScopes.AdvisoryIngest : StellaOpsScopes.AdvisoryRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = advisoryScope;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Advisory scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: advisory scopes require tenant assignment.",
document.ClientId);
return;
}
if ((hasVexIngest || hasVexRead) && !EnsureTenantAssigned())
{
var vexScope = hasVexIngest ? StellaOpsScopes.VexIngest : StellaOpsScopes.VexRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = vexScope;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "VEX scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: vex scopes require tenant assignment.",
document.ClientId);
return;
}
if (hasVulnRead && !EnsureTenantAssigned())
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.VulnRead;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Vuln Explorer scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: vuln scopes require tenant assignment.",
document.ClientId);
return;
}
if (grantedScopes.Length > 0 &&
Array.IndexOf(grantedScopes, StellaOpsScopes.EffectiveWrite) >= 0)
{

View File

@@ -69,6 +69,12 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
return;
}
static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
var identity = context.Principal.Identity as ClaimsIdentity;
var principalTenant = NormalizeTenant(context.Principal.GetClaim(StellaOpsClaimTypes.Tenant));
using var activity = activitySource.StartActivity("authority.token.validate_access", ActivityKind.Internal);
activity?.SetTag("authority.endpoint", context.EndpointType switch
{
@@ -111,21 +117,43 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
if (tokenDocument is not null)
{
EnsureSenderConstraintClaims(context.Principal, tokenDocument);
var documentTenant = NormalizeTenant(tokenDocument.Tenant);
if (documentTenant is not null)
{
if (principalTenant is null)
{
if (identity is not null)
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, documentTenant);
principalTenant = documentTenant;
}
}
else if (!string.Equals(principalTenant, documentTenant, StringComparison.Ordinal))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the issued tenant.");
logger.LogWarning(
"Access token validation failed: tenant mismatch for token {TokenId}. PrincipalTenant={PrincipalTenant}; DocumentTenant={DocumentTenant}.",
tokenDocument.TokenId,
principalTenant,
documentTenant);
return;
}
metadataAccessor.SetTenant(documentTenant);
}
}
if (!context.IsRejected && tokenDocument is not null)
{
await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(tokenDocument.Tenant))
{
metadataAccessor.SetTenant(tokenDocument.Tenant);
}
}
var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId);
AuthorityClientDocument? clientDocument = null;
if (!string.IsNullOrWhiteSpace(clientId))
{
var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken, session).ConfigureAwait(false);
clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken, session).ConfigureAwait(false);
if (clientDocument is null || clientDocument.Disabled)
{
context.Reject(OpenIddictConstants.Errors.InvalidClient, "The client associated with the token is not permitted.");
@@ -134,15 +162,43 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
}
}
if (context.Principal.Identity is not ClaimsIdentity identity)
if (clientDocument is not null &&
clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var clientTenantRaw))
{
var clientTenant = NormalizeTenant(clientTenantRaw);
if (clientTenant is not null)
{
if (principalTenant is null)
{
if (identity is not null)
{
identity.SetClaim(StellaOpsClaimTypes.Tenant, clientTenant);
principalTenant = clientTenant;
}
}
else if (!string.Equals(principalTenant, clientTenant, StringComparison.Ordinal))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token tenant does not match the registered client tenant.");
logger.LogWarning(
"Access token validation failed: tenant mismatch for client {ClientId}. PrincipalTenant={PrincipalTenant}; ClientTenant={ClientTenant}.",
clientId,
principalTenant,
clientTenant);
return;
}
metadataAccessor.SetTenant(clientTenant);
}
}
if (identity is null)
{
return;
}
var tenantClaim = context.Principal.GetClaim(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(tenantClaim))
if (principalTenant is not null)
{
metadataAccessor.SetTenant(tenantClaim);
metadataAccessor.SetTenant(principalTenant);
}
var providerName = context.Principal.GetClaim(StellaOpsClaimTypes.IdentityProvider);

View File

@@ -2,10 +2,13 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-AOC-19-001 | DONE (2025-10-26) | Authority Core & Security Guild | — | Introduce scopes `advisory:read`, `advisory:ingest`, `vex:read`, `vex:ingest`, `aoc:verify` with configuration binding, migrations, and offline kit defaults. | Scopes published in metadata/OpenAPI, configuration validates scope lists, tests cover token issuance + enforcement. |
| AUTH-AOC-19-002 | DOING (2025-10-26) | Authority Core & Security Guild | AUTH-AOC-19-001 | Propagate tenant claim + scope enforcement for ingestion identities; ensure cross-tenant writes/read blocked and audit logs capture tenant context. | Tenant claim injected into downstream services; forbidden cross-tenant access rejected; audit/log fixtures updated. |
| AUTH-AOC-19-002 | DONE (2025-10-27) | Authority Core & Security Guild | AUTH-AOC-19-001 | Propagate tenant claim + scope enforcement for ingestion identities; ensure cross-tenant writes/read blocked and audit logs capture tenant context. | Tenant claim injected into downstream services; forbidden cross-tenant access rejected; audit/log fixtures updated. |
> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003.
| AUTH-AOC-19-003 | TODO | Authority Core & Docs Guild | AUTH-AOC-19-001 | Update Authority docs and sample configs to describe new scopes, tenancy enforcement, and verify endpoints. | Docs and examples refreshed; release notes prepared; smoke tests confirm new scopes required. |
> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests.
> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet<string?>`), preventing Authority test suite run; waiting on Concelier fix before rerun.
| AUTH-AOC-19-003 | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-001 | Update Authority docs and sample configs to describe new scopes, tenancy enforcement, and verify endpoints. | Docs and examples refreshed; release notes prepared; smoke tests confirm new scopes required. |
> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates).
> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients.
## Policy Engine v2
@@ -38,6 +41,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-VULN-24-001 | TODO | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged.
## Orchestrator Dashboard
@@ -54,6 +58,8 @@
| AUTH-CONSOLE-23-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001 | Register StellaOps Console confidential client with OIDC PKCE support, short-lived ID/access tokens, `console:*` audience claims, and SPA-friendly refresh (token exchange endpoint). Publish discovery metadata + offline kit defaults. | Client registration committed, configuration templates updated, integration tests validate PKCE + scope issuance, security review recorded. |
| AUTH-CONSOLE-23-002 | TODO | Authority Core & Security Guild | AUTH-CONSOLE-23-001, AUTH-AOC-19-002 | Expose tenant catalog, user profile, and token introspection endpoints required by Console (fresh-auth prompts, scope checks); enforce tenant header requirements and audit logging with correlation IDs. | Endpoints ship with RBAC enforcement, audit logs include tenant+scope, integration tests cover unauthorized/tenant-mismatch scenarios. |
| AUTH-CONSOLE-23-003 | TODO | Authority Core & Docs Guild | AUTH-CONSOLE-23-001, AUTH-CONSOLE-23-002 | Update security docs/config samples for Console flows (PKCE, tenant badge, fresh-auth for admin actions, session inactivity timeouts) with compliance checklist. | Docs merged, config samples validated, release notes updated, ops runbook references new flows. |
> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120s OpTok, 300s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task.
| AUTH-CONSOLE-23-004 | TODO | Authority Core & Security Guild | AUTH-CONSOLE-23-003, DOCS-CONSOLE-23-012 | Validate console security guide assumptions (120s OpTok TTL, 300s fresh-auth window, scope bundles) against Authority implementation and update configs/audit fixtures if needed. | Confirmation recorded in sprint log; Authority config samples/tests updated when adjustments required; `/fresh-auth` behaviour documented in release notes. |
## Policy Studio (Sprint 27)
@@ -61,6 +67,7 @@
|----|--------|----------|------------|-------------|---------------|
| AUTH-POLICY-27-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-CONSOLE-23-001 | Define Policy Studio roles (`policy:author`, `policy:review`, `policy:approve`, `policy:operate`, `policy:audit`) with tenant-scoped claims, update issuer metadata, and seed offline kit defaults. | Scopes/roles exposed via discovery docs; tokens issued with correct claims; integration tests cover role combinations; docs updated. |
| AUTH-POLICY-27-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
| AUTH-POLICY-27-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
## Exceptions v1

View File

@@ -20,8 +20,10 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| BENCH-GRAPH-21-001 | DOING (2025-10-27) | Bench Guild, Graph Platform Guild | GRAPH-API-28-003, GRAPH-INDEX-28-006 | Build graph viewport/path benchmark harness (50k/100k nodes) measuring Graph API/Indexer latency, memory, and tile cache hit rates. *(Executed within Sprint 28 Graph program).* | Harness committed; baseline metrics logged; integrates with perf dashboards. |
| BENCH-GRAPH-21-002 | TODO | Bench Guild, UI Guild | BENCH-GRAPH-21-001, UI-GRAPH-24-001 | Add headless UI load benchmark (Playwright) for graph canvas interactions to track render times and FPS budgets. *(Executed within Sprint 28 Graph program).* | Benchmark runs in CI; results exported; alert thresholds defined. |
| BENCH-GRAPH-21-001 | BLOCKED (2025-10-27) | Bench Guild, Graph Platform Guild | GRAPH-API-28-003, GRAPH-INDEX-28-006 | Build graph viewport/path benchmark harness (50k/100k nodes) measuring Graph API/Indexer latency, memory, and tile cache hit rates. *(Executed within Sprint 28 Graph program).* | Harness committed; baseline metrics logged; integrates with perf dashboards. |
> 2025-10-27: Graph API (`GRAPH-API-28-003`) and indexer (`GRAPH-INDEX-28-006`) contracts are not yet available, so workload scenarios and baselines cannot be recorded. Revisit once upstream services expose stable perf endpoints.
| BENCH-GRAPH-21-002 | BLOCKED (2025-10-27) | Bench Guild, UI Guild | BENCH-GRAPH-21-001, UI-GRAPH-24-001 | Add headless UI load benchmark (Playwright) for graph canvas interactions to track render times and FPS budgets. *(Executed within Sprint 28 Graph program).* | Benchmark runs in CI; results exported; alert thresholds defined. |
> 2025-10-27: Waiting on BENCH-GRAPH-21-001 harness and UI Graph Explorer (`UI-GRAPH-24-001`) to stabilize. Playwright flows and perf targets are not defined yet.
## Link-Not-Merge v1

View File

@@ -0,0 +1,51 @@
using StellaOps.Auth.Abstractions;
using StellaOps.Cartographer.Options;
using Xunit;
namespace StellaOps.Cartographer.Tests.Options;
public class CartographerAuthorityOptionsConfiguratorTests
{
[Fact]
public void ApplyDefaults_AddsGraphScopes()
{
var options = new CartographerAuthorityOptions();
CartographerAuthorityOptionsConfigurator.ApplyDefaults(options);
Assert.Contains(StellaOpsScopes.GraphRead, options.RequiredScopes);
Assert.Contains(StellaOpsScopes.GraphWrite, options.RequiredScopes);
}
[Fact]
public void ApplyDefaults_DoesNotDuplicateScopes()
{
var options = new CartographerAuthorityOptions();
options.RequiredScopes.Add("GRAPH:READ");
options.RequiredScopes.Add(StellaOpsScopes.GraphWrite);
CartographerAuthorityOptionsConfigurator.ApplyDefaults(options);
Assert.Equal(2, options.RequiredScopes.Count);
}
[Fact]
public void Validate_AllowsDisabledConfiguration()
{
var options = new CartographerAuthorityOptions();
options.Validate(); // should not throw when disabled
}
[Fact]
public void Validate_ThrowsForInvalidIssuer()
{
var options = new CartographerAuthorityOptions
{
Enabled = true,
Issuer = "invalid"
};
Assert.Throws<InvalidOperationException>(() => options.Validate());
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cartographer\StellaOps.Cartographer.csproj" />
</ItemGroup>
</Project>

View File

@@ -15,3 +15,4 @@ Build and operate the Cartographer service that materializes immutable SBOM prop
- Tenancy and scope enforcement must match Authority policies (`graph:*`, `sbom:read`, `findings:read`).
- Update `TASKS.md`, `SPRINTS.md` when status changes.
- Provide fixtures and documentation so UI/CLI teams can simulate graphs offline.
- Authority integration derives scope names from `StellaOps.Auth.Abstractions.StellaOpsScopes`; avoid hard-coded `graph:*` literals.

View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cartographer.Options;
/// <summary>
/// Configuration controlling Authority-backed authentication for the Cartographer service.
/// </summary>
public sealed class CartographerAuthorityOptions
{
/// <summary>
/// Enables Authority-backed authentication for Cartographer endpoints.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Allows anonymous access when Authority integration is enabled (development only).
/// </summary>
public bool AllowAnonymousFallback { get; set; }
/// <summary>
/// Authority issuer URL exposed via OpenID discovery.
/// </summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// Whether HTTPS metadata is required when fetching Authority discovery documents.
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Optional explicit metadata endpoint for Authority discovery.
/// </summary>
public string? MetadataAddress { get; set; }
/// <summary>
/// Timeout (seconds) applied to Authority back-channel HTTP calls.
/// </summary>
public int BackchannelTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Allowed token clock skew (seconds) when validating Authority-issued tokens.
/// </summary>
public int TokenClockSkewSeconds { get; set; } = 60;
/// <summary>
/// Accepted audiences for Cartographer access tokens.
/// </summary>
public IList<string> Audiences { get; } = new List<string>();
/// <summary>
/// Scopes required for Cartographer operations.
/// </summary>
public IList<string> RequiredScopes { get; } = new List<string>();
/// <summary>
/// Tenants permitted to access Cartographer resources.
/// </summary>
public IList<string> RequiredTenants { get; } = new List<string>();
/// <summary>
/// Networks allowed to bypass authentication enforcement.
/// </summary>
public IList<string> BypassNetworks { get; } = new List<string>();
/// <summary>
/// Validates configured values and throws <see cref="InvalidOperationException"/> on failure.
/// </summary>
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Cartographer Authority issuer must be configured when Authority integration is enabled.");
}
if (!Uri.TryCreate(Issuer.Trim(), UriKind.Absolute, out var issuerUri))
{
throw new InvalidOperationException("Cartographer Authority issuer must be an absolute URI.");
}
if (RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Cartographer Authority issuer must use HTTPS unless running on loopback.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Cartographer Authority back-channel timeout must be greater than zero seconds.");
}
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Cartographer Authority token clock skew must be between 0 and 300 seconds.");
}
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Cartographer.Options;
/// <summary>
/// Applies Cartographer-specific defaults to <see cref="CartographerAuthorityOptions"/>.
/// </summary>
internal static class CartographerAuthorityOptionsConfigurator
{
/// <summary>
/// Ensures required scopes are present and duplicates are removed case-insensitively.
/// </summary>
/// <param name="options">Target options.</param>
public static void ApplyDefaults(CartographerAuthorityOptions options)
{
ArgumentNullException.ThrowIfNull(options);
EnsureScope(options.RequiredScopes, StellaOpsScopes.GraphRead);
EnsureScope(options.RequiredScopes, StellaOpsScopes.GraphWrite);
}
private static void EnsureScope(ICollection<string> scopes, string scope)
{
ArgumentNullException.ThrowIfNull(scopes);
ArgumentException.ThrowIfNullOrEmpty(scope);
if (scopes.Any(existing => string.Equals(existing, scope, StringComparison.OrdinalIgnoreCase)))
{
return;
}
scopes.Add(scope);
}
}

View File

@@ -1,3 +1,5 @@
using StellaOps.Cartographer.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration
@@ -7,10 +9,30 @@ builder.Configuration
builder.Services.AddOptions();
builder.Services.AddLogging();
var authoritySection = builder.Configuration.GetSection("Cartographer:Authority");
var authorityOptions = new CartographerAuthorityOptions();
authoritySection.Bind(authorityOptions);
CartographerAuthorityOptionsConfigurator.ApplyDefaults(authorityOptions);
authorityOptions.Validate();
builder.Services.AddSingleton(authorityOptions);
builder.Services.AddOptions<CartographerAuthorityOptions>()
.Bind(authoritySection)
.PostConfigure(CartographerAuthorityOptionsConfigurator.ApplyDefaults);
// TODO: register Cartographer graph builders, overlay workers, and Authority client once implementations land.
var app = builder.Build();
if (!authorityOptions.Enabled)
{
app.Logger.LogWarning("Cartographer Authority authentication is disabled; enable it before production deployments.");
}
else if (authorityOptions.AllowAnonymousFallback)
{
app.Logger.LogWarning("Cartographer Authority allows anonymous fallback; disable fallback before production rollout.");
}
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
app.MapGet("/readyz", () => Results.Ok(new { status = "warming" }));

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Cartographer.Tests")]

View File

@@ -12,5 +12,6 @@
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="..\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,6 @@
# Cartographer Task Board — Epic 3: Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CARTO-GRAPH-21-010 | TODO | Cartographer Guild | AUTH-GRAPH-21-001 | Replace hard-coded `graph:*` scope strings in Cartographer services/clients with `StellaOpsScopes` constants; document new dependency. | All scope checks reference `StellaOpsScopes`; documentation updated; unit tests adjusted if needed. |
| CARTO-GRAPH-21-010 | DONE (2025-10-27) | Cartographer Guild | AUTH-GRAPH-21-001 | Replace hard-coded `graph:*` scope strings in Cartographer services/clients with `StellaOpsScopes` constants; document new dependency. | All scope checks reference `StellaOpsScopes`; documentation updated; unit tests adjusted if needed. |
> 2025-10-26 — Note: awaiting Cartographer service bootstrap. Keep this task open until Cartographer routes exist so we can swap to `StellaOpsScopes` immediately.

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
@@ -379,6 +380,169 @@ public sealed class CommandHandlersTests
}
}
[Fact]
public async Task HandleVulnObservationsAsync_WritesTableOutput()
{
var originalExit = Environment.ExitCode;
var response = new AdvisoryObservationsResponse
{
Observations = new[]
{
new AdvisoryObservationDocument
{
ObservationId = "tenant-a:ghsa:alpha:1",
Tenant = "tenant-a",
Source = new AdvisoryObservationSource
{
Vendor = "ghsa",
Stream = "advisories",
Api = "https://example.test/api"
},
Upstream = new AdvisoryObservationUpstream
{
UpstreamId = "GHSA-abcd-efgh"
},
Linkset = new AdvisoryObservationLinkset
{
Aliases = new[] { "cve-2025-0001" },
Purls = new[] { "pkg:npm/package-a@1.0.0" },
Cpes = new[] { "cpe:/a:vendor:product:1.0" }
},
CreatedAt = new DateTimeOffset(2025, 10, 27, 6, 0, 0, TimeSpan.Zero)
}
},
Linkset = new AdvisoryObservationLinksetAggregate
{
Aliases = new[] { "cve-2025-0001" },
Purls = new[] { "pkg:npm/package-a@1.0.0" },
Cpes = new[] { "cpe:/a:vendor:product:1.0" },
References = Array.Empty<AdvisoryObservationReference>()
}
};
var stubClient = new StubConcelierObservationsClient(response);
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
var provider = BuildServiceProvider(backend, concelierClient: stubClient);
var console = new TestConsole();
var originalConsole = AnsiConsole.Console;
AnsiConsole.Console = console;
try
{
await CommandHandlers.HandleVulnObservationsAsync(
provider,
tenant: "Tenant-A ",
observationIds: new[] { "tenant-a:ghsa:alpha:1 " },
aliases: new[] { " CVE-2025-0001 " },
purls: new[] { " pkg:npm/package-a@1.0.0 " },
cpes: Array.Empty<string>(),
emitJson: false,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
AnsiConsole.Console = originalConsole;
}
Assert.NotNull(stubClient.LastQuery);
var query = stubClient.LastQuery!;
Assert.Equal("tenant-a", query.Tenant);
Assert.Contains("cve-2025-0001", query.Aliases);
Assert.Contains("pkg:npm/package-a@1.0.0", query.Purls);
var output = console.Output;
Assert.False(string.IsNullOrWhiteSpace(output));
}
[Fact]
public async Task HandleVulnObservationsAsync_WritesJsonOutput()
{
var originalExit = Environment.ExitCode;
var response = new AdvisoryObservationsResponse
{
Observations = new[]
{
new AdvisoryObservationDocument
{
ObservationId = "tenant-a:osv:beta:2",
Tenant = "tenant-a",
Source = new AdvisoryObservationSource
{
Vendor = "osv",
Stream = "osv",
Api = "https://example.test/osv"
},
Upstream = new AdvisoryObservationUpstream
{
UpstreamId = "OSV-2025-XYZ"
},
Linkset = new AdvisoryObservationLinkset
{
Aliases = new[] { "cve-2025-0101" },
Purls = new[] { "pkg:pypi/package-b@2.0.0" },
Cpes = Array.Empty<string>(),
References = new[]
{
new AdvisoryObservationReference { Type = "advisory", Url = "https://example.test/advisory" }
}
},
CreatedAt = new DateTimeOffset(2025, 10, 27, 7, 30, 0, TimeSpan.Zero)
}
},
Linkset = new AdvisoryObservationLinksetAggregate
{
Aliases = new[] { "cve-2025-0101" },
Purls = new[] { "pkg:pypi/package-b@2.0.0" },
Cpes = Array.Empty<string>(),
References = new[]
{
new AdvisoryObservationReference { Type = "advisory", Url = "https://example.test/advisory" }
}
}
};
var stubClient = new StubConcelierObservationsClient(response);
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
var provider = BuildServiceProvider(backend, concelierClient: stubClient);
var writer = new StringWriter();
var originalOut = Console.Out;
Console.SetOut(writer);
try
{
await CommandHandlers.HandleVulnObservationsAsync(
provider,
tenant: "tenant-a",
observationIds: Array.Empty<string>(),
aliases: Array.Empty<string>(),
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
emitJson: true,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
Console.SetOut(originalOut);
}
var json = writer.ToString();
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.True(root.TryGetProperty("observations", out var observations));
Assert.Equal("tenant-a:osv:beta:2", observations[0].GetProperty("observationId").GetString());
Assert.Equal("pkg:pypi/package-b@2.0.0", observations[0].GetProperty("linkset").GetProperty("purls")[0].GetString());
}
[Theory]
[InlineData(null)]
[InlineData("default")]
@@ -771,6 +935,218 @@ public sealed class CommandHandlersTests
}
}
[Fact]
public async Task HandlePolicySimulateAsync_WritesInteractiveSummary()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
var console = new TestConsole();
console.Width(120);
console.Interactive();
console.EmitAnsiSequences();
AnsiConsole.Console = console;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
var severity = new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(StringComparer.Ordinal)
{
["critical"] = new PolicySimulationSeverityDelta(1, null),
["high"] = new PolicySimulationSeverityDelta(null, 2)
});
var ruleHits = new ReadOnlyCollection<PolicySimulationRuleDelta>(new List<PolicySimulationRuleDelta>
{
new("rule-block-critical", "Block Critical", 1, 0),
new("rule-quiet-low", "Quiet Low", null, 2)
});
backend.SimulationResult = new PolicySimulationResult(
new PolicySimulationDiff(
"scheduler.policy-diff-summary@1",
2,
1,
10,
severity,
ruleHits),
"blob://policy/P-7/simulation.json");
var provider = BuildServiceProvider(backend);
try
{
await CommandHandlers.HandlePolicySimulateAsync(
provider,
policyId: "P-7",
baseVersion: 3,
candidateVersion: 4,
sbomArguments: new[] { "sbom:A", "sbom:B" },
environmentArguments: new[] { "sealed=false", "exposure=internet" },
format: "table",
outputPath: null,
explain: true,
failOnDiff: false,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
Assert.NotNull(backend.LastPolicySimulation);
var simulation = backend.LastPolicySimulation!.Value;
Assert.Equal("P-7", simulation.PolicyId);
Assert.Equal(3, simulation.Input.BaseVersion);
Assert.Equal(4, simulation.Input.CandidateVersion);
Assert.True(simulation.Input.Explain);
Assert.Equal(new[] { "sbom:A", "sbom:B" }, simulation.Input.SbomSet);
Assert.True(simulation.Input.Environment.TryGetValue("sealed", out var sealedValue) && sealedValue is bool sealedFlag && sealedFlag == false);
Assert.True(simulation.Input.Environment.TryGetValue("exposure", out var exposureValue) && string.Equals(exposureValue as string, "internet", StringComparison.Ordinal));
var output = console.Output;
Assert.Contains("Severity", output, StringComparison.Ordinal);
Assert.Contains("critical", output, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Rule", output, StringComparison.Ordinal);
Assert.Contains("Block Critical", output, StringComparison.Ordinal);
}
finally
{
Environment.ExitCode = originalExit;
AnsiConsole.Console = originalConsole;
}
}
[Fact]
public async Task HandlePolicySimulateAsync_WritesJsonOutput()
{
var originalExit = Environment.ExitCode;
var originalOut = Console.Out;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
backend.SimulationResult = new PolicySimulationResult(
new PolicySimulationDiff(
"scheduler.policy-diff-summary@1",
0,
0,
5,
new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)),
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
null);
var provider = BuildServiceProvider(backend);
using var writer = new StringWriter();
Console.SetOut(writer);
try
{
await CommandHandlers.HandlePolicySimulateAsync(
provider,
policyId: "P-9",
baseVersion: null,
candidateVersion: 5,
sbomArguments: Array.Empty<string>(),
environmentArguments: new[] { "sealed=true", "threshold=0.8" },
format: "json",
outputPath: null,
explain: false,
failOnDiff: false,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
using var document = JsonDocument.Parse(writer.ToString());
var root = document.RootElement;
Assert.Equal("P-9", root.GetProperty("policyId").GetString());
Assert.Equal(5, root.GetProperty("candidateVersion").GetInt32());
Assert.True(root.TryGetProperty("environment", out var envElement) && envElement.TryGetProperty("sealed", out var sealedElement) && sealedElement.GetBoolean());
Assert.True(envElement.TryGetProperty("threshold", out var thresholdElement) && Math.Abs(thresholdElement.GetDouble() - 0.8) < 0.0001);
}
finally
{
Console.SetOut(originalOut);
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandlePolicySimulateAsync_FailOnDiffSetsExitCode20()
{
var originalExit = Environment.ExitCode;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null));
backend.SimulationResult = new PolicySimulationResult(
new PolicySimulationDiff(
null,
1,
0,
0,
new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)),
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
null);
var provider = BuildServiceProvider(backend);
try
{
await CommandHandlers.HandlePolicySimulateAsync(
provider,
policyId: "P-11",
baseVersion: null,
candidateVersion: null,
sbomArguments: Array.Empty<string>(),
environmentArguments: Array.Empty<string>(),
format: "json",
outputPath: null,
explain: false,
failOnDiff: true,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(20, Environment.ExitCode);
}
finally
{
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandlePolicySimulateAsync_MapsErrorCodes()
{
var originalExit = Environment.ExitCode;
var originalOut = Console.Out;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
SimulationException = new PolicyApiException("Missing inputs", HttpStatusCode.BadRequest, "ERR_POL_003")
};
var provider = BuildServiceProvider(backend);
using var writer = new StringWriter();
Console.SetOut(writer);
try
{
await CommandHandlers.HandlePolicySimulateAsync(
provider,
policyId: "P-12",
baseVersion: null,
candidateVersion: null,
sbomArguments: Array.Empty<string>(),
environmentArguments: Array.Empty<string>(),
format: "json",
outputPath: null,
explain: false,
failOnDiff: false,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(21, Environment.ExitCode);
}
finally
{
Console.SetOut(originalOut);
Environment.ExitCode = originalExit;
}
}
private static async Task<RevocationArtifactPaths> WriteRevocationArtifactsAsync(TempDirectory temp, string? providerHint)
{
var (bundleBytes, signature, keyPem) = await BuildRevocationArtifactsAsync(providerHint);
@@ -849,7 +1225,8 @@ public sealed class CommandHandlersTests
IScannerExecutor? executor = null,
IScannerInstaller? installer = null,
StellaOpsCliOptions? options = null,
IStellaOpsTokenClient? tokenClient = null)
IStellaOpsTokenClient? tokenClient = null,
IConcelierObservationsClient? concelierClient = null)
{
var services = new ServiceCollection();
services.AddSingleton(backend);
@@ -870,6 +1247,9 @@ public sealed class CommandHandlersTests
services.AddSingleton(tokenClient);
}
services.AddSingleton<IConcelierObservationsClient>(
concelierClient ?? new StubConcelierObservationsClient());
return services.BuildServiceProvider();
}
@@ -907,6 +1287,45 @@ public sealed class CommandHandlersTests
public ExcititorOperationResult? ExcititorResult { get; set; } = new ExcititorOperationResult(true, "ok", null, null);
public IReadOnlyList<ExcititorProviderSummary> ProviderSummaries { get; set; } = Array.Empty<ExcititorProviderSummary>();
public RuntimePolicyEvaluationResult RuntimePolicyResult { get; set; } = DefaultRuntimePolicyResult;
public PolicySimulationResult SimulationResult { get; set; } = new PolicySimulationResult(
new PolicySimulationDiff(
null,
0,
0,
0,
new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)),
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
null);
public PolicyApiException? SimulationException { get; set; }
public (string PolicyId, PolicySimulationInput Input)? LastPolicySimulation { get; private set; }
public (string PolicyId, PolicyFindingsQuery Query)? LastFindingsQuery { get; private set; }
public (string PolicyId, string FindingId)? LastFindingRequest { get; private set; }
public (string PolicyId, string FindingId, bool Verbose)? LastExplainRequest { get; private set; }
public PolicyFindingsPage FindingsPage { get; set; } = new PolicyFindingsPage(
new ReadOnlyCollection<PolicyFinding>(Array.Empty<PolicyFinding>()),
null);
public PolicyFinding Finding { get; set; } = new PolicyFinding(
"finding-1",
"affected",
"High",
7.5,
"sbom:S-42",
4,
DateTimeOffset.Parse("2025-10-26T14:06:01Z", CultureInfo.InvariantCulture),
false,
null,
"internet",
null,
Array.Empty<string>(),
Array.Empty<string>(),
"{}");
public PolicyFindingExplain FindingExplain { get; set; } = new PolicyFindingExplain(
"finding-1",
4,
new ReadOnlyCollection<PolicyFindingExplainStep>(Array.Empty<PolicyFindingExplainStep>()),
new ReadOnlyCollection<string>(Array.Empty<string>()),
"{}");
public PolicyApiException? FindingsException { get; set; }
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
=> throw new NotImplementedException();
@@ -952,6 +1371,50 @@ public sealed class CommandHandlersTests
public Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
=> Task.FromResult(RuntimePolicyResult);
public Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken)
{
LastPolicySimulation = (policyId, input);
if (SimulationException is not null)
{
throw SimulationException;
}
return Task.FromResult(SimulationResult);
}
public Task<PolicyFindingsPage> GetPolicyFindingsAsync(string policyId, PolicyFindingsQuery query, CancellationToken cancellationToken)
{
LastFindingsQuery = (policyId, query);
if (FindingsException is not null)
{
throw FindingsException;
}
return Task.FromResult(FindingsPage);
}
public Task<PolicyFinding> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken)
{
LastFindingRequest = (policyId, findingId);
if (FindingsException is not null)
{
throw FindingsException;
}
return Task.FromResult(Finding);
}
public Task<PolicyFindingExplain> GetPolicyFindingExplainAsync(string policyId, string findingId, bool verbose, CancellationToken cancellationToken)
{
LastExplainRequest = (policyId, findingId, verbose);
if (FindingsException is not null)
{
throw FindingsException;
}
return Task.FromResult(FindingExplain);
}
public Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken)
=> throw new NotSupportedException();
@@ -1066,4 +1529,26 @@ public sealed class CommandHandlersTests
.Replace('+', '-')
.Replace('/', '_');
}
private sealed class StubConcelierObservationsClient : IConcelierObservationsClient
{
private readonly AdvisoryObservationsResponse _response;
public StubConcelierObservationsClient(AdvisoryObservationsResponse? response = null)
{
_response = response ?? new AdvisoryObservationsResponse();
}
public AdvisoryObservationsQuery? LastQuery { get; private set; }
public Task<AdvisoryObservationsResponse> GetObservationsAsync(
AdvisoryObservationsQuery query,
CancellationToken cancellationToken)
{
LastQuery = query;
return Task.FromResult(_response);
}
}
}
public Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken)
=> Task.FromResult(new AocIngestDryRunResponse(true, Array.Empty<AocForbiddenField>(), Array.Empty<string>(), "{}"));

View File

@@ -22,6 +22,7 @@ public sealed class CliCommandModuleLoaderTests
options.Plugins.BaseDirectory = repoRoot;
options.Plugins.Directory = "plugins/cli";
options.Plugins.ManifestSearchPattern = "manifest.json";
var services = new ServiceCollection()
.AddSingleton(options)

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Net;
@@ -865,5 +866,135 @@ public sealed class BackendOperationsClientTests
Requests++;
return Task.FromResult(_tokenResult);
}
}
}
}
[Fact]
public async Task SimulatePolicyAsync_SendsPayloadAndParsesResponse()
{
string? capturedBody = null;
var handler = new StubHttpMessageHandler((request, _) =>
{
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("https://policy.example/api/policy/policies/P-7/simulate", request.RequestUri!.ToString());
capturedBody = request.Content!.ReadAsStringAsync().Result;
var responseDocument = new PolicySimulationResponseDocument
{
Diff = new PolicySimulationDiffDocument
{
SchemaVersion = "scheduler.policy-diff-summary@1",
Added = 2,
Removed = 1,
Unchanged = 10,
BySeverity = new Dictionary<string, PolicySimulationSeverityDeltaDocument>
{
["critical"] = new PolicySimulationSeverityDeltaDocument { Up = 1 },
["high"] = new PolicySimulationSeverityDeltaDocument { Down = 1 }
},
RuleHits = new List<PolicySimulationRuleDeltaDocument>
{
new() { RuleId = "rule-block", RuleName = "Block Critical", Up = 1, Down = 0 }
}
},
ExplainUri = "blob://policy/P-7/simulation.json"
};
var json = JsonSerializer.Serialize(responseDocument, new JsonSerializerOptions(JsonSerializerDefaults.Web));
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
RequestMessage = request
};
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://policy.example")
};
var options = new StellaOpsCliOptions { BackendUrl = "https://policy.example" };
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
var sbomSet = new ReadOnlyCollection<string>(new List<string> { "sbom:A", "sbom:B" });
var environment = new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(StringComparer.Ordinal)
{
["sealed"] = false,
["threshold"] = 0.85
});
var input = new PolicySimulationInput(3, 4, sbomSet, environment, true);
var result = await client.SimulatePolicyAsync("P-7", input, CancellationToken.None);
Assert.NotNull(capturedBody);
using (var document = JsonDocument.Parse(capturedBody!))
{
var root = document.RootElement;
Assert.Equal(3, root.GetProperty("baseVersion").GetInt32());
Assert.Equal(4, root.GetProperty("candidateVersion").GetInt32());
Assert.True(root.TryGetProperty("env", out var envElement) && envElement.GetProperty("sealed").GetBoolean() == false);
Assert.Equal(0.85, envElement.GetProperty("threshold").GetDouble(), 3);
Assert.True(root.GetProperty("explain").GetBoolean());
var sboms = root.GetProperty("sbomSet");
Assert.Equal(2, sboms.GetArrayLength());
Assert.Equal("sbom:A", sboms[0].GetString());
}
Assert.Equal("scheduler.policy-diff-summary@1", result.Diff.SchemaVersion);
Assert.Equal(2, result.Diff.Added);
Assert.Equal(1, result.Diff.Removed);
Assert.Equal(10, result.Diff.Unchanged);
Assert.Equal("blob://policy/P-7/simulation.json", result.ExplainUri);
Assert.True(result.Diff.BySeverity.ContainsKey("critical"));
Assert.Single(result.Diff.RuleHits);
Assert.Equal("rule-block", result.Diff.RuleHits[0].RuleId);
}
[Fact]
public async Task SimulatePolicyAsync_ThrowsPolicyApiExceptionOnError()
{
var handler = new StubHttpMessageHandler((request, _) =>
{
var problem = new ProblemDocument
{
Title = "Bad request",
Detail = "Missing SBOM set",
Status = (int)HttpStatusCode.BadRequest,
Extensions = new Dictionary<string, object?>
{
["code"] = "ERR_POL_003"
}
};
var json = JsonSerializer.Serialize(problem, new JsonSerializerOptions(JsonSerializerDefaults.Web));
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
RequestMessage = request
};
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://policy.example")
};
var options = new StellaOpsCliOptions { BackendUrl = "https://policy.example" };
var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Debug));
var client = new BackendOperationsClient(httpClient, options, loggerFactory.CreateLogger<BackendOperationsClient>());
var input = new PolicySimulationInput(
null,
null,
new ReadOnlyCollection<string>(Array.Empty<string>()),
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>()),
false);
var exception = await Assert.ThrowsAsync<PolicyApiException>(() => client.SimulatePolicyAsync("P-7", input, CancellationToken.None));
Assert.Equal(HttpStatusCode.BadRequest, exception.StatusCode);
Assert.Equal("ERR_POL_003", exception.ErrorCode);
Assert.Contains("Bad request", exception.Message);
}
}

View File

@@ -32,8 +32,11 @@ internal static class CommandFactory
root.Add(BuildScannerCommand(services, verboseOption, cancellationToken));
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(options));
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
@@ -230,12 +233,91 @@ internal static class CommandFactory
return CommandHandlers.HandleExportJobAsync(services, format, delta, publishFull, publishDelta, includeFull, includeDelta, verbose, cancellationToken);
});
db.Add(fetch);
db.Add(merge);
db.Add(fetch);
db.Add(merge);
db.Add(export);
return db;
}
private static Command BuildSourcesCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var sources = new Command("sources", "Interact with source ingestion workflows.");
var ingest = new Command("ingest", "Validate source documents before ingestion.");
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Evaluate guard rules without writing to persistent storage."
};
var sourceOption = new Option<string>("--source")
{
Description = "Logical source identifier (e.g. redhat, ubuntu, osv).",
Required = true
};
var inputOption = new Option<string>("--input")
{
Description = "Path to a local document or HTTPS URI.",
Required = true
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant identifier override."
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: table or json."
};
var noColorOption = new Option<bool>("--no-color")
{
Description = "Disable ANSI colouring in console output."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write the JSON report to the specified file path."
};
ingest.Add(dryRunOption);
ingest.Add(sourceOption);
ingest.Add(inputOption);
ingest.Add(tenantOption);
ingest.Add(formatOption);
ingest.Add(noColorOption);
ingest.Add(outputOption);
ingest.SetAction((parseResult, _) =>
{
var dryRun = parseResult.GetValue(dryRunOption);
var source = parseResult.GetValue(sourceOption) ?? string.Empty;
var input = parseResult.GetValue(inputOption) ?? string.Empty;
var tenant = parseResult.GetValue(tenantOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var noColor = parseResult.GetValue(noColorOption);
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleSourcesIngestAsync(
services,
dryRun,
source,
input,
tenant,
format,
noColor,
output,
verbose,
cancellationToken);
});
sources.Add(ingest);
return sources;
}
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
@@ -322,6 +404,167 @@ internal static class CommandFactory
return auth;
}
private static Command BuildPolicyCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
_ = options;
var policy = new Command("policy", "Interact with Policy Engine operations.");
var simulate = new Command("simulate", "Simulate a policy revision against selected SBOMs and environment.");
var policyIdArgument = new Argument<string>("policy-id")
{
Description = "Policy identifier (e.g. P-7)."
};
simulate.Add(policyIdArgument);
var baseOption = new Option<int?>("--base")
{
Description = "Base policy version for diff calculations."
};
var candidateOption = new Option<int?>("--candidate")
{
Description = "Candidate policy version. Defaults to latest approved."
};
var sbomOption = new Option<string[]>("--sbom")
{
Description = "SBOM identifier to include (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
sbomOption.AllowMultipleArgumentsPerToken = true;
var envOption = new Option<string[]>("--env")
{
Description = "Environment override (key=value, repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
envOption.AllowMultipleArgumentsPerToken = true;
var formatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write JSON output to the specified file."
};
var explainOption = new Option<bool>("--explain")
{
Description = "Request explain traces for diffed findings."
};
var failOnDiffOption = new Option<bool>("--fail-on-diff")
{
Description = "Exit with code 20 when findings are added or removed."
};
simulate.Add(baseOption);
simulate.Add(candidateOption);
simulate.Add(sbomOption);
simulate.Add(envOption);
simulate.Add(formatOption);
simulate.Add(outputOption);
simulate.Add(explainOption);
simulate.Add(failOnDiffOption);
simulate.SetAction((parseResult, _) =>
{
var policyId = parseResult.GetValue(policyIdArgument) ?? string.Empty;
var baseVersion = parseResult.GetValue(baseOption);
var candidateVersion = parseResult.GetValue(candidateOption);
var sbomSet = parseResult.GetValue(sbomOption) ?? Array.Empty<string>();
var environment = parseResult.GetValue(envOption) ?? Array.Empty<string>();
var format = parseResult.GetValue(formatOption);
var output = parseResult.GetValue(outputOption);
var explain = parseResult.GetValue(explainOption);
var failOnDiff = parseResult.GetValue(failOnDiffOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicySimulateAsync(
services,
policyId,
baseVersion,
candidateVersion,
sbomSet,
environment,
format,
output,
explain,
failOnDiff,
verbose,
cancellationToken);
});
policy.Add(simulate);
return policy;
}
private static Command BuildVulnCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var vuln = new Command("vuln", "Explore vulnerability observations and overlays.");
var observations = new Command("observations", "List raw advisory observations for overlay consumers.");
var tenantOption = new Option<string>("--tenant")
{
Description = "Tenant identifier.",
Required = true
};
var observationIdOption = new Option<string[]>("--observation-id")
{
Description = "Filter by observation identifier (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var aliasOption = new Option<string[]>("--alias")
{
Description = "Filter by vulnerability alias (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var purlOption = new Option<string[]>("--purl")
{
Description = "Filter by Package URL (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var cpeOption = new Option<string[]>("--cpe")
{
Description = "Filter by CPE value (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
var jsonOption = new Option<bool>("--json")
{
Description = "Emit raw JSON payload instead of a table."
};
observations.Add(tenantOption);
observations.Add(observationIdOption);
observations.Add(aliasOption);
observations.Add(purlOption);
observations.Add(cpeOption);
observations.Add(jsonOption);
observations.SetAction((parseResult, _) =>
{
var tenant = parseResult.GetValue(tenantOption) ?? string.Empty;
var observationIds = parseResult.GetValue(observationIdOption) ?? Array.Empty<string>();
var aliases = parseResult.GetValue(aliasOption) ?? Array.Empty<string>();
var purls = parseResult.GetValue(purlOption) ?? Array.Empty<string>();
var cpes = parseResult.GetValue(cpeOption) ?? Array.Empty<string>();
var emitJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleVulnObservationsAsync(
services,
tenant,
observationIds,
aliases,
purls,
cpes,
emitJson,
verbose,
cancellationToken);
});
vuln.Add(observations);
return vuln;
}
private static Command BuildConfigCommand(StellaOpsCliOptions options)
{
var config = new Command("config", "Inspect CLI configuration state.");
@@ -333,6 +576,7 @@ internal static class CommandFactory
var lines = new[]
{
$"Backend URL: {MaskIfEmpty(options.BackendUrl)}",
$"Concelier URL: {MaskIfEmpty(options.ConcelierUrl)}",
$"API Key: {DescribeSecret(options.ApiKey)}",
$"Scanner Cache: {options.ScannerCacheDirectory}",
$"Results Directory: {options.ResultsDirectory}",

File diff suppressed because it is too large Load Diff

View File

@@ -25,12 +25,14 @@ public static class CliBootstrapper
};
options.PostBind = (cliOptions, configuration) =>
{
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
cliOptions.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
cliOptions.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
cliOptions.ScannerSignaturePublicKeyPath = cliOptions.ScannerSignaturePublicKeyPath?.Trim() ?? string.Empty;
var attemptsRaw = ResolveWithFallback(

View File

@@ -11,6 +11,8 @@ public sealed class StellaOpsCliOptions
public string BackendUrl { get; set; } = string.Empty;
public string ConcelierUrl { get; set; } = string.Empty;
public string ScannerCacheDirectory { get; set; } = "scanners";
public string ResultsDirectory { get; set; } = "results";

View File

@@ -96,14 +96,24 @@ internal static class Program
{
client.Timeout = TimeSpan.FromMinutes(5);
if (!string.IsNullOrWhiteSpace(options.BackendUrl) &&
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
{
client.BaseAddress = backendUri;
}
});
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var backendUri))
{
client.BaseAddress = backendUri;
}
});
services.AddHttpClient<IConcelierObservationsClient, ConcelierObservationsClient>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) &&
Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var concelierUri))
{
client.BaseAddress = concelierUri;
}
});
services.AddSingleton<IScannerExecutor, ScannerExecutor>();
services.AddSingleton<IScannerInstaller, ScannerInstaller>();
await using var serviceProvider = services.BuildServiceProvider();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();

View File

@@ -467,14 +467,231 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
}
}
var decisionsView = new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions);
return new RuntimePolicyEvaluationResult(
document.TtlSeconds ?? 0,
document.ExpiresAtUtc?.ToUniversalTime(),
string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision,
decisionsView);
}
var decisionsView = new ReadOnlyDictionary<string, RuntimePolicyImageDecision>(decisions);
return new RuntimePolicyEvaluationResult(
document.TtlSeconds ?? 0,
document.ExpiresAtUtc?.ToUniversalTime(),
string.IsNullOrWhiteSpace(document.PolicyRevision) ? null : document.PolicyRevision,
decisionsView);
}
public async Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (input is null)
{
throw new ArgumentNullException(nameof(input));
}
var requestDocument = new PolicySimulationRequestDocument
{
BaseVersion = input.BaseVersion,
CandidateVersion = input.CandidateVersion,
Explain = input.Explain ? true : null
};
if (input.SbomSet.Count > 0)
{
requestDocument.SbomSet = input.SbomSet;
}
if (input.Environment.Count > 0)
{
var environment = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
foreach (var pair in input.Environment)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
environment[pair.Key] = SerializeEnvironmentValue(pair.Value);
}
if (environment.Count > 0)
{
requestDocument.Env = environment;
}
}
var encodedPolicyId = Uri.EscapeDataString(policyId);
using var request = CreateRequest(HttpMethod.Post, $"api/policy/policies/{encodedPolicyId}/simulate");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
request.Content = JsonContent.Create(requestDocument, options: SerializerOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
if (response.Content is null || response.Content.Headers.ContentLength is 0)
{
throw new InvalidOperationException("Policy simulation response was empty.");
}
PolicySimulationResponseDocument? document;
try
{
document = await response.Content.ReadFromJsonAsync<PolicySimulationResponseDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse policy simulation response: {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
if (document is null)
{
throw new InvalidOperationException("Policy simulation response was empty.");
}
if (document.Diff is null)
{
throw new InvalidOperationException("Policy simulation response missing diff summary.");
}
return MapPolicySimulation(document);
}
public async Task<PolicyFindingsPage> GetPolicyFindingsAsync(string policyId, PolicyFindingsQuery query, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (query is null)
{
throw new ArgumentNullException(nameof(query));
}
var encodedPolicyId = Uri.EscapeDataString(policyId.Trim());
var requestPath = new StringBuilder($"api/policy/findings/{encodedPolicyId}");
var queryString = BuildFindingsQueryString(query);
if (!string.IsNullOrEmpty(queryString))
{
requestPath.Append('?').Append(queryString);
}
using var request = CreateRequest(HttpMethod.Get, requestPath.ToString());
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var items = new List<PolicyFinding>();
var root = document.RootElement;
if (root.TryGetProperty("items", out var itemsElement) && itemsElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in itemsElement.EnumerateArray())
{
items.Add(ParsePolicyFinding(item));
}
}
string? nextCursor = null;
if (root.TryGetProperty("nextCursor", out var cursorElement) && cursorElement.ValueKind == JsonValueKind.String)
{
var value = cursorElement.GetString();
nextCursor = string.IsNullOrWhiteSpace(value) ? null : value;
}
return new PolicyFindingsPage(items.AsReadOnly(), nextCursor);
}
public async Task<PolicyFinding> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (string.IsNullOrWhiteSpace(findingId))
{
throw new ArgumentException("Finding identifier must be provided.", nameof(findingId));
}
var encodedPolicyId = Uri.EscapeDataString(policyId.Trim());
var encodedFindingId = Uri.EscapeDataString(findingId.Trim());
var path = $"api/policy/findings/{encodedPolicyId}/{encodedFindingId}";
using var request = CreateRequest(HttpMethod.Get, path);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return ParsePolicyFinding(document.RootElement);
}
public async Task<PolicyFindingExplain> GetPolicyFindingExplainAsync(string policyId, string findingId, bool verbose, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
if (string.IsNullOrWhiteSpace(policyId))
{
throw new ArgumentException("Policy identifier must be provided.", nameof(policyId));
}
if (string.IsNullOrWhiteSpace(findingId))
{
throw new ArgumentException("Finding identifier must be provided.", nameof(findingId));
}
var encodedPolicyId = Uri.EscapeDataString(policyId.Trim());
var encodedFindingId = Uri.EscapeDataString(findingId.Trim());
var mode = verbose ? "verbose" : "summary";
var path = $"api/policy/findings/{encodedPolicyId}/{encodedFindingId}/explain?mode={mode}";
using var request = CreateRequest(HttpMethod.Get, path);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var (message, problem) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
var errorCode = ExtractProblemErrorCode(problem);
throw new PolicyApiException(message, response.StatusCode, errorCode);
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return ParsePolicyFindingExplain(document.RootElement);
}
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
{
@@ -800,6 +1017,37 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
components);
}
public async Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest requestBody, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
ArgumentNullException.ThrowIfNull(requestBody);
using var request = CreateRequest(HttpMethod.Post, "api/aoc/ingest/dry-run");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
request.Content = JsonContent.Create(requestBody, options: SerializerOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
var result = await response.Content.ReadFromJsonAsync<AocIngestDryRunResponse>(SerializerOptions, cancellationToken).ConfigureAwait(false);
return result ?? new AocIngestDryRunResponse();
}
catch (JsonException ex)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse ingest dry-run response. {ex.Message}", ex)
{
Data = { ["payload"] = payload }
};
}
}
private string ResolveOfflineDirectory(string destinationDirectory)
{
if (!string.IsNullOrWhiteSpace(destinationDirectory))
@@ -1501,12 +1749,418 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return null;
}
private void EnsureBackendConfigured()
{
if (_httpClient.BaseAddress is null)
{
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
}
private static string BuildFindingsQueryString(PolicyFindingsQuery query)
{
var parameters = new List<string>();
AppendJoinedParameter(parameters, "sbomId", query.SbomIds);
AppendJoinedParameter(parameters, "status", query.Statuses);
AppendJoinedParameter(parameters, "severity", query.Severities);
AppendSingleParameter(parameters, "cursor", query.Cursor);
if (query.Page.HasValue)
{
AppendSingleParameter(parameters, "page", query.Page.Value.ToString(CultureInfo.InvariantCulture));
}
if (query.PageSize.HasValue)
{
AppendSingleParameter(parameters, "pageSize", query.PageSize.Value.ToString(CultureInfo.InvariantCulture));
}
if (query.Since.HasValue)
{
AppendSingleParameter(parameters, "since", query.Since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
}
return string.Join("&", parameters);
}
private static void AppendJoinedParameter(List<string> parameters, string name, IReadOnlyList<string> values)
{
if (values is null || values.Count == 0)
{
return;
}
var normalized = new List<string>();
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
normalized.Add(Uri.EscapeDataString(value.Trim()));
}
if (normalized.Count == 0)
{
return;
}
parameters.Add($"{name}={string.Join(",", normalized)}");
}
private static void AppendSingleParameter(List<string> parameters, string name, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
parameters.Add($"{name}={Uri.EscapeDataString(value)}");
}
private static PolicyFinding ParsePolicyFinding(JsonElement element)
{
var findingId = TryGetString(element, "findingId") ?? string.Empty;
var status = TryGetString(element, "status") ?? "unknown";
string? severityNormalized = null;
double? severityScore = null;
if (element.TryGetProperty("severity", out var severityElement) && severityElement.ValueKind == JsonValueKind.Object)
{
severityNormalized = TryGetString(severityElement, "normalized");
severityScore = TryGetDouble(severityElement, "score");
}
var sbomId = TryGetString(element, "sbomId");
var policyVersion = TryGetInt(element, "policyVersion");
var updatedAt = TryGetTimestamp(element, "updatedAt");
var quieted = TryGetNullableBoolean(element, "quieted");
var quietedBy = TryGetString(element, "quietedBy");
var environment = TryGetString(element, "environment");
string? vexStatementId = null;
if (element.TryGetProperty("vex", out var vexElement) && vexElement.ValueKind == JsonValueKind.Object)
{
vexStatementId = TryGetString(vexElement, "winningStatementId");
}
var advisoryIds = ExtractStringArray(element, "advisoryIds");
var tags = ExtractStringArray(element, "tags");
return new PolicyFinding(
findingId,
status,
severityNormalized,
severityScore,
sbomId,
policyVersion,
updatedAt,
quieted,
quietedBy,
environment,
vexStatementId,
advisoryIds,
tags,
element.GetRawText());
}
private static PolicyFindingExplain ParsePolicyFindingExplain(JsonElement element)
{
var findingId = TryGetString(element, "findingId") ?? string.Empty;
var policyVersion = TryGetInt(element, "policyVersion");
var steps = new List<PolicyFindingExplainStep>();
if (element.TryGetProperty("steps", out var stepsElement) && stepsElement.ValueKind == JsonValueKind.Array)
{
foreach (var stepElement in stepsElement.EnumerateArray())
{
steps.Add(ParseExplainStep(stepElement));
}
}
var hints = new List<string>();
if (element.TryGetProperty("sealedHints", out var hintsElement) && hintsElement.ValueKind == JsonValueKind.Array)
{
foreach (var hint in hintsElement.EnumerateArray())
{
if (hint.ValueKind == JsonValueKind.String)
{
var value = hint.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
hints.Add(value);
}
}
else if (hint.ValueKind == JsonValueKind.Object && hint.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String)
{
var value = messageElement.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
hints.Add(value);
}
}
}
}
return new PolicyFindingExplain(
findingId,
policyVersion,
steps.AsReadOnly(),
hints.AsReadOnly(),
element.GetRawText());
}
private static PolicyFindingExplainStep ParseExplainStep(JsonElement element)
{
var rule = TryGetString(element, "rule");
var status = TryGetString(element, "status");
var inputs = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, "rule", StringComparison.OrdinalIgnoreCase) ||
string.Equals(property.Name, "status", StringComparison.OrdinalIgnoreCase))
{
continue;
}
inputs[property.Name] = ConvertJsonElement(property.Value);
}
return new PolicyFindingExplainStep(
rule,
status,
new ReadOnlyDictionary<string, object?>(inputs),
element.GetRawText());
}
private static string? TryGetString(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String)
{
var value = property.GetString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
return null;
}
private static double? TryGetDouble(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property))
{
return TryGetDouble(property);
}
return null;
}
private static double? TryGetDouble(JsonElement property)
{
return property.ValueKind switch
{
JsonValueKind.Number => property.TryGetDouble(out var number) ? number : null,
JsonValueKind.String => double.TryParse(property.GetString(), NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null,
_ => null
};
}
private static int? TryGetInt(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property))
{
return TryGetInt(property);
}
return null;
}
private static int? TryGetInt(JsonElement property)
{
return property.ValueKind switch
{
JsonValueKind.Number => property.TryGetInt32(out var number) ? number : null,
JsonValueKind.String => int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: null,
_ => null
};
}
private static bool? TryGetNullableBoolean(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property))
{
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(property.GetString(), out var parsed) ? parsed : null,
_ => null
};
}
return null;
}
private static DateTimeOffset? TryGetTimestamp(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String)
{
return TryParseTimestamp(property.GetString());
}
return null;
}
private static DateTimeOffset? TryParseTimestamp(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var timestamp))
{
return timestamp.ToUniversalTime();
}
return null;
}
private static IReadOnlyList<string> ExtractStringArray(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array)
{
return Array.Empty<string>();
}
var list = new List<string>();
foreach (var item in property.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
list.Add(value);
}
}
}
return list.Count == 0 ? Array.Empty<string>() : new ReadOnlyCollection<string>(list);
}
private static object? ConvertJsonElement(JsonElement element)
=> element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number when element.TryGetInt64(out var l) => l,
JsonValueKind.Number when element.TryGetDouble(out var d) => d,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Object => ConvertObject(element),
JsonValueKind.Array => ConvertArray(element),
_ => element.GetRawText()
};
private static IReadOnlyDictionary<string, object?> ConvertObject(JsonElement element)
{
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
result[property.Name] = ConvertJsonElement(property.Value);
}
return new ReadOnlyDictionary<string, object?>(result);
}
private static IReadOnlyList<object?> ConvertArray(JsonElement element)
{
var list = new List<object?>();
foreach (var item in element.EnumerateArray())
{
list.Add(ConvertJsonElement(item));
}
return new ReadOnlyCollection<object?>(list);
}
private static JsonElement SerializeEnvironmentValue(object? value)
{
if (value is JsonElement element)
{
return element;
}
return JsonSerializer.SerializeToElement<object?>(value, SerializerOptions);
}
private static string? ExtractProblemErrorCode(ProblemDocument? problem)
{
if (problem?.Extensions is null || problem.Extensions.Count == 0)
{
return null;
}
if (problem.Extensions.TryGetValue("code", out var value))
{
switch (value)
{
case string code when !string.IsNullOrWhiteSpace(code):
return code;
case JsonElement element when element.ValueKind == JsonValueKind.String:
var text = element.GetString();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
return null;
}
private static PolicySimulationResult MapPolicySimulation(PolicySimulationResponseDocument document)
{
var diffDocument = document.Diff ?? throw new InvalidOperationException("Policy simulation response missing diff summary.");
var severity = diffDocument.BySeverity is null
? new Dictionary<string, PolicySimulationSeverityDelta>(0, StringComparer.Ordinal)
: diffDocument.BySeverity
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key) && kvp.Value is not null)
.ToDictionary(
kvp => kvp.Key,
kvp => new PolicySimulationSeverityDelta(kvp.Value!.Up, kvp.Value.Down),
StringComparer.Ordinal);
var severityView = new ReadOnlyDictionary<string, PolicySimulationSeverityDelta>(severity);
var ruleHits = diffDocument.RuleHits is null
? new List<PolicySimulationRuleDelta>()
: diffDocument.RuleHits
.Where(hit => hit is not null)
.Select(hit => new PolicySimulationRuleDelta(
hit!.RuleId ?? string.Empty,
hit.RuleName ?? string.Empty,
hit.Up,
hit.Down))
.ToList();
var ruleHitsView = ruleHits.AsReadOnly();
var diff = new PolicySimulationDiff(
string.IsNullOrWhiteSpace(diffDocument.SchemaVersion) ? null : diffDocument.SchemaVersion,
diffDocument.Added ?? 0,
diffDocument.Removed ?? 0,
diffDocument.Unchanged ?? 0,
severityView,
ruleHitsView);
return new PolicySimulationResult(
diff,
string.IsNullOrWhiteSpace(document.ExplainUri) ? null : document.ExplainUri);
}
private void EnsureBackendConfigured()
{
if (_httpClient.BaseAddress is null)
{
throw new InvalidOperationException("Backend URL is not configured. Provide STELLAOPS_BACKEND_URL or configure appsettings.");
}
}
private string ResolveArtifactPath(string outputPath, string channel)
@@ -1525,45 +2179,59 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return Path.Combine(directory, fileName);
}
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var statusCode = (int)response.StatusCode;
var builder = new StringBuilder();
builder.Append("Backend request failed with status ");
builder.Append(statusCode);
builder.Append(' ');
builder.Append(response.ReasonPhrase ?? "Unknown");
if (response.Content.Headers.ContentLength is > 0)
{
try
{
var problem = await response.Content.ReadFromJsonAsync<ProblemDocument>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (problem is not null)
{
if (!string.IsNullOrWhiteSpace(problem.Title))
{
builder.AppendLine().Append(problem.Title);
}
if (!string.IsNullOrWhiteSpace(problem.Detail))
{
builder.AppendLine().Append(problem.Detail);
}
}
}
catch (JsonException)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(raw))
{
builder.AppendLine().Append(raw);
}
}
}
return builder.ToString();
}
private async Task<string> CreateFailureMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var (message, _) = await CreateFailureDetailsAsync(response, cancellationToken).ConfigureAwait(false);
return message;
}
private async Task<(string Message, ProblemDocument? Problem)> CreateFailureDetailsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var statusCode = (int)response.StatusCode;
var builder = new StringBuilder();
builder.Append("Backend request failed with status ");
builder.Append(statusCode);
builder.Append(' ');
builder.Append(response.ReasonPhrase ?? "Unknown");
ProblemDocument? problem = null;
if (response.Content is not null && response.Content.Headers.ContentLength is > 0)
{
string? raw = null;
try
{
raw = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(raw))
{
problem = JsonSerializer.Deserialize<ProblemDocument>(raw, SerializerOptions);
}
}
catch (JsonException)
{
problem = null;
}
if (problem is not null)
{
if (!string.IsNullOrWhiteSpace(problem.Title))
{
builder.AppendLine().Append(problem.Title);
}
if (!string.IsNullOrWhiteSpace(problem.Detail))
{
builder.AppendLine().Append(problem.Detail);
}
}
else if (!string.IsNullOrWhiteSpace(raw))
{
builder.AppendLine().Append(raw);
}
}
return (builder.ToString(), problem);
}
private static string? ExtractHeaderValue(HttpResponseHeaders headers, string name)
{

View File

@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
private readonly HttpClient httpClient;
private readonly StellaOpsCliOptions options;
private readonly ILogger<ConcelierObservationsClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public ConcelierObservationsClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<ConcelierObservationsClient> logger,
IStellaOpsTokenClient? tokenClient = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.tokenClient = tokenClient;
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl) && httpClient.BaseAddress is null)
{
if (Uri.TryCreate(options.ConcelierUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<AdvisoryObservationsResponse> GetObservationsAsync(
AdvisoryObservationsQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
EnsureConfigured();
var requestUri = BuildRequestUri(query);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to query observations (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
response.EnsureSuccessStatusCode();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<AdvisoryObservationsResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new AdvisoryObservationsResponse();
}
private static string BuildRequestUri(AdvisoryObservationsQuery query)
{
var builder = new StringBuilder("/concelier/observations?tenant=");
builder.Append(Uri.EscapeDataString(query.Tenant));
AppendValues(builder, "observationId", query.ObservationIds);
AppendValues(builder, "alias", query.Aliases);
AppendValues(builder, "purl", query.Purls);
AppendValues(builder, "cpe", query.Cpes);
return builder.ToString();
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
{
if (values is null || values.Count == 0)
{
return;
}
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
builder.Append('&');
builder.Append(name);
builder.Append('=');
builder.Append(Uri.EscapeDataString(value));
}
}
}
private void EnsureConfigured()
{
if (!string.IsNullOrWhiteSpace(options.ConcelierUrl))
{
return;
}
throw new InvalidOperationException(
"ConcelierUrl is not configured. Set StellaOps:ConcelierUrl or STELLAOPS_CONCELIER_URL.");
}
private async Task AuthorizeRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await ResolveAccessTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
private async Task<string?> ResolveAccessTokenAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(options.ApiKey))
{
return options.ApiKey;
}
if (tokenClient is null || string.IsNullOrWhiteSpace(options.Authority.Url))
{
return null;
}
var now = DateTimeOffset.UtcNow;
lock (tokenSync)
{
if (!string.IsNullOrEmpty(cachedAccessToken) && now < cachedAccessTokenExpiresAt - TokenRefreshSkew)
{
return cachedAccessToken;
}
}
var (scope, cacheKey) = BuildScopeAndCacheKey(options);
var cachedEntry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cachedEntry is not null && now < cachedEntry.ExpiresAtUtc - TokenRefreshSkew)
{
lock (tokenSync)
{
cachedAccessToken = cachedEntry.AccessToken;
cachedAccessTokenExpiresAt = cachedEntry.ExpiresAtUtc;
return cachedAccessToken;
}
}
StellaOpsTokenResult token;
if (!string.IsNullOrWhiteSpace(options.Authority.Username))
{
if (string.IsNullOrWhiteSpace(options.Authority.Password))
{
throw new InvalidOperationException("Authority password must be configured when username is provided.");
}
token = await tokenClient.RequestPasswordTokenAsync(
options.Authority.Username,
options.Authority.Password!,
scope,
cancellationToken).ConfigureAwait(false);
}
else
{
token = await tokenClient.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false);
}
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = token.AccessToken;
cachedAccessTokenExpiresAt = token.ExpiresAtUtc;
return cachedAccessToken;
}
}
private static (string Scope, string CacheKey) BuildScopeAndCacheKey(StellaOpsCliOptions options)
{
var baseScope = AuthorityTokenUtilities.ResolveScope(options);
var finalScope = EnsureScope(baseScope, StellaOpsScopes.VulnRead);
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
? $"user:{options.Authority.Username}"
: $"client:{options.Authority.ClientId}";
var cacheKey = $"{options.Authority.Url}|{credential}|{finalScope}";
return (finalScope, cacheKey);
}
private static string EnsureScope(string scopes, string required)
{
if (string.IsNullOrWhiteSpace(scopes))
{
return required;
}
var parts = scopes
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static scope => scope.ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.ToList();
if (!parts.Contains(required, StringComparer.Ordinal))
{
parts.Add(required);
}
return string.Join(' ', parts);
}
}

View File

@@ -23,9 +23,19 @@ internal interface IBackendOperationsClient
Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken);
Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken);
Task<PolicyFindingsPage> GetPolicyFindingsAsync(string policyId, PolicyFindingsQuery query, CancellationToken cancellationToken);
Task<PolicyFinding> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken);
Task<PolicyFindingExplain> GetPolicyFindingExplainAsync(string policyId, string findingId, bool verbose, CancellationToken cancellationToken);
Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken);
Task<OfflineKitImportResult> ImportOfflineKitAsync(OfflineKitImportRequest request, CancellationToken cancellationToken);
Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken);
Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
internal interface IConcelierObservationsClient
{
Task<AdvisoryObservationsResponse> GetObservationsAsync(
AdvisoryObservationsQuery query,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
internal sealed record AdvisoryObservationsQuery(
string Tenant,
IReadOnlyList<string> ObservationIds,
IReadOnlyList<string> Aliases,
IReadOnlyList<string> Purls,
IReadOnlyList<string> Cpes);
internal sealed class AdvisoryObservationsResponse
{
[JsonPropertyName("observations")]
public IReadOnlyList<AdvisoryObservationDocument> Observations { get; init; } =
Array.Empty<AdvisoryObservationDocument>();
[JsonPropertyName("linkset")]
public AdvisoryObservationLinksetAggregate Linkset { get; init; } =
new();
}
internal sealed class AdvisoryObservationDocument
{
[JsonPropertyName("observationId")]
public string ObservationId { get; init; } = string.Empty;
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("source")]
public AdvisoryObservationSource Source { get; init; } = new();
[JsonPropertyName("upstream")]
public AdvisoryObservationUpstream Upstream { get; init; } = new();
[JsonPropertyName("linkset")]
public AdvisoryObservationLinkset Linkset { get; init; } = new();
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; init; }
}
internal sealed class AdvisoryObservationSource
{
[JsonPropertyName("vendor")]
public string Vendor { get; init; } = string.Empty;
[JsonPropertyName("stream")]
public string Stream { get; init; } = string.Empty;
[JsonPropertyName("api")]
public string Api { get; init; } = string.Empty;
[JsonPropertyName("collectorVersion")]
public string? CollectorVersion { get; init; }
}
internal sealed class AdvisoryObservationUpstream
{
[JsonPropertyName("upstreamId")]
public string UpstreamId { get; init; } = string.Empty;
[JsonPropertyName("documentVersion")]
public string? DocumentVersion { get; init; }
}
internal sealed class AdvisoryObservationLinkset
{
[JsonPropertyName("aliases")]
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
[JsonPropertyName("purls")]
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
[JsonPropertyName("cpes")]
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
[JsonPropertyName("references")]
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
Array.Empty<AdvisoryObservationReference>();
}
internal sealed class AdvisoryObservationReference
{
[JsonPropertyName("type")]
public string Type { get; init; } = string.Empty;
[JsonPropertyName("url")]
public string Url { get; init; } = string.Empty;
}
internal sealed class AdvisoryObservationLinksetAggregate
{
[JsonPropertyName("aliases")]
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
[JsonPropertyName("purls")]
public IReadOnlyList<string> Purls { get; init; } = Array.Empty<string>();
[JsonPropertyName("cpes")]
public IReadOnlyList<string> Cpes { get; init; } = Array.Empty<string>();
[JsonPropertyName("references")]
public IReadOnlyList<AdvisoryObservationReference> References { get; init; } =
Array.Empty<AdvisoryObservationReference>();
}

View File

@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
internal sealed class AocIngestDryRunRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("source")]
public string Source { get; init; } = string.Empty;
[JsonPropertyName("document")]
public AocIngestDryRunDocument Document { get; init; } = new();
}
internal sealed class AocIngestDryRunDocument
{
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("content")]
public string Content { get; init; } = string.Empty;
[JsonPropertyName("contentType")]
public string ContentType { get; init; } = "application/json";
[JsonPropertyName("contentEncoding")]
public string? ContentEncoding { get; init; }
}
internal sealed class AocIngestDryRunResponse
{
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("guardVersion")]
public string? GuardVersion { get; init; }
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("document")]
public AocIngestDryRunDocumentResult Document { get; init; } = new();
[JsonPropertyName("violations")]
public IReadOnlyList<AocIngestDryRunViolation> Violations { get; init; } =
Array.Empty<AocIngestDryRunViolation>();
}
internal sealed class AocIngestDryRunDocumentResult
{
[JsonPropertyName("contentHash")]
public string? ContentHash { get; init; }
[JsonPropertyName("supersedes")]
public string? Supersedes { get; init; }
[JsonPropertyName("provenance")]
public AocIngestDryRunProvenance Provenance { get; init; } = new();
}
internal sealed class AocIngestDryRunProvenance
{
[JsonPropertyName("signature")]
public AocIngestDryRunSignature Signature { get; init; } = new();
}
internal sealed class AocIngestDryRunSignature
{
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("present")]
public bool Present { get; init; }
}
internal sealed class AocIngestDryRunViolation
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("path")]
public string? Path { get; init; }
}

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record PolicyFindingsQuery(
IReadOnlyList<string> SbomIds,
IReadOnlyList<string> Statuses,
IReadOnlyList<string> Severities,
string? Cursor,
int? Page,
int? PageSize,
DateTimeOffset? Since);
internal sealed record PolicyFindingsPage(
IReadOnlyList<PolicyFinding> Items,
string? NextCursor);
internal sealed record PolicyFinding(
string FindingId,
string Status,
string? SeverityNormalized,
double? SeverityScore,
string? SbomId,
int? PolicyVersion,
DateTimeOffset? UpdatedAt,
bool? Quieted,
string? QuietedBy,
string? Environment,
string? VexStatementId,
IReadOnlyList<string> AdvisoryIds,
IReadOnlyList<string> Tags,
string RawJson);
internal sealed record PolicyFindingExplain(
string FindingId,
int? PolicyVersion,
IReadOnlyList<PolicyFindingExplainStep> Steps,
IReadOnlyList<string> SealedHints,
string RawJson);
internal sealed record PolicyFindingExplainStep(
string? Rule,
string? Status,
IReadOnlyDictionary<string, object?> Inputs,
string RawJson);

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record PolicySimulationInput(
int? BaseVersion,
int? CandidateVersion,
IReadOnlyList<string> SbomSet,
IReadOnlyDictionary<string, object?> Environment,
bool Explain);
internal sealed record PolicySimulationResult(
PolicySimulationDiff Diff,
string? ExplainUri);
internal sealed record PolicySimulationDiff(
string? SchemaVersion,
int Added,
int Removed,
int Unchanged,
IReadOnlyDictionary<string, PolicySimulationSeverityDelta> BySeverity,
IReadOnlyList<PolicySimulationRuleDelta> RuleHits);
internal sealed record PolicySimulationSeverityDelta(int? Up, int? Down);
internal sealed record PolicySimulationRuleDelta(string RuleId, string RuleName, int? Up, int? Down);

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class PolicySimulationRequestDocument
{
public int? BaseVersion { get; set; }
public int? CandidateVersion { get; set; }
public IReadOnlyList<string>? SbomSet { get; set; }
public Dictionary<string, JsonElement>? Env { get; set; }
public bool? Explain { get; set; }
}
internal sealed class PolicySimulationResponseDocument
{
public PolicySimulationDiffDocument? Diff { get; set; }
public string? ExplainUri { get; set; }
}
internal sealed class PolicySimulationDiffDocument
{
public string? SchemaVersion { get; set; }
public int? Added { get; set; }
public int? Removed { get; set; }
public int? Unchanged { get; set; }
public Dictionary<string, PolicySimulationSeverityDeltaDocument>? BySeverity { get; set; }
public List<PolicySimulationRuleDeltaDocument>? RuleHits { get; set; }
}
internal sealed class PolicySimulationSeverityDeltaDocument
{
public int? Up { get; set; }
public int? Down { get; set; }
}
internal sealed class PolicySimulationRuleDeltaDocument
{
public string? RuleId { get; set; }
public string? RuleName { get; set; }
public int? Up { get; set; }
public int? Down { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Net;
namespace StellaOps.Cli.Services;
internal sealed class PolicyApiException : Exception
{
public PolicyApiException(string message, HttpStatusCode statusCode, string? errorCode, Exception? innerException = null)
: base(message, innerException)
{
StatusCode = statusCode;
ErrorCode = errorCode;
}
public HttpStatusCode StatusCode { get; }
public string? ErrorCode { get; }
}

View File

@@ -1,8 +1,10 @@
# CLI Task Board — Epic 1: Aggregation-Only Contract
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-AOC-19-001 | TODO | DevEx/CLI Guild | CONCELIER-WEB-AOC-19-001, EXCITITOR-WEB-AOC-19-001 | Implement `stella sources ingest --dry-run` printing would-write payloads with forbidden field scan results and guard status. | Command displays diff-safe JSON, highlights forbidden fields, exits non-zero on guard violation, and has unit tests. |
| CLI-AOC-19-001 | DOING (2025-10-27) | DevEx/CLI Guild | CONCELIER-WEB-AOC-19-001, EXCITITOR-WEB-AOC-19-001 | Implement `stella sources ingest --dry-run` printing would-write payloads with forbidden field scan results and guard status. | Command displays diff-safe JSON, highlights forbidden fields, exits non-zero on guard violation, and has unit tests. |
> Docs ready (2025-10-26): Reference behaviour/spec in `docs/cli/cli-reference.md` §2 and AOC reference §5.
> 2025-10-27: CLI command scaffolded with backend client call, JSON/table output, gzip/base64 normalisation, and exit-code mapping. Awaiting Concelier dry-run endpoint + integration tests once backend lands.
> 2025-10-27: Progress paused before adding CLI unit tests; blocked on extending `StubBackendClient` + fixtures for `ExecuteAocIngestDryRunAsync` coverage.
| CLI-AOC-19-002 | TODO | DevEx/CLI Guild | CLI-AOC-19-001 | Add `stella aoc verify` command supporting `--since`/`--limit`, mapping `ERR_AOC_00x` to exit codes, with JSON/table output. | Command integrates with both services, exit codes documented, regression tests green. |
> Docs ready (2025-10-26): CLI guide §3 covers options/exit codes; deployment doc `docs/deploy/containers.md` describes required verifier user.
| CLI-AOC-19-003 | TODO | Docs/CLI Guild | CLI-AOC-19-001, CLI-AOC-19-002 | Update CLI reference and quickstart docs to cover new commands, exit codes, and offline verification workflows. | Docs updated; examples recorded; release notes mention new commands. |
@@ -13,9 +15,12 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-POLICY-20-001 | TODO | DevEx/CLI Guild | WEB-POLICY-20-001 | Add `stella policy new|edit|submit|approve` commands with local editor integration, version pinning, and approval workflow wiring. | Commands round-trip policy drafts with temp files; approval requires correct scopes; unit tests cover happy/error paths. |
| CLI-POLICY-20-002 | TODO | DevEx/CLI Guild | CLI-POLICY-20-001, WEB-POLICY-20-001, WEB-POLICY-20-002 | Implement `stella policy simulate` with SBOM/env arguments and diff output (table/JSON), handling exit codes for `ERR_POL_*`. | Simulation outputs deterministic diffs; JSON schema documented; tests validate exit codes + piping of env variables. |
| CLI-POLICY-20-002 | DONE (2025-10-27) | DevEx/CLI Guild | CLI-POLICY-20-001, WEB-POLICY-20-001, WEB-POLICY-20-002 | Implement `stella policy simulate` with SBOM/env arguments and diff output (table/JSON), handling exit codes for `ERR_POL_*`. | Simulation outputs deterministic diffs; JSON schema documented; tests validate exit codes + piping of env variables. |
> 2025-10-26: Scheduler Models expose canonical run/diff schemas (`src/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`). Schema exporter lives at `scripts/export-policy-schemas.sh`; wire schema validation once DevOps publishes artifacts (see DEVOPS-POLICY-20-004).
> 2025-10-27: DevOps pipeline now publishes `policy-schema-exports` artefacts per commit (see `.gitea/workflows/build-test-deploy.yml`); Slack `#policy-engine` alerts trigger on schema diffs. Pull the JSON from the CI artifact instead of committing local copies.
> 2025-10-27: CLI command supports table/JSON output, environment parsing, `--fail-on-diff`, and maps `ERR_POL_*` to exit codes; tested in `StellaOps.Cli.Tests` against stubbed backend.
| CLI-POLICY-20-003 | TODO | DevEx/CLI Guild, Docs Guild | CLI-POLICY-20-002, WEB-POLICY-20-003, DOCS-POLICY-20-006 | Extend `stella findings ls|get` commands for policy-filtered retrieval with pagination, severity filters, and explain output. | Commands stream paginated results; explain view renders rationale entries; docs/help updated; end-to-end tests cover filters. |
> 2025-10-27: Work paused after stubbing backend parsing helpers; command wiring/tests still pending. Resume by finishing backend query serialization + CLI output paths.
## Graph Explorer v1
@@ -61,9 +66,13 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-POLICY-27-001 | TODO | DevEx/CLI Guild | REGISTRY-API-27-001, WEB-POLICY-27-001 | Implement policy workspace commands (`stella policy init`, `edit`, `lint`, `compile`, `test`) with template selection, local cache, JSON output, and deterministic temp directories. | Commands operate offline with cached templates; diagnostics mirror API responses; unit tests cover happy/error paths; help text updated. |
> Docs dependency: `DOCS-POLICY-27-007` blocked until CLI commands + help output land.
| CLI-POLICY-27-002 | TODO | DevEx/CLI Guild | REGISTRY-API-27-006, WEB-POLICY-27-002 | Add submission/review workflow commands (`stella policy version bump`, `submit`, `review comment`, `approve`, `reject`) supporting reviewer assignment, changelog capture, and exit codes. | Workflow commands enforce required approvers; comments upload correctly; integration tests cover approval failure; docs updated. |
> Docs dependency: `DOCS-POLICY-27-007` and `DOCS-POLICY-27-006` require review/promotion CLI flows.
| CLI-POLICY-27-003 | TODO | DevEx/CLI Guild | REGISTRY-API-27-005, SCHED-CONSOLE-27-001 | Implement `stella policy simulate` enhancements (quick vs batch, SBOM selectors, heatmap summary, manifest download) with `--json` and Markdown report output for CI. | CLI can trigger batch sim, poll progress, download artifacts; outputs deterministic schemas; CI sample workflow documented; tests cover cancellation/timeouts. |
> Docs dependency: `DOCS-POLICY-27-004` needs simulate CLI examples.
| CLI-POLICY-27-004 | TODO | DevEx/CLI Guild | REGISTRY-API-27-007, REGISTRY-API-27-008, AUTH-POLICY-27-002 | Add lifecycle commands for publish/promote/rollback/sign (`stella policy publish --sign`, `promote --env`, `rollback`) with attestation verification and canary arguments. | Commands enforce signing requirement, support dry-run, produce audit logs; integration tests cover promotion + rollback; documentation updated. |
> Docs dependency: `DOCS-POLICY-27-006` requires publish/promote/rollback CLI examples.
| CLI-POLICY-27-005 | TODO | DevEx/CLI Guild, Docs Guild | DOCS-CONSOLE-27-007, DOCS-POLICY-27-007 | Update CLI reference and samples for Policy Studio including JSON schemas, exit codes, and CI snippets. | CLI docs merged with screenshots/transcripts; parity matrix updated; acceptance tests ensure `--help` examples compile. |
## Vulnerability Explorer (Sprint 29)

View File

@@ -12,6 +12,8 @@ internal static class CliMetrics
private static readonly Counter<long> ScanRunCounter = Meter.CreateCounter<long>("stellaops.cli.scan.run.count");
private static readonly Counter<long> OfflineKitDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.download.count");
private static readonly Counter<long> OfflineKitImportCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.import.count");
private static readonly Counter<long> PolicySimulationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.simulate.count");
private static readonly Counter<long> SourcesDryRunCounter = Meter.CreateCounter<long>("stellaops.cli.sources.dryrun.count");
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
public static void RecordScannerDownload(string channel, bool fromCache)
@@ -44,6 +46,18 @@ internal static class CliMetrics
new("status", string.IsNullOrWhiteSpace(status) ? "queued" : status)
});
public static void RecordPolicySimulation(string outcome)
=> PolicySimulationCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordSourcesDryRun(string status)
=> SourcesDryRunCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("status", string.IsNullOrWhiteSpace(status) ? "unknown" : status)
});
public static IDisposable MeasureCommandDuration(string command)
{
var start = DateTime.UtcNow;

View File

@@ -1,8 +1,9 @@
{
"StellaOps": {
"ApiKey": "",
"BackendUrl": "",
"ScannerCacheDirectory": "scanners",
"ApiKey": "",
"BackendUrl": "",
"ConcelierUrl": "",
"ScannerCacheDirectory": "scanners",
"ResultsDirectory": "results",
"DefaultRunner": "dotnet",
"ScannerSignaturePublicKeyPath": "",

View File

@@ -0,0 +1,231 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Models.Observations;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Observations;
public sealed class AdvisoryObservationQueryServiceTests
{
private static readonly AdvisoryObservationSource DefaultSource = new("ghsa", "stream", "https://example.test/api");
private static readonly AdvisoryObservationSignature DefaultSignature = new(false, null, null, null);
[Fact]
public async Task QueryAsync_WhenNoFilters_ReturnsTenantObservationsSortedAndAggregated()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "Tenant-A",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:npm/package-a@1.0.0" },
cpes: new[] { "cpe:/a:vendor:product:1.0" },
references: new[]
{
new AdvisoryObservationReference("advisory", "https://example.test/advisory-1")
},
createdAt: DateTimeOffset.UtcNow.AddMinutes(-5)),
CreateObservation(
observationId: "tenant-a:osv:beta:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0002", "GHSA-xyzz" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: Array.Empty<string>(),
references: new[]
{
new AdvisoryObservationReference("advisory", "https://example.test/advisory-2"),
new AdvisoryObservationReference("patch", "https://example.test/patch-1")
},
createdAt: DateTimeOffset.UtcNow)
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var result = await service.QueryAsync(new AdvisoryObservationQueryOptions("tenant-a"), CancellationToken.None);
Assert.Equal(2, result.Observations.Length);
Assert.Equal("tenant-a:osv:beta:1", result.Observations[0].ObservationId);
Assert.Equal("tenant-a:ghsa:alpha:1", result.Observations[1].ObservationId);
Assert.Equal(
new[] { "cve-2025-0001", "cve-2025-0002", "ghsa-xyzz" },
result.Linkset.Aliases);
Assert.Equal(
new[] { "pkg:npm/package-a@1.0.0", "pkg:pypi/package-b@2.0.0" },
result.Linkset.Purls);
Assert.Equal(new[] { "cpe:/a:vendor:product:1.0" }, result.Linkset.Cpes);
Assert.Equal(3, result.Linkset.References.Length);
Assert.Equal("advisory", result.Linkset.References[0].Type);
Assert.Equal("https://example.test/advisory-1", result.Linkset.References[0].Url);
Assert.Equal("https://example.test/advisory-2", result.Linkset.References[1].Url);
Assert.Equal("patch", result.Linkset.References[2].Type);
}
[Fact]
public async Task QueryAsync_WithAliasFilter_UsesAliasLookupAndFilters()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow),
CreateObservation(
observationId: "tenant-a:nvd:gamma:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-9999" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-10))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var result = await service.QueryAsync(
new AdvisoryObservationQueryOptions("TEnant-A", aliases: new[] { " CVE-2025-0001 ", "CVE-2025-9999" }),
CancellationToken.None);
Assert.Equal(2, result.Observations.Length);
Assert.All(result.Observations, observation =>
Assert.Contains(observation.Linkset.Aliases, alias => alias is "cve-2025-0001" or "cve-2025-9999"));
}
[Fact]
public async Task QueryAsync_WithObservationIdAndLinksetFilters_ReturnsIntersection()
{
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:ghsa:alpha:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:npm/package-a@1.0.0" },
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow),
CreateObservation(
observationId: "tenant-a:ghsa:beta:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: new[] { "cpe:/a:vendor:product:2.0" },
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: DateTimeOffset.UtcNow.AddMinutes(-1))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var options = new AdvisoryObservationQueryOptions(
tenant: "tenant-a",
observationIds: new[] { "tenant-a:ghsa:beta:1" },
aliases: new[] { "CVE-2025-0001" },
purls: new[] { "pkg:pypi/package-b@2.0.0" },
cpes: new[] { "cpe:/a:vendor:product:2.0" });
var result = await service.QueryAsync(options, CancellationToken.None);
Assert.Single(result.Observations);
Assert.Equal("tenant-a:ghsa:beta:1", result.Observations[0].ObservationId);
Assert.Equal(new[] { "pkg:pypi/package-b@2.0.0" }, result.Linkset.Purls);
Assert.Equal(new[] { "cpe:/a:vendor:product:2.0" }, result.Linkset.Cpes);
}
private static AdvisoryObservation CreateObservation(
string observationId,
string tenant,
IEnumerable<string> aliases,
IEnumerable<string> purls,
IEnumerable<string> cpes,
IEnumerable<AdvisoryObservationReference> references,
DateTimeOffset createdAt)
{
var raw = JsonNode.Parse("""{"message":"payload"}""") ?? throw new InvalidOperationException("Raw payload must not be null.");
var upstream = new AdvisoryObservationUpstream(
upstreamId: observationId,
documentVersion: null,
fetchedAt: createdAt,
receivedAt: createdAt,
contentHash: $"sha256:{observationId}",
signature: DefaultSignature);
var content = new AdvisoryObservationContent("CSAF", "2.0", raw);
var linkset = new AdvisoryObservationLinkset(aliases, purls, cpes, references);
return new AdvisoryObservation(
observationId,
tenant,
DefaultSource,
upstream,
content,
linkset,
createdAt);
}
private sealed class InMemoryLookup : IAdvisoryObservationLookup
{
private readonly ImmutableDictionary<string, ImmutableArray<AdvisoryObservation>> _observationsByTenant;
public InMemoryLookup(IEnumerable<AdvisoryObservation> observations)
{
ArgumentNullException.ThrowIfNull(observations);
_observationsByTenant = observations
.GroupBy(static observation => observation.Tenant, StringComparer.Ordinal)
.ToImmutableDictionary(
static group => group.Key,
static group => group.ToImmutableArray(),
StringComparer.Ordinal);
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
cancellationToken.ThrowIfCancellationRequested();
if (_observationsByTenant.TryGetValue(tenant, out var observations))
{
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(observations);
}
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByAliasesAsync(
string tenant,
IReadOnlyCollection<string> aliases,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(aliases);
cancellationToken.ThrowIfCancellationRequested();
if (!_observationsByTenant.TryGetValue(tenant, out var observations) || aliases.Count == 0)
{
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
var aliasSet = aliases.ToImmutableHashSet(StringComparer.Ordinal);
var matches = observations
.Where(observation => observation.Linkset.Aliases.Any(aliasSet.Contains))
.ToImmutableArray();
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(matches);
}
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.Core.Observations;
/// <summary>
/// Query options for retrieving advisory observations scoped to a tenant.
/// </summary>
public sealed record AdvisoryObservationQueryOptions
{
public AdvisoryObservationQueryOptions(
string tenant,
IReadOnlyCollection<string>? observationIds = null,
IReadOnlyCollection<string>? aliases = null,
IReadOnlyCollection<string>? purls = null,
IReadOnlyCollection<string>? cpes = null)
{
Tenant = Validation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
ObservationIds = observationIds ?? Array.Empty<string>();
Aliases = aliases ?? Array.Empty<string>();
Purls = purls ?? Array.Empty<string>();
Cpes = cpes ?? Array.Empty<string>();
}
/// <summary>
/// Tenant identifier used for scoping queries (case-insensitive).
/// </summary>
public string Tenant { get; }
/// <summary>
/// Optional set of observation identifiers to include.
/// </summary>
public IReadOnlyCollection<string> ObservationIds { get; }
/// <summary>
/// Optional set of alias identifiers (e.g., CVE/GHSA) to filter by.
/// </summary>
public IReadOnlyCollection<string> Aliases { get; }
/// <summary>
/// Optional set of Package URLs to filter by.
/// </summary>
public IReadOnlyCollection<string> Purls { get; }
/// <summary>
/// Optional set of CPE values to filter by.
/// </summary>
public IReadOnlyCollection<string> Cpes { get; }
}
/// <summary>
/// Query result containing observations and their aggregated linkset hints.
/// </summary>
public sealed record AdvisoryObservationQueryResult(
ImmutableArray<AdvisoryObservation> Observations,
AdvisoryObservationLinksetAggregate Linkset);
/// <summary>
/// Aggregated linkset built from the observations returned by a query.
/// </summary>
public sealed record AdvisoryObservationLinksetAggregate(
ImmutableArray<string> Aliases,
ImmutableArray<string> Purls,
ImmutableArray<string> Cpes,
ImmutableArray<AdvisoryObservationReference> References);

View File

@@ -0,0 +1,164 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.Core.Observations;
/// <summary>
/// Default implementation of <see cref="IAdvisoryObservationQueryService"/> that projects raw observations for overlay consumers.
/// </summary>
public sealed class AdvisoryObservationQueryService : IAdvisoryObservationQueryService
{
private readonly IAdvisoryObservationLookup _lookup;
public AdvisoryObservationQueryService(IAdvisoryObservationLookup lookup)
{
_lookup = lookup ?? throw new ArgumentNullException(nameof(lookup));
}
public async ValueTask<AdvisoryObservationQueryResult> QueryAsync(
AdvisoryObservationQueryOptions options,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
var normalizedTenant = NormalizeTenant(options.Tenant);
var normalizedObservationIds = NormalizeSet(options.ObservationIds, static value => value, StringComparer.Ordinal);
var normalizedAliases = NormalizeSet(options.Aliases, static value => value.ToLowerInvariant(), StringComparer.Ordinal);
var normalizedPurls = NormalizeSet(options.Purls, static value => value, StringComparer.Ordinal);
var normalizedCpes = NormalizeSet(options.Cpes, static value => value, StringComparer.Ordinal);
IReadOnlyList<AdvisoryObservation> observations;
if (normalizedAliases.Count > 0)
{
observations = await _lookup
.FindByAliasesAsync(normalizedTenant, normalizedAliases, cancellationToken)
.ConfigureAwait(false);
}
else
{
observations = await _lookup
.ListByTenantAsync(normalizedTenant, cancellationToken)
.ConfigureAwait(false);
}
var matched = observations
.Where(observation => Matches(observation, normalizedObservationIds, normalizedAliases, normalizedPurls, normalizedCpes))
.OrderByDescending(static observation => observation.CreatedAt)
.ThenBy(static observation => observation.ObservationId, StringComparer.Ordinal)
.ToImmutableArray();
var linkset = BuildAggregateLinkset(matched);
return new AdvisoryObservationQueryResult(matched, linkset);
}
private static bool Matches(
AdvisoryObservation observation,
ImmutableHashSet<string> observationIds,
ImmutableHashSet<string> aliases,
ImmutableHashSet<string> purls,
ImmutableHashSet<string> cpes)
{
ArgumentNullException.ThrowIfNull(observation);
if (observationIds.Count > 0 && !observationIds.Contains(observation.ObservationId))
{
return false;
}
if (aliases.Count > 0 && !observation.Linkset.Aliases.Any(aliases.Contains))
{
return false;
}
if (purls.Count > 0 && !observation.Linkset.Purls.Any(purls.Contains))
{
return false;
}
if (cpes.Count > 0 && !observation.Linkset.Cpes.Any(cpes.Contains))
{
return false;
}
return true;
}
private static string NormalizeTenant(string tenant)
=> Validation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant)).ToLowerInvariant();
private static ImmutableHashSet<string> NormalizeSet(
IEnumerable<string>? values,
Func<string, string> projector,
StringComparer comparer)
{
if (values is null)
{
return ImmutableHashSet<string>.Empty;
}
var builder = ImmutableHashSet.CreateBuilder<string>(comparer);
foreach (var value in values)
{
var normalized = Validation.TrimToNull(value);
if (normalized is null)
{
continue;
}
builder.Add(projector(normalized));
}
return builder.ToImmutable();
}
private static AdvisoryObservationLinksetAggregate BuildAggregateLinkset(ImmutableArray<AdvisoryObservation> observations)
{
if (observations.IsDefaultOrEmpty)
{
return new AdvisoryObservationLinksetAggregate(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<AdvisoryObservationReference>.Empty);
}
var aliasSet = new HashSet<string>(StringComparer.Ordinal);
var purlSet = new HashSet<string>(StringComparer.Ordinal);
var cpeSet = new HashSet<string>(StringComparer.Ordinal);
var referenceSet = new HashSet<AdvisoryObservationReference>();
foreach (var observation in observations)
{
foreach (var alias in observation.Linkset.Aliases)
{
aliasSet.Add(alias);
}
foreach (var purl in observation.Linkset.Purls)
{
purlSet.Add(purl);
}
foreach (var cpe in observation.Linkset.Cpes)
{
cpeSet.Add(cpe);
}
foreach (var reference in observation.Linkset.References)
{
referenceSet.Add(reference);
}
}
return new AdvisoryObservationLinksetAggregate(
aliasSet.OrderBy(static alias => alias, StringComparer.Ordinal).ToImmutableArray(),
purlSet.OrderBy(static purl => purl, StringComparer.Ordinal).ToImmutableArray(),
cpeSet.OrderBy(static cpe => cpe, StringComparer.Ordinal).ToImmutableArray(),
referenceSet
.OrderBy(static reference => reference.Type, StringComparer.Ordinal)
.ThenBy(static reference => reference.Url, StringComparer.Ordinal)
.ToImmutableArray());
}
}

View File

@@ -0,0 +1,29 @@
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.Core.Observations;
/// <summary>
/// Abstraction over the advisory observation persistence layer used for overlay queries.
/// </summary>
public interface IAdvisoryObservationLookup
{
/// <summary>
/// Lists all advisory observations for the provided tenant.
/// </summary>
/// <param name="tenant">Tenant identifier (case-insensitive).</param>
/// <param name="cancellationToken">A cancellation token.</param>
ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken);
/// <summary>
/// Finds advisory observations for a tenant that match at least one of the supplied aliases.
/// </summary>
/// <param name="tenant">Tenant identifier (case-insensitive).</param>
/// <param name="aliases">Normalized alias values to match against.</param>
/// <param name="cancellationToken">A cancellation token.</param>
ValueTask<IReadOnlyList<AdvisoryObservation>> FindByAliasesAsync(
string tenant,
IReadOnlyCollection<string> aliases,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Concelier.Core.Observations;
/// <summary>
/// Provides read-only access to advisory observations for overlay services.
/// </summary>
public interface IAdvisoryObservationQueryService
{
/// <summary>
/// Queries advisory observations scoped by tenant and optional linkset filters.
/// </summary>
/// <param name="options">Query options defining tenant and filter criteria.</param>
/// <param name="cancellationToken">A cancellation token.</param>
ValueTask<AdvisoryObservationQueryResult> QueryAsync(
AdvisoryObservationQueryOptions options,
CancellationToken cancellationToken);
}

View File

@@ -4,6 +4,7 @@
|---|---|---|---|---|
| CONCELIER-CORE-AOC-19-001 `AOC write guard` | TODO | Concelier Core Guild | WEB-AOC-19-001 | Implement repository interceptor that inspects write payloads for forbidden AOC keys, validates provenance/signature presence, and maps violations to `ERR_AOC_00x`. |
> Docs alignment (2025-10-26): Behaviour/spec captured in `docs/ingestion/aggregation-only-contract.md` and architecture overview §2.
> Coordination (2025-10-27): Authority `dotnet test` run is currently blocked because `AdvisoryObservationQueryService.BuildAliasLookup` returns `ImmutableHashSet<string?>`; please normalise these lookups to `ImmutableHashSet<string>` (trim nulls) so downstream builds succeed.
| CONCELIER-CORE-AOC-19-002 `Deterministic linkset extraction` | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Build canonical linkset mappers for CVE/GHSA/PURL/CPE/reference extraction from upstream raw payloads, ensuring reconciled-from metadata is tracked and deterministic. |
> Docs alignment (2025-10-26): Linkset expectations detailed in AOC reference §4 and policy-engine architecture §2.1.
| CONCELIER-CORE-AOC-19-003 `Idempotent append-only upsert` | TODO | Concelier Core Guild | CONCELIER-STORE-AOC-19-002 | Implement idempotent upsert path using `(vendor, upstreamId, contentHash, tenant)` key, emitting supersedes pointers for new revisions and preventing duplicate inserts. |
@@ -22,16 +23,18 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| CONCELIER-GRAPH-21-001 `SBOM projection enrichment` | TODO | Concelier Core Guild, Cartographer Guild | CONCELIER-POLICY-20-002, CARTO-GRAPH-21-002 | Extend SBOM normalization to emit full relationship graph (depends_on/contains/provides), scope tags, entrypoint annotations, and component metadata required by Cartographer. |
| CONCELIER-GRAPH-21-002 `Change events` | TODO | Concelier Core Guild, Scheduler Guild | CONCELIER-GRAPH-21-001 | Publish change events (new SBOM version, relationship delta) for Cartographer build queue; ensure events include tenant/context metadata. |
| CONCELIER-GRAPH-21-001 `SBOM projection enrichment` | BLOCKED (2025-10-27) | Concelier Core Guild, Cartographer Guild | CONCELIER-POLICY-20-002, CARTO-GRAPH-21-002 | Extend SBOM normalization to emit full relationship graph (depends_on/contains/provides), scope tags, entrypoint annotations, and component metadata required by Cartographer. |
> 2025-10-27: Waiting on policy-driven linkset enrichment (`CONCELIER-POLICY-20-002`) and Cartographer API contract (`CARTO-GRAPH-21-002`) to define required relationship payloads. Without those schemas the projection changes cannot be implemented deterministically.
| CONCELIER-GRAPH-21-002 `Change events` | BLOCKED (2025-10-27) | Concelier Core Guild, Scheduler Guild | CONCELIER-GRAPH-21-001 | Publish change events (new SBOM version, relationship delta) for Cartographer build queue; ensure events include tenant/context metadata. |
> 2025-10-27: Depends on `CONCELIER-GRAPH-21-001`; event schema hinges on finalized projection output and Cartographer webhook contract, both pending.
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| CONCELIER-LNM-21-001 `Advisory observation schema` | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Introduce immutable `advisory_observations` model with AOC metadata, raw payload pointers, normalized fields, and tenancy guardrails; publish schema definition. |
| CONCELIER-LNM-21-002 `Linkset builder` | TODO | Concelier Core Guild, Data Science Guild | CONCELIER-LNM-21-001 | Implement correlation pipeline (alias graph, PURL overlap, CVSS vector equality, fuzzy title match) that produces `advisory_linksets` with confidence + conflict annotations. |
| CONCELIER-LNM-21-003 `Conflict annotator` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Detect field disagreements (severity, CVSS, ranges, references) and record structured conflicts on linksets; surface to API/UI. |
| CONCELIER-LNM-21-001 `Advisory observation schema` | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Introduce immutable `advisory_observations` model with AOC metadata, raw payload pointers, normalized fields, and tenancy guardrails; publish schema definition. `DOCS-LNM-22-001` blocked pending this deliverable. |
| CONCELIER-LNM-21-002 `Linkset builder` | TODO | Concelier Core Guild, Data Science Guild | CONCELIER-LNM-21-001 | Implement correlation pipeline (alias graph, PURL overlap, CVSS vector equality, fuzzy title match) that produces `advisory_linksets` with confidence + conflict annotations. Docs note: unblock `DOCS-LNM-22-001` once builder lands. |
| CONCELIER-LNM-21-003 `Conflict annotator` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Detect field disagreements (severity, CVSS, ranges, references) and record structured conflicts on linksets; surface to API/UI. Docs awaiting structured conflict payloads. |
| CONCELIER-LNM-21-004 `Merge code removal` | TODO | Concelier Core Guild | CONCELIER-LNM-21-002 | Excise existing merge/dedup logic, enforce immutability on observations, and add guards/tests to prevent future merges. |
| CONCELIER-LNM-21-005 `Event emission` | TODO | Concelier Core Guild, Platform Events Guild | CONCELIER-LNM-21-002 | Emit `advisory.linkset.updated` events with delta payloads for downstream Policy Engine/Cartographer consumers; ensure idempotent delivery. |
@@ -46,7 +49,8 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| CONCELIER-GRAPH-24-001 `Advisory overlay inputs` | DOING (2025-10-27) | Concelier Core Guild | CONCELIER-POLICY-23-001 | Expose raw advisory observations/linksets with tenant filters for overlay services; no derived counts/severity in ingestion. |
| CONCELIER-GRAPH-24-001 `Advisory overlay inputs` | TODO | Concelier Core Guild | CONCELIER-POLICY-23-001 | Expose raw advisory observations/linksets with tenant filters for overlay services; no derived counts/severity in ingestion. |
> 2025-10-27: Initial prototype (query service + CLI consumer) drafted but reverted pending scope/tenant alignment; no changes merged.
## Reachability v1

View File

@@ -0,0 +1,38 @@
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.Storage.Mongo.Observations;
internal sealed class AdvisoryObservationLookup : IAdvisoryObservationLookup
{
private readonly IAdvisoryObservationStore _store;
public AdvisoryObservationLookup(IAdvisoryObservationStore store)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> ListByTenantAsync(
string tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
cancellationToken.ThrowIfCancellationRequested();
return new ValueTask<IReadOnlyList<AdvisoryObservation>>(
_store.ListByTenantAsync(tenant, cancellationToken));
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByAliasesAsync(
string tenant,
IReadOnlyCollection<string> aliases,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(aliases);
cancellationToken.ThrowIfCancellationRequested();
return new ValueTask<IReadOnlyList<AdvisoryObservation>>(
_store.FindByAliasesAsync(tenant, aliases, cancellationToken));
}
}

View File

@@ -12,12 +12,13 @@ using StellaOps.Concelier.Storage.Mongo.Exporting;
using StellaOps.Concelier.Storage.Mongo.JpFlags;
using StellaOps.Concelier.Storage.Mongo.MergeEvents;
using StellaOps.Concelier.Storage.Mongo.Conflicts;
using StellaOps.Concelier.Storage.Mongo.PsirtFlags;
using StellaOps.Concelier.Storage.Mongo.Statements;
using StellaOps.Concelier.Storage.Mongo.Events;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Storage.Mongo.PsirtFlags;
using StellaOps.Concelier.Storage.Mongo.Statements;
using StellaOps.Concelier.Storage.Mongo.Events;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Storage.Mongo.Migrations;
using StellaOps.Concelier.Storage.Mongo.Observations;
using StellaOps.Concelier.Core.Observations;
namespace StellaOps.Concelier.Storage.Mongo;
@@ -73,6 +74,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IAdvisoryStatementStore, AdvisoryStatementStore>();
services.AddSingleton<IAdvisoryConflictStore, AdvisoryConflictStore>();
services.AddSingleton<IAdvisoryObservationStore, AdvisoryObservationStore>();
services.AddSingleton<IAdvisoryObservationLookup, AdvisoryObservationLookup>();
services.AddSingleton<IAdvisoryEventRepository, MongoAdvisoryEventRepository>();
services.AddSingleton<IAdvisoryEventLog, AdvisoryEventLog>();
services.AddSingleton<IExportStateStore, ExportStateStore>();

View File

@@ -0,0 +1,14 @@
using System.Collections.Immutable;
using StellaOps.Concelier.Models.Observations;
namespace StellaOps.Concelier.WebService.Contracts;
public sealed record AdvisoryObservationQueryResponse(
ImmutableArray<AdvisoryObservation> Observations,
AdvisoryObservationLinksetAggregateResponse Linkset);
public sealed record AdvisoryObservationLinksetAggregateResponse(
ImmutableArray<string> Aliases,
ImmutableArray<string> Purls,
ImmutableArray<string> Cpes,
ImmutableArray<AdvisoryObservationReference> References);

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -12,13 +13,14 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.WebService.Diagnostics;
using Serilog;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Core.Observations;
using StellaOps.Concelier.WebService.Diagnostics;
using Serilog;
using StellaOps.Concelier.Merge;
using StellaOps.Concelier.Merge.Services;
using StellaOps.Concelier.WebService.Extensions;
@@ -34,10 +36,12 @@ using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Aoc;
using StellaOps.Concelier.WebService.Contracts;
var builder = WebApplication.CreateBuilder(args);
const string JobsPolicyName = "Concelier.Jobs.Trigger";
const string ObservationsPolicyName = "Concelier.Observations.Read";
builder.Configuration.AddStellaOpsDefaults(options =>
{
@@ -75,12 +79,13 @@ builder.Services.AddSingleton<MirrorFileLocator>();
builder.Services.AddMongoStorage(storageOptions =>
{
storageOptions.ConnectionString = concelierOptions.Storage.Dsn;
storageOptions.DatabaseName = concelierOptions.Storage.Database;
storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds);
});
builder.Services.AddMergeModule(builder.Configuration);
builder.Services.AddJobScheduler();
storageOptions.DatabaseName = concelierOptions.Storage.Database;
storageOptions.CommandTimeout = TimeSpan.FromSeconds(concelierOptions.Storage.CommandTimeoutSeconds);
});
builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
builder.Services.AddMergeModule(builder.Configuration);
builder.Services.AddJobScheduler();
builder.Services.AddBuiltInConcelierJobs();
builder.Services.AddSingleton<ServiceStatus>(sp => new ServiceStatus(sp.GetRequiredService<TimeProvider>()));
@@ -163,6 +168,7 @@ if (authorityConfigured)
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(JobsPolicyName, concelierOptions.Authority.RequiredScopes.ToArray());
options.AddStellaOpsScopePolicy(ObservationsPolicyName, StellaOpsScopes.VulnRead);
});
}
@@ -189,6 +195,71 @@ app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
jsonOptions.Converters.Add(new JsonStringEnumConverter());
var observationsEndpoint = app.MapGet("/concelier/observations", async (
string tenant,
[FromQuery(Name = "observationId")] string[]? observationIds,
[FromQuery(Name = "alias")] string[]? aliases,
[FromQuery(Name = "purl")] string[]? purls,
[FromQuery(Name = "cpe")] string[]? cpes,
IAdvisoryObservationQueryService queryService,
HttpContext httpContext,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(tenant))
{
return Results.BadRequest("tenant must be provided.");
}
var normalizedTenant = tenant.Trim().ToLowerInvariant();
if (authorityConfigured)
{
var principal = httpContext.User;
if (enforceAuthority && (principal?.Identity?.IsAuthenticated != true))
{
return Results.Unauthorized();
}
if (principal?.Identity?.IsAuthenticated == true)
{
var tenantClaim = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(tenantClaim))
{
return Results.Forbid();
}
var normalizedClaim = tenantClaim.Trim().ToLowerInvariant();
if (!string.Equals(normalizedClaim, normalizedTenant, StringComparison.Ordinal))
{
return Results.Forbid();
}
}
}
var options = new AdvisoryObservationQueryOptions(
normalizedTenant,
observationIds,
aliases,
purls,
cpes);
var result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
var response = new AdvisoryObservationQueryResponse(
result.Observations,
new AdvisoryObservationLinksetAggregateResponse(
result.Linkset.Aliases,
result.Linkset.Purls,
result.Linkset.Cpes,
result.Linkset.References));
return Results.Ok(response);
}).WithName("GetConcelierObservations");
if (authorityConfigured)
{
observationsEndpoint.RequireAuthorization(ObservationsPolicyName);
}
app.MapGet("/concelier/advisories/{vulnerabilityKey}/replay", async (
string vulnerabilityKey,
DateTimeOffset? asOf,

View File

@@ -18,16 +18,18 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-GRAPH-21-001 `Inspector linkouts` | TODO | Excititor Core Guild, Cartographer Guild | EXCITITOR-POLICY-20-002, CARTO-GRAPH-21-005 | Provide batched VEX/advisory reference fetches keyed by graph node PURLs so UI inspector can display raw documents and justification metadata. |
| EXCITITOR-GRAPH-21-002 `Overlay enrichment` | TODO | Excititor Core Guild | EXCITITOR-GRAPH-21-001, POLICY-ENGINE-30-001 | Ensure overlay metadata includes VEX justification summaries and document versions for Cartographer overlays; update fixtures/tests. |
| EXCITITOR-GRAPH-21-001 `Inspector linkouts` | BLOCKED (2025-10-27) | Excititor Core Guild, Cartographer Guild | EXCITITOR-POLICY-20-002, CARTO-GRAPH-21-005 | Provide batched VEX/advisory reference fetches keyed by graph node PURLs so UI inspector can display raw documents and justification metadata. |
> 2025-10-27: Pending policy-driven linkset enrichment (`EXCITITOR-POLICY-20-002`) and Cartographer inspector contract (`CARTO-GRAPH-21-005`). No stable payload to target.
| EXCITITOR-GRAPH-21-002 `Overlay enrichment` | BLOCKED (2025-10-27) | Excititor Core Guild | EXCITITOR-GRAPH-21-001, POLICY-ENGINE-30-001 | Ensure overlay metadata includes VEX justification summaries and document versions for Cartographer overlays; update fixtures/tests. |
> 2025-10-27: Requires inspector linkouts (`EXCITITOR-GRAPH-21-001`) and Policy Engine overlay schema (`POLICY-ENGINE-30-001`) before enrichment can be implemented.
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-LNM-21-001 `VEX observation model` | TODO | Excititor Core Guild | EXCITITOR-CORE-AOC-19-001 | Define immutable `vex_observations` schema capturing raw statements, product PURLs, justification, and AOC metadata. |
| EXCITITOR-LNM-21-002 `Linkset correlator` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-001 | Build correlation pipeline combining alias + product PURL signals to form `vex_linksets` with confidence metrics. |
| EXCITITOR-LNM-21-003 `Conflict annotator` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-002 | Record status/justification disagreements within linksets and expose structured conflicts. |
| EXCITITOR-LNM-21-001 `VEX observation model` | TODO | Excititor Core Guild | EXCITITOR-CORE-AOC-19-001 | Define immutable `vex_observations` schema capturing raw statements, product PURLs, justification, and AOC metadata. `DOCS-LNM-22-002` blocked pending this schema. |
| EXCITITOR-LNM-21-002 `Linkset correlator` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-001 | Build correlation pipeline combining alias + product PURL signals to form `vex_linksets` with confidence metrics. Docs waiting to finalize VEX aggregation guide. |
| EXCITITOR-LNM-21-003 `Conflict annotator` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-002 | Record status/justification disagreements within linksets and expose structured conflicts. Provide structured payloads for `DOCS-LNM-22-002`. |
| EXCITITOR-LNM-21-004 `Merge removal` | TODO | Excititor Core Guild | EXCITITOR-LNM-21-002 | Remove legacy VEX merge logic, enforce immutability, and add guards/tests to prevent future merges. |
| EXCITITOR-LNM-21-005 `Event emission` | TODO | Excititor Core Guild, Platform Events Guild | EXCITITOR-LNM-21-002 | Emit `vex.linkset.updated` events for downstream consumers with delta descriptions and tenant context. |

View File

@@ -17,7 +17,8 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| EXCITITOR-GRAPH-21-005 `Inspector indexes` | TODO | Excititor Storage Guild | EXCITITOR-GRAPH-21-001 | Add indexes/materialized views for VEX lookups by PURL/policy to support Cartographer inspector performance; document migrations. |
| EXCITITOR-GRAPH-21-005 `Inspector indexes` | BLOCKED (2025-10-27) | Excititor Storage Guild | EXCITITOR-GRAPH-21-001 | Add indexes/materialized views for VEX lookups by PURL/policy to support Cartographer inspector performance; document migrations. |
> 2025-10-27: Indexed workload requirements depend on Inspector linkouts (`EXCITITOR-GRAPH-21-001`) which are themselves blocked on Cartographer contract. Revisit once access patterns are defined.
## Link-Not-Merge v1

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Policy.Engine.Services;
@@ -126,6 +127,144 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
Assert.Contains(result.Warnings, message => message.Contains("EOL", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Evaluate_ExceptionSuppressesCriticalFinding()
{
var document = CompileBaseline();
var effect = new PolicyExceptionEffect(
Id: "suppress-critical",
Name: "Critical Break Glass",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: "secops",
MaxDurationDays: 7,
Description: null);
var scope = PolicyEvaluationExceptionScope.Create(ruleNames: new[] { "block_critical" });
var instance = new PolicyEvaluationExceptionInstance(
Id: "exc-001",
EffectId: effect.Id,
Scope: scope,
CreatedAt: new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty);
var exceptions = new PolicyEvaluationExceptions(
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
ImmutableArray.Create(instance));
var context = CreateContext("Critical", "internal", exceptions);
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("block_critical", result.RuleName);
Assert.Equal("suppressed", result.Status);
Assert.NotNull(result.AppliedException);
Assert.Equal("exc-001", result.AppliedException!.ExceptionId);
Assert.Equal("suppress-critical", result.AppliedException!.EffectId);
Assert.Equal("blocked", result.AppliedException!.OriginalStatus);
Assert.Equal("suppressed", result.AppliedException!.AppliedStatus);
Assert.Equal("suppressed", result.Annotations["exception.status"]);
}
[Fact]
public void Evaluate_ExceptionDowngradesSeverity()
{
var document = CompileBaseline();
var effect = new PolicyExceptionEffect(
Id: "downgrade-internet",
Name: "Downgrade High Internet",
Effect: PolicyExceptionEffectType.Downgrade,
DowngradeSeverity: PolicySeverity.Medium,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: null,
Description: null);
var scope = PolicyEvaluationExceptionScope.Create(
ruleNames: new[] { "escalate_high_internet" },
severities: new[] { "High" },
sources: new[] { "GHSA" });
var instance = new PolicyEvaluationExceptionInstance(
Id: "exc-200",
EffectId: effect.Id,
Scope: scope,
CreatedAt: new DateTimeOffset(2025, 10, 2, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty);
var exceptions = new PolicyEvaluationExceptions(
ImmutableDictionary<string, PolicyExceptionEffect>.Empty.Add(effect.Id, effect),
ImmutableArray.Create(instance));
var context = CreateContext("High", "internet", exceptions);
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("escalate_high_internet", result.RuleName);
Assert.Equal("affected", result.Status);
Assert.Equal("Medium", result.Severity);
Assert.NotNull(result.AppliedException);
Assert.Equal("Critical", result.AppliedException!.OriginalSeverity);
Assert.Equal("Medium", result.AppliedException!.AppliedSeverity);
Assert.Equal("Medium", result.Annotations["exception.severity"]);
}
[Fact]
public void Evaluate_MoreSpecificExceptionWins()
{
var document = CompileBaseline();
var suppressGlobal = new PolicyExceptionEffect(
Id: "suppress-critical-global",
Name: "Global Critical Suppress",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: null,
Description: null);
var suppressRule = new PolicyExceptionEffect(
Id: "suppress-critical-rule",
Name: "Rule Critical Suppress",
Effect: PolicyExceptionEffectType.Suppress,
DowngradeSeverity: null,
RequiredControlId: null,
RoutingTemplate: null,
MaxDurationDays: null,
Description: null);
var globalInstance = new PolicyEvaluationExceptionInstance(
Id: "exc-global",
EffectId: suppressGlobal.Id,
Scope: PolicyEvaluationExceptionScope.Create(severities: new[] { "Critical" }),
CreatedAt: new DateTimeOffset(2025, 9, 1, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty);
var ruleInstance = new PolicyEvaluationExceptionInstance(
Id: "exc-rule",
EffectId: suppressRule.Id,
Scope: PolicyEvaluationExceptionScope.Create(
ruleNames: new[] { "block_critical" },
severities: new[] { "Critical" }),
CreatedAt: new DateTimeOffset(2025, 10, 5, 0, 0, 0, TimeSpan.Zero),
Metadata: ImmutableDictionary<string, string>.Empty.Add("requestedBy", "alice"));
var effects = ImmutableDictionary<string, PolicyExceptionEffect>.Empty
.Add(suppressGlobal.Id, suppressGlobal)
.Add(suppressRule.Id, suppressRule);
var exceptions = new PolicyEvaluationExceptions(
effects,
ImmutableArray.Create(globalInstance, ruleInstance));
var context = CreateContext("Critical", "internal", exceptions);
var result = evaluationService.Evaluate(document, context);
Assert.True(result.Matched);
Assert.Equal("suppressed", result.Status);
Assert.NotNull(result.AppliedException);
Assert.Equal("exc-rule", result.AppliedException!.ExceptionId);
Assert.Equal("Rule Critical Suppress", result.AppliedException!.Metadata["effectName"]);
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
}
private PolicyIrDocument CompileBaseline()
{
var compilation = compiler.Compile(BaselinePolicy);
@@ -133,7 +272,7 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
return Assert.IsType<PolicyIrDocument>(compilation.Document);
}
private static PolicyEvaluationContext CreateContext(string severity, string exposure)
private static PolicyEvaluationContext CreateContext(string severity, string exposure, PolicyEvaluationExceptions? exceptions = null)
{
return new PolicyEvaluationContext(
new PolicyEvaluationSeverity(severity),
@@ -143,7 +282,8 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
PolicyEvaluationVexEvidence.Empty,
new PolicyEvaluationSbom(ImmutableHashSet<string>.Empty));
new PolicyEvaluationSbom(ImmutableHashSet<string>.Empty),
exceptions ?? PolicyEvaluationExceptions.Empty);
}
private static string Describe(ImmutableArray<PolicyIssue> issues) =>

View File

@@ -49,6 +49,7 @@ internal sealed class PolicyParser
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, PolicyLiteralValue>(StringComparer.Ordinal);
var settingsBuilder = ImmutableDictionary.CreateBuilder<string, PolicyLiteralValue>(StringComparer.Ordinal);
var profiles = ImmutableArray.CreateBuilder<PolicyProfileNode>();
var rules = ImmutableArray.CreateBuilder<PolicyRuleNode>();
while (!Check(TokenKind.RightBrace) && !IsAtEnd)
@@ -75,9 +76,12 @@ internal sealed class PolicyParser
if (Match(TokenKind.KeywordProfile))
{
Consume(TokenKind.Identifier, "Profile requires a name.", "policy.profile");
Consume(TokenKind.LeftBrace, "Expected '{' after profile declaration.", "policy.profile");
SkipBlock();
var profile = ParseProfile();
if (profile is not null)
{
profiles.Add(profile);
}
continue;
}
@@ -108,12 +112,43 @@ internal sealed class PolicyParser
name,
syntax,
metadataBuilder.ToImmutable(),
ImmutableArray<PolicyProfileNode>.Empty,
profiles.ToImmutable(),
settingsBuilder.ToImmutable(),
rules.ToImmutable(),
span);
}
private PolicyProfileNode? ParseProfile()
{
var nameToken = Consume(TokenKind.Identifier, "Profile requires a name.", "policy.profile");
var name = nameToken.Text;
Consume(TokenKind.LeftBrace, "Expected '{' after profile declaration.", $"policy.profile.{name}");
var start = nameToken.Span.Start;
var depth = 1;
while (depth > 0 && !IsAtEnd)
{
if (Match(TokenKind.LeftBrace))
{
depth++;
}
else if (Match(TokenKind.RightBrace))
{
depth--;
}
else
{
Advance();
}
}
var close = Previous;
return new PolicyProfileNode(
name,
ImmutableArray<PolicyProfileItemNode>.Empty,
new SourceSpan(start, close.Span.End));
}
private PolicyRuleNode? ParseRule()
{
var nameToken = Consume(TokenKind.Identifier, "Rule requires a name.", "policy.rule");
@@ -153,7 +188,7 @@ internal sealed class PolicyParser
if (because is null)
{
diagnostics.Add(PolicyIssue.Warning(PolicyDslDiagnosticCodes.MissingBecauseClause, $"Rule '{name}' missing 'because' clause.", $"policy.rule.{name}"));
diagnostics.Add(PolicyIssue.Error(PolicyDslDiagnosticCodes.MissingBecauseClause, $"Rule '{name}' missing 'because' clause.", $"policy.rule.{name}"));
}
return new PolicyRuleNode(name, priority, when, thenActions, elseActions, because, new SourceSpan(nameToken.Span.Start, close.Span.End));

View File

@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
namespace StellaOps.Policy.Engine.Evaluation;
@@ -13,7 +16,8 @@ internal sealed record PolicyEvaluationContext(
PolicyEvaluationEnvironment Environment,
PolicyEvaluationAdvisory Advisory,
PolicyEvaluationVexEvidence Vex,
PolicyEvaluationSbom Sbom);
PolicyEvaluationSbom Sbom,
PolicyEvaluationExceptions Exceptions);
internal sealed record PolicyEvaluationSeverity(string Normalized, decimal? Score = null);
@@ -51,7 +55,8 @@ internal sealed record PolicyEvaluationResult(
string? RuleName,
int? Priority,
ImmutableDictionary<string, string> Annotations,
ImmutableArray<string> Warnings)
ImmutableArray<string> Warnings,
PolicyExceptionApplication? AppliedException)
{
public static PolicyEvaluationResult CreateDefault(string? severity) => new(
Matched: false,
@@ -60,5 +65,78 @@ internal sealed record PolicyEvaluationResult(
RuleName: null,
Priority: null,
Annotations: ImmutableDictionary<string, string>.Empty,
Warnings: ImmutableArray<string>.Empty);
Warnings: ImmutableArray<string>.Empty,
AppliedException: null);
}
internal sealed record PolicyEvaluationExceptions(
ImmutableDictionary<string, PolicyExceptionEffect> Effects,
ImmutableArray<PolicyEvaluationExceptionInstance> Instances)
{
public static readonly PolicyEvaluationExceptions Empty = new(
ImmutableDictionary<string, PolicyExceptionEffect>.Empty,
ImmutableArray<PolicyEvaluationExceptionInstance>.Empty);
public bool IsEmpty => Instances.IsDefaultOrEmpty || Instances.Length == 0;
}
internal sealed record PolicyEvaluationExceptionInstance(
string Id,
string EffectId,
PolicyEvaluationExceptionScope Scope,
DateTimeOffset CreatedAt,
ImmutableDictionary<string, string> Metadata);
internal sealed record PolicyEvaluationExceptionScope(
ImmutableHashSet<string> RuleNames,
ImmutableHashSet<string> Severities,
ImmutableHashSet<string> Sources,
ImmutableHashSet<string> Tags)
{
public static PolicyEvaluationExceptionScope Empty { get; } = new(
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase));
public bool IsEmpty => RuleNames.Count == 0
&& Severities.Count == 0
&& Sources.Count == 0
&& Tags.Count == 0;
public static PolicyEvaluationExceptionScope Create(
IEnumerable<string>? ruleNames = null,
IEnumerable<string>? severities = null,
IEnumerable<string>? sources = null,
IEnumerable<string>? tags = null)
{
return new PolicyEvaluationExceptionScope(
Normalize(ruleNames),
Normalize(severities),
Normalize(sources),
Normalize(tags));
}
private static ImmutableHashSet<string> Normalize(IEnumerable<string>? values)
{
if (values is null)
{
return ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
}
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
}
}
internal sealed record PolicyExceptionApplication(
string ExceptionId,
string EffectId,
PolicyExceptionEffectType EffectType,
string OriginalStatus,
string? OriginalSeverity,
string AppliedStatus,
string? AppliedSeverity,
ImmutableDictionary<string, string> Metadata);

View File

@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using StellaOps.Policy;
using StellaOps.Policy.Engine.Compilation;
namespace StellaOps.Policy.Engine.Evaluation;
@@ -49,17 +51,21 @@ internal sealed class PolicyEvaluator
runtime.Status = "affected";
}
return new PolicyEvaluationResult(
var baseResult = new PolicyEvaluationResult(
Matched: true,
Status: runtime.Status,
Severity: runtime.Severity,
RuleName: rule.Name,
Priority: rule.Priority,
Annotations: runtime.Annotations.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase),
Warnings: runtime.Warnings.ToImmutableArray());
Warnings: runtime.Warnings.ToImmutableArray(),
AppliedException: null);
return ApplyExceptions(request, baseResult);
}
return PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
var defaultResult = PolicyEvaluationResult.CreateDefault(request.Context.Severity.Normalized);
return ApplyExceptions(request, defaultResult);
}
private static void ApplyAction(
@@ -181,4 +187,234 @@ internal sealed class PolicyEvaluator
public List<string> Warnings { get; } = new();
}
private static PolicyEvaluationResult ApplyExceptions(PolicyEvaluationRequest request, PolicyEvaluationResult baseResult)
{
var exceptions = request.Context.Exceptions;
if (exceptions.IsEmpty)
{
return baseResult;
}
PolicyEvaluationExceptionInstance? winningInstance = null;
PolicyExceptionEffect? winningEffect = null;
var winningScore = -1;
foreach (var instance in exceptions.Instances)
{
if (!exceptions.Effects.TryGetValue(instance.EffectId, out var effect))
{
continue;
}
if (!MatchesScope(instance.Scope, request, baseResult))
{
continue;
}
var specificity = ComputeSpecificity(instance.Scope);
if (specificity < 0)
{
continue;
}
if (winningInstance is null
|| specificity > winningScore
|| (specificity == winningScore && instance.CreatedAt > winningInstance.CreatedAt)
|| (specificity == winningScore && instance.CreatedAt == winningInstance!.CreatedAt
&& string.CompareOrdinal(instance.Id, winningInstance.Id) < 0))
{
winningInstance = instance;
winningEffect = effect;
winningScore = specificity;
}
}
if (winningInstance is null || winningEffect is null)
{
return baseResult;
}
return ApplyExceptionEffect(baseResult, winningInstance, winningEffect);
}
private static bool MatchesScope(
PolicyEvaluationExceptionScope scope,
PolicyEvaluationRequest request,
PolicyEvaluationResult baseResult)
{
if (scope.RuleNames.Count > 0)
{
if (string.IsNullOrEmpty(baseResult.RuleName)
|| !scope.RuleNames.Contains(baseResult.RuleName))
{
return false;
}
}
if (scope.Severities.Count > 0)
{
var severity = request.Context.Severity.Normalized;
if (string.IsNullOrEmpty(severity)
|| !scope.Severities.Contains(severity))
{
return false;
}
}
if (scope.Sources.Count > 0)
{
var source = request.Context.Advisory.Source;
if (string.IsNullOrEmpty(source)
|| !scope.Sources.Contains(source))
{
return false;
}
}
if (scope.Tags.Count > 0)
{
var sbom = request.Context.Sbom;
var hasMatch = scope.Tags.Any(sbom.HasTag);
if (!hasMatch)
{
return false;
}
}
return true;
}
private static int ComputeSpecificity(PolicyEvaluationExceptionScope scope)
{
var score = 0;
if (scope.RuleNames.Count > 0)
{
score += 1_000 + scope.RuleNames.Count * 25;
}
if (scope.Severities.Count > 0)
{
score += 500 + scope.Severities.Count * 10;
}
if (scope.Sources.Count > 0)
{
score += 250 + scope.Sources.Count * 10;
}
if (scope.Tags.Count > 0)
{
score += 100 + scope.Tags.Count * 5;
}
return score;
}
private static PolicyEvaluationResult ApplyExceptionEffect(
PolicyEvaluationResult baseResult,
PolicyEvaluationExceptionInstance instance,
PolicyExceptionEffect effect)
{
var annotationsBuilder = baseResult.Annotations.ToBuilder();
annotationsBuilder["exception.id"] = instance.Id;
annotationsBuilder["exception.effectId"] = effect.Id;
annotationsBuilder["exception.effectType"] = effect.Effect.ToString();
if (!string.IsNullOrWhiteSpace(effect.Name))
{
annotationsBuilder["exception.effectName"] = effect.Name!;
}
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
{
annotationsBuilder["exception.routingTemplate"] = effect.RoutingTemplate!;
}
if (effect.MaxDurationDays is int durationDays)
{
annotationsBuilder["exception.maxDurationDays"] = durationDays.ToString(CultureInfo.InvariantCulture);
}
foreach (var pair in instance.Metadata)
{
annotationsBuilder[$"exception.meta.{pair.Key}"] = pair.Value;
}
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
{
metadataBuilder["routingTemplate"] = effect.RoutingTemplate!;
}
if (effect.MaxDurationDays is int metadataDuration)
{
metadataBuilder["maxDurationDays"] = metadataDuration.ToString(CultureInfo.InvariantCulture);
}
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
{
metadataBuilder["requiredControlId"] = effect.RequiredControlId!;
}
if (!string.IsNullOrWhiteSpace(effect.Name))
{
metadataBuilder["effectName"] = effect.Name!;
}
foreach (var pair in instance.Metadata)
{
metadataBuilder[pair.Key] = pair.Value;
}
var newStatus = baseResult.Status;
var newSeverity = baseResult.Severity;
var warnings = baseResult.Warnings;
switch (effect.Effect)
{
case PolicyExceptionEffectType.Suppress:
newStatus = "suppressed";
annotationsBuilder["exception.status"] = newStatus;
break;
case PolicyExceptionEffectType.Defer:
newStatus = "deferred";
annotationsBuilder["exception.status"] = newStatus;
break;
case PolicyExceptionEffectType.Downgrade:
if (effect.DowngradeSeverity is { } downgradeSeverity)
{
newSeverity = downgradeSeverity.ToString();
annotationsBuilder["exception.severity"] = newSeverity!;
}
break;
case PolicyExceptionEffectType.RequireControl:
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
{
annotationsBuilder["exception.requiredControl"] = effect.RequiredControlId!;
warnings = warnings.Add($"Exception '{instance.Id}' requires control '{effect.RequiredControlId}'.");
}
break;
}
var application = new PolicyExceptionApplication(
ExceptionId: instance.Id,
EffectId: instance.EffectId,
EffectType: effect.Effect,
OriginalStatus: baseResult.Status,
OriginalSeverity: baseResult.Severity,
AppliedStatus: newStatus,
AppliedSeverity: newSeverity,
Metadata: metadataBuilder.ToImmutable());
return baseResult with
{
Status = newStatus,
Severity = newSeverity,
Annotations = annotationsBuilder.ToImmutable(),
Warnings = warnings,
AppliedException = application,
};
}
}

View File

@@ -9,6 +9,19 @@ namespace StellaOps.Policy.Engine.Evaluation;
internal sealed class PolicyExpressionEvaluator
{
private static readonly IReadOnlyDictionary<string, decimal> SeverityOrder = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase)
{
["critical"] = 5m,
["high"] = 4m,
["medium"] = 3m,
["moderate"] = 3m,
["low"] = 2m,
["informational"] = 1m,
["info"] = 1m,
["none"] = 0m,
["unknown"] = -1m,
};
private readonly PolicyEvaluationContext context;
public PolicyExpressionEvaluator(PolicyEvaluationContext context)
@@ -208,9 +221,35 @@ internal sealed class PolicyExpressionEvaluator
private EvaluationValue CompareNumeric(PolicyExpression left, PolicyExpression right, EvaluationScope scope, Func<decimal, decimal, bool> comparer)
{
var leftValue = Evaluate(left, scope).AsDecimal();
var rightValue = Evaluate(right, scope).AsDecimal();
return new EvaluationValue(leftValue.HasValue && rightValue.HasValue && comparer(leftValue.Value, rightValue.Value));
var leftValue = Evaluate(left, scope);
var rightValue = Evaluate(right, scope);
if (!TryGetComparableNumber(leftValue, out var leftNumber)
|| !TryGetComparableNumber(rightValue, out var rightNumber))
{
return EvaluationValue.False;
}
return new EvaluationValue(comparer(leftNumber, rightNumber));
}
private static bool TryGetComparableNumber(EvaluationValue value, out decimal number)
{
var numeric = value.AsDecimal();
if (numeric.HasValue)
{
number = numeric.Value;
return true;
}
if (value.Raw is string text && SeverityOrder.TryGetValue(text.Trim(), out var mapped))
{
number = mapped;
return true;
}
number = 0m;
return false;
}
private EvaluationValue Contains(PolicyExpression needleExpr, PolicyExpression haystackExpr, EvaluationScope scope)

View File

@@ -89,7 +89,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-ENGINE-70-001 | TODO | Policy Guild, Governance Guild | POLICY-EXC-25-001 | Implement exception evaluation layer: specificity resolution, effect application (suppress/defer/downgrade/require control), and integration with explain traces. | Engine applies exceptions deterministically; unit/property tests cover precedence; explainer includes exception metadata. |
| POLICY-ENGINE-70-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-EXC-25-001 | Implement exception evaluation layer: specificity resolution, effect application (suppress/defer/downgrade/require control), and integration with explain traces. | Engine applies exceptions deterministically; unit/property tests cover precedence; explainer includes exception metadata. |
| POLICY-ENGINE-70-002 | TODO | Policy Guild, Storage Guild | POLICY-ENGINE-70-001 | Design and create Mongo collections (`exceptions`, `exception_reviews`, `exception_bindings`) with indexes and migrations; expose repository APIs. | Collections created; migrations documented; tests cover CRUD and binding lookups. |
| POLICY-ENGINE-70-003 | TODO | Policy Guild, Runtime Guild | POLICY-ENGINE-70-001 | Build Redis exception decision cache (`exceptions_effective_map`) with warm/invalidation logic reacting to `exception.*` events. | Cache layer operational; metrics track hit/miss; fallback path tested. |
| POLICY-ENGINE-70-004 | TODO | Policy Guild, Observability Guild | POLICY-ENGINE-70-001 | Extend metrics/tracing/logging for exception application (latency, counts, expiring events) and include AOC references in logs. | Metrics emitted (`policy_exception_applied_total` etc.); traces updated; log schema documented. |

View File

@@ -1,13 +1,17 @@
# Policy Registry Task Board — Epic 4: Policy Studio
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| REGISTRY-API-27-001 | TODO | Policy Registry Guild | AUTH-CONSOLE-23-001, POLICY-ENGINE-20-001 | Define OpenAPI specification covering workspaces, versions, reviews, simulations, promotions, and attestations; publish typed clients for Console/CLI. | OpenAPI YAML committed, spectral lint passes, SDK regeneration documented, consumers notified. |
| REGISTRY-API-27-001 | TODO | Policy Registry Guild | AUTH-CONSOLE-23-001, POLICY-ENGINE-20-001 | Define OpenAPI specification covering workspaces, versions, reviews, simulations, promotions, and attestations; publish typed clients for Console/CLI. | OpenAPI YAML committed, spectral lint passes, SDK regeneration documented, consumers notified. Docs `DOCS-POLICY-27-001/008/010` waiting on this spec. |
| REGISTRY-API-27-002 | TODO | Policy Registry Guild | REGISTRY-API-27-001 | Implement workspace storage (Mongo collections, object storage buckets) with CRUD endpoints, diff history, and retention policies. | Workspace CRUD passes integration tests; retention job documented; tenancy scopes enforced. |
| REGISTRY-API-27-003 | TODO | Policy Registry Guild | REGISTRY-API-27-002, POLICY-ENGINE-20-001 | Integrate compile endpoint: forward source bundle to Policy Engine, persist diagnostics, symbol table, rule index, and complexity metrics. | Compile API returns diagnostics + symbol table, metrics recorded, failures mapped to `ERR_POL_*`, tests cover success/error cases. |
| REGISTRY-API-27-004 | TODO | Policy Registry Guild | REGISTRY-API-27-003, POLICY-ENGINE-20-002 | Implement quick simulation API with request limits (sample size, timeouts), returning counts, heatmap, sampled explains. | Quick sim enforces limits, results cached with hash, integration tests validate deterministic output. |
| REGISTRY-API-27-005 | TODO | Policy Registry Guild, Scheduler Guild | REGISTRY-API-27-004, SCHED-WORKER-27-301 | Build batch simulation orchestration: enqueue shards, collect partials, reduce deltas, produce evidence bundles + signed manifest. | Batch sim runs end-to-end in staging fixture, manifests stored with checksums, retries/backoff documented. |
> Docs dependency: `DOCS-POLICY-27-004` needs simulation APIs/workers.
| REGISTRY-API-27-006 | TODO | Policy Registry Guild | REGISTRY-API-27-003 | Implement review workflow (comments, votes, required approvers, status transitions) with audit trails and webhooks. | Review endpoints enforce approver quorum, audit log captured, webhook integration tests pass. |
> Docs dependency: `DOCS-POLICY-27-005` waiting on review workflow.
| REGISTRY-API-27-007 | TODO | Policy Registry Guild, Security Guild | REGISTRY-API-27-006, AUTH-POLICY-27-001 | Implement publish pipeline: sign source/compiled digests, create attestations, mark version immutable, emit events. | Published versions immutable, attestations stored & verifiable, metrics/logs emitted, tests cover signing failure. |
> Docs dependency: `DOCS-POLICY-27-003` blocked until publish/sign pipeline ships.
| REGISTRY-API-27-008 | TODO | Policy Registry Guild | REGISTRY-API-27-007, AUTH-POLICY-27-002 | Implement promotion bindings per tenant/environment with canary subsets, rollback path, and environment history. | Promotion API updates bindings atomically, canary percent enforced, rollback recorded, runbooks updated. |
> Docs dependency: `DOCS-POLICY-27-006` requires promotion APIs.
| REGISTRY-API-27-009 | TODO | Policy Registry Guild, Observability Guild | REGISTRY-API-27-002..008 | Instrument metrics/logs/traces (compile time, diagnostics rate, sim queue depth, approval latency) and expose dashboards. | Metrics registered, dashboards seeded, alerts configured, documentation updated. |
| REGISTRY-API-27-010 | TODO | Policy Registry Guild, QA Guild | REGISTRY-API-27-002..008 | Build unit/integration/load test suites for compile/sim/review/publish/promote flows; provide seeded fixtures for CI. | Tests run in CI, load test report documented, determinism checks validated across runs. |

View File

@@ -1,8 +1,9 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.Policy.Tests;
@@ -26,8 +27,78 @@ public sealed class PolicyBinderTests
Assert.Equal("1.0", result.Document.Version);
Assert.Single(result.Document.Rules);
Assert.Empty(result.Issues);
}
}
[Fact]
public void Bind_ExceptionsConfigured_ParsesDefinitions()
{
const string yaml = """
version: "1.0"
exceptions:
effects:
- id: suppress-temp
name: Temporary Suppress
effect: suppress
routingTemplate: secops
maxDurationDays: 30
- id: downgrade-ops
name: Downgrade To Low
effect: downgrade
downgradeSeverity: Low
routingTemplates:
- id: secops
authorityRouteId: route-secops
requireMfa: true
rules:
- name: Allow
action: ignore
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.True(result.Success);
var effects = result.Document.Exceptions.Effects;
Assert.Equal(2, effects.Length);
var suppress = effects.Single(effect => effect.Id == "suppress-temp");
Assert.Equal(PolicyExceptionEffectType.Suppress, suppress.Effect);
Assert.Equal("Temporary Suppress", suppress.Name);
Assert.Equal("secops", suppress.RoutingTemplate);
Assert.Equal(30, suppress.MaxDurationDays);
var downgrade = effects.Single(effect => effect.Id == "downgrade-ops");
Assert.Equal(PolicyExceptionEffectType.Downgrade, downgrade.Effect);
Assert.Equal("Downgrade To Low", downgrade.Name);
Assert.Equal(PolicySeverity.Low, downgrade.DowngradeSeverity);
var routing = result.Document.Exceptions.RoutingTemplates;
Assert.Single(routing);
Assert.Equal("secops", routing[0].Id);
Assert.Equal("route-secops", routing[0].AuthorityRouteId);
Assert.True(routing[0].RequireMfa);
}
[Fact]
public void Bind_ExceptionDowngradeMissingSeverity_ReturnsError()
{
const string yaml = """
version: "1.0"
exceptions:
effects:
- id: downgrade-invalid
effect: downgrade
routingTemplates: []
rules:
- name: Allow
action: ignore
""";
var result = PolicyBinder.Bind(yaml, PolicyDocumentFormat.Yaml);
Assert.False(result.Success);
Assert.Contains(result.Issues, issue => issue.Code == "policy.exceptions.effect.downgrade.missingSeverity");
}
[Fact]
public void Bind_InvalidSeverity_ReturnsError()
{

View File

@@ -21,10 +21,11 @@ public sealed class PolicyEvaluationTests
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
@@ -66,10 +67,11 @@ public sealed class PolicyEvaluationTests
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(
@@ -107,10 +109,11 @@ public sealed class PolicyEvaluationTests
expires: null,
justification: null);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty);
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var config = PolicyScoringConfig.Default;
var finding = PolicyFinding.Create(

View File

@@ -180,16 +180,19 @@ public static class PolicyBinder
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonPropertyName("rules")]
public List<PolicyRuleModel>? Rules { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonPropertyName("rules")]
public List<PolicyRuleModel>? Rules { get; init; }
[JsonPropertyName("exceptions")]
public PolicyExceptionsModel? Exceptions { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed record PolicyRuleModel
{
[JsonPropertyName("id")]
@@ -258,18 +261,78 @@ public static class PolicyBinder
[JsonPropertyName("quiet")]
public bool? Quiet { get; init; }
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed class PolicyNormalizer
{
private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap =
new Dictionary<string, PolicySeverity>(StringComparer.OrdinalIgnoreCase)
{
[JsonPropertyName("metadata")]
public Dictionary<string, JsonNode?>? Metadata { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed record PolicyExceptionsModel
{
[JsonPropertyName("effects")]
public List<PolicyExceptionEffectModel>? Effects { get; init; }
[JsonPropertyName("routingTemplates")]
public List<PolicyExceptionRoutingTemplateModel>? RoutingTemplates { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed record PolicyExceptionEffectModel
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("effect")]
public string? Effect { get; init; }
[JsonPropertyName("downgradeSeverity")]
public string? DowngradeSeverity { get; init; }
[JsonPropertyName("requiredControlId")]
public string? RequiredControlId { get; init; }
[JsonPropertyName("routingTemplate")]
public string? RoutingTemplate { get; init; }
[JsonPropertyName("maxDurationDays")]
public int? MaxDurationDays { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed record PolicyExceptionRoutingTemplateModel
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("authorityRouteId")]
public string? AuthorityRouteId { get; init; }
[JsonPropertyName("requireMfa")]
public bool? RequireMfa { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extensions { get; init; }
}
private sealed class PolicyNormalizer
{
private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap =
new Dictionary<string, PolicySeverity>(StringComparer.OrdinalIgnoreCase)
{
["critical"] = PolicySeverity.Critical,
["high"] = PolicySeverity.High,
["medium"] = PolicySeverity.Medium,
@@ -282,33 +345,35 @@ public static class PolicyBinder
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
public static (PolicyDocument Document, ImmutableArray<PolicyIssue> Issues) Normalize(PolicyDocumentModel model)
{
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
var version = NormalizeVersion(model.Version, issues);
var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues);
var rules = NormalizeRules(model.Rules, issues);
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
{
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
var version = NormalizeVersion(model.Version, issues);
var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues);
var rules = NormalizeRules(model.Rules, issues);
var exceptions = NormalizeExceptions(model.Exceptions, issues);
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.document.extension",
$"Unrecognized document property '{pair.Key}' has been ignored.",
$"$.{pair.Key}"));
}
}
var document = new PolicyDocument(
version ?? PolicySchema.CurrentVersion,
rules,
metadata);
var orderedIssues = SortIssues(issues);
return (document, orderedIssues);
}
}
var document = new PolicyDocument(
version ?? PolicySchema.CurrentVersion,
rules,
metadata,
exceptions);
var orderedIssues = SortIssues(issues);
return (document, orderedIssues);
}
private static string? NormalizeVersion(JsonNode? versionNode, ImmutableArray<PolicyIssue>.Builder issues)
{
if (versionNode is null)
@@ -392,11 +457,11 @@ public static class PolicyBinder
return builder.ToImmutable();
}
private static ImmutableArray<PolicyRule> NormalizeRules(
List<PolicyRuleModel>? rules,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (rules is null || rules.Count == 0)
private static ImmutableArray<PolicyRule> NormalizeRules(
List<PolicyRuleModel>? rules,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (rules is null || rules.Count == 0)
{
issues.Add(PolicyIssue.Error("policy.rules.empty", "At least one rule must be defined.", "$.rules"));
return ImmutableArray<PolicyRule>.Empty;
@@ -425,19 +490,273 @@ public static class PolicyBinder
normalized.Add((normalizedRule, index));
}
return normalized
.OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Index)
.Select(static tuple => tuple.Rule)
.ToImmutableArray();
}
private static PolicyRule? NormalizeRule(
PolicyRuleModel model,
int index,
ImmutableArray<PolicyIssue>.Builder issues)
{
return normalized
.OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase)
.ThenBy(static tuple => tuple.Index)
.Select(static tuple => tuple.Rule)
.ToImmutableArray();
}
private static PolicyExceptionConfiguration NormalizeExceptions(
PolicyExceptionsModel? model,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (model is null)
{
return PolicyExceptionConfiguration.Empty;
}
var effects = NormalizeExceptionEffects(model.Effects, "$.exceptions.effects", issues);
var routingTemplates = NormalizeExceptionRoutingTemplates(model.RoutingTemplates, "$.exceptions.routingTemplates", issues);
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.exceptions.extension",
$"Unrecognized exceptions property '{pair.Key}' has been ignored.",
$"$.exceptions.{pair.Key}"));
}
}
return new PolicyExceptionConfiguration(effects, routingTemplates);
}
private static ImmutableArray<PolicyExceptionEffect> NormalizeExceptionEffects(
List<PolicyExceptionEffectModel>? models,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (models is null || models.Count == 0)
{
return ImmutableArray<PolicyExceptionEffect>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyExceptionEffect>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < models.Count; index++)
{
var model = models[index];
var basePath = $"{path}[{index}]";
var id = NormalizeOptionalString(model.Id);
if (string.IsNullOrEmpty(id))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.id.missing",
"Exception effect id is required.",
$"{basePath}.id"));
continue;
}
if (!seenIds.Add(id))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.id.duplicate",
$"Duplicate exception effect id '{id}'.",
$"{basePath}.id"));
continue;
}
var effectType = NormalizeExceptionEffectType(model.Effect, $"{basePath}.effect", issues);
if (effectType is null)
{
continue;
}
PolicySeverity? downgradeSeverity = null;
if (!string.IsNullOrWhiteSpace(model.DowngradeSeverity))
{
var severityText = NormalizeOptionalString(model.DowngradeSeverity);
if (!string.IsNullOrEmpty(severityText) && SeverityMap.TryGetValue(severityText, out var mapped))
{
downgradeSeverity = mapped;
}
else if (!string.IsNullOrEmpty(severityText))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.downgrade.invalidSeverity",
$"Unknown downgradeSeverity '{severityText}'.",
$"{basePath}.downgradeSeverity"));
}
}
var requiredControlId = NormalizeOptionalString(model.RequiredControlId);
if (effectType == PolicyExceptionEffectType.RequireControl && string.IsNullOrEmpty(requiredControlId))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.control.missing",
"requireControl effects must specify requiredControlId.",
$"{basePath}.requiredControlId"));
continue;
}
if (effectType == PolicyExceptionEffectType.Downgrade && downgradeSeverity is null)
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.downgrade.missingSeverity",
"downgrade effects must specify downgradeSeverity.",
$"{basePath}.downgradeSeverity"));
continue;
}
var name = NormalizeOptionalString(model.Name);
var routingTemplate = NormalizeOptionalString(model.RoutingTemplate);
var description = NormalizeOptionalString(model.Description);
int? maxDurationDays = null;
if (model.MaxDurationDays is { } durationValue)
{
if (durationValue <= 0)
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.duration.invalid",
"maxDurationDays must be greater than zero.",
$"{basePath}.maxDurationDays"));
}
else
{
maxDurationDays = durationValue;
}
}
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.exceptions.effect.extension",
$"Unrecognized exception effect property '{pair.Key}' has been ignored.",
$"{basePath}.{pair.Key}"));
}
}
builder.Add(new PolicyExceptionEffect(
id,
name,
effectType.Value,
downgradeSeverity,
requiredControlId,
routingTemplate,
maxDurationDays,
description));
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyExceptionRoutingTemplate> NormalizeExceptionRoutingTemplates(
List<PolicyExceptionRoutingTemplateModel>? models,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
if (models is null || models.Count == 0)
{
return ImmutableArray<PolicyExceptionRoutingTemplate>.Empty;
}
var builder = ImmutableArray.CreateBuilder<PolicyExceptionRoutingTemplate>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = 0; index < models.Count; index++)
{
var model = models[index];
var basePath = $"{path}[{index}]";
var id = NormalizeOptionalString(model.Id);
if (string.IsNullOrEmpty(id))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.routing.id.missing",
"Routing template id is required.",
$"{basePath}.id"));
continue;
}
if (!seenIds.Add(id))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.routing.id.duplicate",
$"Duplicate routing template id '{id}'.",
$"{basePath}.id"));
continue;
}
var authorityRouteId = NormalizeOptionalString(model.AuthorityRouteId);
if (string.IsNullOrEmpty(authorityRouteId))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.routing.authority.missing",
"Routing template must specify authorityRouteId.",
$"{basePath}.authorityRouteId"));
continue;
}
var description = NormalizeOptionalString(model.Description);
var requireMfa = model.RequireMfa ?? false;
if (model.Extensions is { Count: > 0 })
{
foreach (var pair in model.Extensions)
{
issues.Add(PolicyIssue.Warning(
"policy.exceptions.routing.extension",
$"Unrecognized routing template property '{pair.Key}' has been ignored.",
$"{basePath}.{pair.Key}"));
}
}
builder.Add(new PolicyExceptionRoutingTemplate(
id,
authorityRouteId,
requireMfa,
description));
}
return builder.ToImmutable();
}
private static PolicyExceptionEffectType? NormalizeExceptionEffectType(
string? value,
string path,
ImmutableArray<PolicyIssue>.Builder issues)
{
var normalized = NormalizeOptionalString(value);
if (string.IsNullOrEmpty(normalized))
{
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.type.missing",
"Exception effect type is required.",
path));
return null;
}
switch (normalized.ToLowerInvariant())
{
case "suppress":
return PolicyExceptionEffectType.Suppress;
case "defer":
return PolicyExceptionEffectType.Defer;
case "downgrade":
return PolicyExceptionEffectType.Downgrade;
case "requirecontrol":
return PolicyExceptionEffectType.RequireControl;
default:
issues.Add(PolicyIssue.Error(
"policy.exceptions.effect.type.invalid",
$"Unsupported exception effect type '{normalized}'.",
path));
return null;
}
}
private static PolicyRule? NormalizeRule(
PolicyRuleModel model,
int index,
ImmutableArray<PolicyIssue>.Builder issues)
{
var basePath = $"$.rules[{index}]";
var name = NormalizeRequiredString(model.Name, $"{basePath}.name", "Rule name", issues);

View File

@@ -46,16 +46,50 @@ public static class PolicyDigest
}
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
}
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
if (!document.Exceptions.Effects.IsDefaultOrEmpty || !document.Exceptions.RoutingTemplates.IsDefaultOrEmpty)
{
writer.WritePropertyName("exceptions");
writer.WriteStartObject();
if (!document.Exceptions.Effects.IsDefaultOrEmpty)
{
writer.WritePropertyName("effects");
writer.WriteStartArray();
foreach (var effect in document.Exceptions.Effects
.OrderBy(static e => e.Id, StringComparer.Ordinal))
{
WriteExceptionEffect(writer, effect);
}
writer.WriteEndArray();
}
if (!document.Exceptions.RoutingTemplates.IsDefaultOrEmpty)
{
writer.WritePropertyName("routingTemplates");
writer.WriteStartArray();
foreach (var template in document.Exceptions.RoutingTemplates
.OrderBy(static t => t.Id, StringComparer.Ordinal))
{
WriteExceptionRoutingTemplate(writer, template);
}
writer.WriteEndArray();
}
writer.WriteEndObject();
}
writer.WriteEndObject();
writer.Flush();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule)
{
@@ -193,19 +227,78 @@ public static class PolicyDigest
writer.WriteEndArray();
}
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
{
if (values.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName(propertyName);
writer.WriteStartArray();
foreach (var value in values)
{
writer.WriteStringValue(value);
}
writer.WriteEndArray();
}
}
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
{
if (values.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName(propertyName);
writer.WriteStartArray();
foreach (var value in values)
{
writer.WriteStringValue(value);
}
writer.WriteEndArray();
}
private static void WriteExceptionEffect(Utf8JsonWriter writer, PolicyExceptionEffect effect)
{
writer.WriteStartObject();
writer.WriteString("id", effect.Id);
if (!string.IsNullOrWhiteSpace(effect.Name))
{
writer.WriteString("name", effect.Name);
}
writer.WriteString("effect", effect.Effect.ToString().ToLowerInvariant());
if (effect.DowngradeSeverity is { } downgradeSeverity)
{
writer.WriteString("downgradeSeverity", downgradeSeverity.ToString());
}
if (!string.IsNullOrWhiteSpace(effect.RequiredControlId))
{
writer.WriteString("requiredControlId", effect.RequiredControlId);
}
if (!string.IsNullOrWhiteSpace(effect.RoutingTemplate))
{
writer.WriteString("routingTemplate", effect.RoutingTemplate);
}
if (effect.MaxDurationDays is int maxDurationDays)
{
writer.WriteNumber("maxDurationDays", maxDurationDays);
}
if (!string.IsNullOrWhiteSpace(effect.Description))
{
writer.WriteString("description", effect.Description);
}
writer.WriteEndObject();
}
private static void WriteExceptionRoutingTemplate(Utf8JsonWriter writer, PolicyExceptionRoutingTemplate template)
{
writer.WriteStartObject();
writer.WriteString("id", template.Id);
writer.WriteString("authorityRouteId", template.AuthorityRouteId);
if (template.RequireMfa)
{
writer.WriteBoolean("requireMfa", true);
}
if (!string.IsNullOrWhiteSpace(template.Description))
{
writer.WriteString("description", template.Description);
}
writer.WriteEndObject();
}
}

View File

@@ -1,25 +1,28 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
/// <summary>
/// Canonical representation of a StellaOps policy document.
/// </summary>
public sealed record PolicyDocument(
string Version,
ImmutableArray<PolicyRule> Rules,
ImmutableDictionary<string, string> Metadata)
{
public static PolicyDocument Empty { get; } = new(
PolicySchema.CurrentVersion,
ImmutableArray<PolicyRule>.Empty,
ImmutableDictionary<string, string>.Empty);
}
public static class PolicySchema
{
public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json";
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy;
/// <summary>
/// Canonical representation of a StellaOps policy document.
/// </summary>
public sealed record PolicyDocument(
string Version,
ImmutableArray<PolicyRule> Rules,
ImmutableDictionary<string, string> Metadata,
PolicyExceptionConfiguration Exceptions)
{
public static PolicyDocument Empty { get; } = new(
PolicySchema.CurrentVersion,
ImmutableArray<PolicyRule>.Empty,
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
}
public static class PolicySchema
{
public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json";
public const string CurrentVersion = "1.0";
public static PolicyDocumentFormat DetectFormat(string fileName)
@@ -154,12 +157,12 @@ public sealed record PolicyRuleMatchCriteria(
UsedByEntrypoint.IsDefaultOrEmpty;
}
public sealed record PolicyAction(
PolicyActionType Type,
PolicyIgnoreOptions? Ignore,
PolicyEscalateOptions? Escalate,
PolicyRequireVexOptions? RequireVex,
bool Quiet);
public sealed record PolicyAction(
PolicyActionType Type,
PolicyIgnoreOptions? Ignore,
PolicyEscalateOptions? Escalate,
PolicyRequireVexOptions? RequireVex,
bool Quiet);
public enum PolicyActionType
{
@@ -178,17 +181,61 @@ public sealed record PolicyEscalateOptions(
bool RequireKev,
double? MinimumEpss);
public sealed record PolicyRequireVexOptions(
ImmutableArray<string> Vendors,
ImmutableArray<string> Justifications);
public enum PolicySeverity
{
Critical,
High,
Medium,
Low,
Informational,
None,
Unknown,
}
public sealed record PolicyRequireVexOptions(
ImmutableArray<string> Vendors,
ImmutableArray<string> Justifications);
public enum PolicySeverity
{
Critical,
High,
Medium,
Low,
Informational,
None,
Unknown,
}
public sealed record PolicyExceptionConfiguration(
ImmutableArray<PolicyExceptionEffect> Effects,
ImmutableArray<PolicyExceptionRoutingTemplate> RoutingTemplates)
{
public static PolicyExceptionConfiguration Empty { get; } = new(
ImmutableArray<PolicyExceptionEffect>.Empty,
ImmutableArray<PolicyExceptionRoutingTemplate>.Empty);
public PolicyExceptionEffect? FindEffect(string effectId)
{
if (string.IsNullOrWhiteSpace(effectId) || Effects.IsDefaultOrEmpty)
{
return null;
}
return Effects.FirstOrDefault(effect =>
string.Equals(effect.Id, effectId, StringComparison.OrdinalIgnoreCase));
}
}
public sealed record PolicyExceptionEffect(
string Id,
string? Name,
PolicyExceptionEffectType Effect,
PolicySeverity? DowngradeSeverity,
string? RequiredControlId,
string? RoutingTemplate,
int? MaxDurationDays,
string? Description);
public enum PolicyExceptionEffectType
{
Suppress,
Defer,
Downgrade,
RequireControl,
}
public sealed record PolicyExceptionRoutingTemplate(
string Id,
string AuthorityRouteId,
bool RequireMfa,
string? Description);

View File

@@ -12,17 +12,38 @@
"description": {
"type": "string"
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
},
"rules": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/rule"
"metadata": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
},
"exceptions": {
"type": "object",
"properties": {
"effects": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/exceptionEffect"
},
"uniqueItems": true
},
"routingTemplates": {
"type": "array",
"items": {
"$ref": "#/$defs/exceptionRoutingTemplate"
},
"uniqueItems": true
}
},
"additionalProperties": false
},
"rules": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/rule"
}
}
},
@@ -36,17 +57,97 @@
"type": "string",
"enum": ["Critical", "High", "Medium", "Low", "Informational", "None", "Unknown"]
},
"stringArray": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"rule": {
"type": "object",
"required": ["name", "action"],
"stringArray": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"exceptionEffect": {
"type": "object",
"required": ["id", "effect"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"effect": {
"type": "string",
"enum": ["suppress", "defer", "downgrade", "requireControl"]
},
"downgradeSeverity": {
"$ref": "#/$defs/severity"
},
"requiredControlId": {
"$ref": "#/$defs/identifier"
},
"routingTemplate": {
"$ref": "#/$defs/identifier"
},
"maxDurationDays": {
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"effect": {
"const": "downgrade"
}
},
"required": ["effect"]
},
"then": {
"required": ["downgradeSeverity"]
}
},
{
"if": {
"properties": {
"effect": {
"const": "requireControl"
}
},
"required": ["effect"]
},
"then": {
"required": ["requiredControlId"]
}
}
]
},
"exceptionRoutingTemplate": {
"type": "object",
"required": ["id", "authorityRouteId"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"description": {
"type": "string"
},
"authorityRouteId": {
"$ref": "#/$defs/identifier"
},
"requireMfa": {
"type": "boolean"
}
},
"additionalProperties": false
},
"rule": {
"type": "object",
"required": ["name", "action"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"

View File

@@ -22,7 +22,7 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-EXC-25-001 | TODO | Policy Guild, Governance Guild | POLICY-SPL-23-001 | Extend SPL schema/spec to reference exception effects and routing templates; publish updated docs and validation fixtures. | Schema updated with exception references; validation tests cover effect types; docs draft ready. |
| POLICY-EXC-25-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-SPL-23-001 | Extend SPL schema/spec to reference exception effects and routing templates; publish updated docs and validation fixtures. | Schema updated with exception references; validation tests cover effect types; docs draft ready. |
## Reachability v1 (Epic 8)

View File

@@ -1,10 +1,14 @@
# SBOM Service Task Board — Epic 3: Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SBOM-SERVICE-21-001 | TODO | SBOM Service Guild, Cartographer Guild | CONCELIER-GRAPH-21-001 | Publish normalized SBOM projection schema (components, relationships, scopes, entrypoints) and implement read API with pagination + tenant enforcement. | Schema validated with fixtures; API documented; integration tests cover CycloneDX/SPDX inputs. |
| SBOM-SERVICE-21-002 | TODO | SBOM Service Guild, Scheduler Guild | SBOM-SERVICE-21-001, SCHED-MODELS-21-001 | Emit change events (`sbom.version.created`) carrying digest/version metadata for Graph Indexer builds; add replay/backfill tooling. | Events published on new SBOMs; consumer harness validated; replay scripts documented. |
| SBOM-SERVICE-21-003 | TODO | SBOM Service Guild | SBOM-SERVICE-21-001 | Provide entrypoint/service node management API (list/update overrides) feeding Cartographer path relevance with deterministic defaults. | Entrypoint API live; overrides persisted; docs updated; tests cover fallback logic. |
| SBOM-SERVICE-21-004 | TODO | SBOM Service Guild, Observability Guild | SBOM-SERVICE-21-001 | Wire observability: metrics (`sbom_projection_seconds`, `sbom_projection_size`), traces, structured logs with tenant info; set alerts for backlog. | Metrics/traces exposed; dashboards updated; alert thresholds defined. |
| SBOM-SERVICE-21-001 | BLOCKED (2025-10-27) | SBOM Service Guild, Cartographer Guild | CONCELIER-GRAPH-21-001 | Publish normalized SBOM projection schema (components, relationships, scopes, entrypoints) and implement read API with pagination + tenant enforcement. | Schema validated with fixtures; API documented; integration tests cover CycloneDX/SPDX inputs. |
> 2025-10-27: Awaiting projection schema from Concelier (`CONCELIER-GRAPH-21-001`) before we can finalize API payloads and fixtures.
| SBOM-SERVICE-21-002 | BLOCKED (2025-10-27) | SBOM Service Guild, Scheduler Guild | SBOM-SERVICE-21-001, SCHED-MODELS-21-001 | Emit change events (`sbom.version.created`) carrying digest/version metadata for Graph Indexer builds; add replay/backfill tooling. | Events published on new SBOMs; consumer harness validated; replay scripts documented. |
> 2025-10-27: Blocked until `SBOM-SERVICE-21-001` defines projection schema and endpoints.
| SBOM-SERVICE-21-003 | BLOCKED (2025-10-27) | SBOM Service Guild | SBOM-SERVICE-21-001 | Provide entrypoint/service node management API (list/update overrides) feeding Cartographer path relevance with deterministic defaults. | Entrypoint API live; overrides persisted; docs updated; tests cover fallback logic. |
> 2025-10-27: Depends on base projection schema (`SBOM-SERVICE-21-001`) which is blocked.
| SBOM-SERVICE-21-004 | BLOCKED (2025-10-27) | SBOM Service Guild, Observability Guild | SBOM-SERVICE-21-001 | Wire observability: metrics (`sbom_projection_seconds`, `sbom_projection_size`), traces, structured logs with tenant info; set alerts for backlog. | Metrics/traces exposed; dashboards updated; alert thresholds defined. |
> 2025-10-27: Projection pipeline not in place yet; will follow once `SBOM-SERVICE-21-001` unblocks.
## Policy Engine + Editor v1

View File

@@ -26,4 +26,10 @@ public interface IRunRepository
RunQueryOptions? options = null,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<Run>> ListByStateAsync(
RunState state,
int limit = 50,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default);
}

View File

@@ -150,4 +150,27 @@ internal sealed class RunRepository : IRunRepository
var documents = await find.ToListAsync(cancellationToken).ConfigureAwait(false);
return documents.Select(RunDocumentMapper.FromBsonDocument).ToArray();
}
public async Task<IReadOnlyList<Run>> ListByStateAsync(
RunState state,
int limit = 50,
IClientSessionHandle? session = null,
CancellationToken cancellationToken = default)
{
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit), limit, "Limit must be greater than zero.");
}
var filter = Filter.Eq("state", state.ToString().ToLowerInvariant());
var find = session is null
? _collection.Find(filter)
: _collection.Find(session, filter);
find = find.Sort(Sort.Ascending("createdAt"));
find = find.Limit(limit);
var documents = await find.ToListAsync(cancellationToken).ConfigureAwait(false);
return documents.Select(RunDocumentMapper.FromBsonDocument).ToArray();
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.IO;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Hosting;
using StellaOps.Scheduler.WebService.Options;
using Xunit;
namespace StellaOps.Scheduler.WebService.Tests;
public class SchedulerPluginHostFactoryTests
{
[Fact]
public void Build_usesDefaults_whenOptionsEmpty()
{
var options = new SchedulerOptions.PluginOptions();
var contentRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(contentRoot);
try
{
var hostOptions = SchedulerPluginHostFactory.Build(options, contentRoot);
var expectedBase = Path.GetFullPath(Path.Combine(contentRoot, ".."));
var expectedPlugins = Path.Combine(expectedBase, "plugins", "scheduler");
Assert.Equal(expectedBase, hostOptions.BaseDirectory);
Assert.Equal(expectedPlugins, hostOptions.PluginsDirectory);
Assert.Single(hostOptions.SearchPatterns, "StellaOps.Scheduler.Plugin.*.dll");
Assert.True(hostOptions.EnsureDirectoryExists);
Assert.False(hostOptions.RecursiveSearch);
Assert.Empty(hostOptions.PluginOrder);
}
finally
{
Directory.Delete(contentRoot, recursive: true);
}
}
[Fact]
public void Build_respectsConfiguredValues()
{
var options = new SchedulerOptions.PluginOptions
{
BaseDirectory = Path.Combine(Path.GetTempPath(), "scheduler-options", Guid.NewGuid().ToString("N")),
Directory = Path.Combine("custom", "plugins"),
RecursiveSearch = true,
EnsureDirectoryExists = false
};
options.SearchPatterns.Add("Custom.Plugin.*.dll");
options.OrderedPlugins.Add("StellaOps.Scheduler.Plugin.Alpha");
Directory.CreateDirectory(options.BaseDirectory!);
try
{
var hostOptions = SchedulerPluginHostFactory.Build(options, contentRootPath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")));
var expectedPlugins = Path.GetFullPath(Path.Combine(options.BaseDirectory!, options.Directory!));
Assert.Equal(options.BaseDirectory, hostOptions.BaseDirectory);
Assert.Equal(expectedPlugins, hostOptions.PluginsDirectory);
Assert.Single(hostOptions.SearchPatterns, "Custom.Plugin.*.dll");
Assert.Single(hostOptions.PluginOrder, "StellaOps.Scheduler.Plugin.Alpha");
Assert.True(hostOptions.RecursiveSearch);
Assert.False(hostOptions.EnsureDirectoryExists);
}
finally
{
Directory.Delete(options.BaseDirectory!, recursive: true);
}
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.IO;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Options;
namespace StellaOps.Scheduler.WebService.Hosting;
internal static class SchedulerPluginHostFactory
{
public static PluginHostOptions Build(SchedulerOptions.PluginOptions options, string contentRootPath)
{
ArgumentNullException.ThrowIfNull(options);
if (string.IsNullOrWhiteSpace(contentRootPath))
{
throw new ArgumentException("Content root path must be provided for plug-in discovery.", nameof(contentRootPath));
}
var baseDirectory = ResolveBaseDirectory(options.BaseDirectory, contentRootPath);
var pluginsDirectory = ResolvePluginsDirectory(options.Directory, baseDirectory);
var hostOptions = new PluginHostOptions
{
BaseDirectory = baseDirectory,
PluginsDirectory = pluginsDirectory,
PrimaryPrefix = "StellaOps.Scheduler",
RecursiveSearch = options.RecursiveSearch,
EnsureDirectoryExists = options.EnsureDirectoryExists
};
if (options.OrderedPlugins.Count > 0)
{
foreach (var pluginName in options.OrderedPlugins)
{
hostOptions.PluginOrder.Add(pluginName);
}
}
if (options.SearchPatterns.Count > 0)
{
foreach (var pattern in options.SearchPatterns)
{
hostOptions.SearchPatterns.Add(pattern);
}
}
else
{
hostOptions.SearchPatterns.Add("StellaOps.Scheduler.Plugin.*.dll");
}
return hostOptions;
}
private static string ResolveBaseDirectory(string? configuredBaseDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredBaseDirectory))
{
return Path.GetFullPath(Path.Combine(contentRootPath, ".."));
}
return Path.IsPathRooted(configuredBaseDirectory)
? configuredBaseDirectory
: Path.GetFullPath(Path.Combine(contentRootPath, configuredBaseDirectory));
}
private static string ResolvePluginsDirectory(string? configuredDirectory, string baseDirectory)
{
var pluginsDirectory = string.IsNullOrWhiteSpace(configuredDirectory)
? Path.Combine("plugins", "scheduler")
: configuredDirectory;
return Path.IsPathRooted(pluginsDirectory)
? pluginsDirectory
: Path.GetFullPath(Path.Combine(baseDirectory, pluginsDirectory));
}
}

View File

@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Scheduler.WebService.Options;
/// <summary>
/// Scheduler host configuration defaults consumed at startup for cross-cutting concerns
/// such as plug-in discovery.
/// </summary>
public sealed class SchedulerOptions
{
public PluginOptions Plugins { get; set; } = new();
public void Validate()
{
Plugins.Validate();
}
public sealed class PluginOptions
{
/// <summary>
/// Base directory resolving relative plug-in paths. Defaults to solution root.
/// </summary>
public string? BaseDirectory { get; set; }
/// <summary>
/// Directory containing plug-in binaries. Defaults to <c>plugins/scheduler</c>.
/// </summary>
public string? Directory { get; set; }
/// <summary>
/// Controls whether sub-directories are scanned for plug-ins.
/// </summary>
public bool RecursiveSearch { get; set; } = false;
/// <summary>
/// Ensures the plug-in directory exists on startup.
/// </summary>
public bool EnsureDirectoryExists { get; set; } = true;
/// <summary>
/// Explicit plug-in discovery patterns (supports globbing).
/// </summary>
public IList<string> SearchPatterns { get; } = new List<string>();
/// <summary>
/// Optional ordered plug-in assembly names (without extension).
/// </summary>
public IList<string> OrderedPlugins { get; } = new List<string>();
public void Validate()
{
foreach (var pattern in SearchPatterns)
{
if (string.IsNullOrWhiteSpace(pattern))
{
throw new InvalidOperationException("Scheduler plug-in search patterns cannot contain null or whitespace entries.");
}
}
foreach (var assemblyName in OrderedPlugins)
{
if (string.IsNullOrWhiteSpace(assemblyName))
{
throw new InvalidOperationException("Scheduler ordered plug-in entries cannot contain null or whitespace values.");
}
}
}
}
}

View File

@@ -4,6 +4,9 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Scheduler.WebService.Hosting;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Storage.Mongo;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
@@ -103,6 +106,17 @@ else
builder.Services.AddScoped<IGraphJobService, GraphJobService>();
builder.Services.AddImpactIndexStub();
var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get<SchedulerOptions>() ?? new SchedulerOptions();
schedulerOptions.Validate();
builder.Services.AddSingleton(schedulerOptions);
builder.Services.AddOptions<SchedulerOptions>()
.Bind(builder.Configuration.GetSection("Scheduler"))
.PostConfigure(options => options.Validate());
var pluginHostOptions = SchedulerPluginHostFactory.Build(schedulerOptions.Plugins, builder.Environment.ContentRootPath);
builder.Services.AddSingleton(pluginHostOptions);
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
if (authorityOptions.Enabled)
{
builder.Services.AddHttpContextAccessor();

View File

@@ -8,6 +8,7 @@
<ProjectReference Include="../StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
<ProjectReference Include="../StellaOps.Scheduler.Storage.Mongo/StellaOps.Scheduler.Storage.Mongo.csproj" />
<ProjectReference Include="../StellaOps.Scheduler.ImpactIndex/StellaOps.Scheduler.ImpactIndex.csproj" />
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>

View File

@@ -1,8 +1,8 @@
# Scheduler WebService Task Board (Sprint 16)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-WEB-16-101 | DOING (2025-10-19) | Scheduler WebService Guild | SCHED-MODELS-16-101 | Bootstrap Minimal API host with Authority OpTok + DPoP, health endpoints, plug-in discovery per architecture §§12. | Service boots with config validation; `/healthz`/`/readyz` pass; restart-only plug-ins enforced. |
# Scheduler WebService Task Board (Sprint 16)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-WEB-16-101 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-MODELS-16-101 | Bootstrap Minimal API host with Authority OpTok + DPoP, health endpoints, plug-in discovery per architecture §§12. | Service boots with config validation; `/healthz`/`/readyz` pass; restart-only plug-ins enforced. |
| SCHED-WEB-16-102 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-101 | Implement schedules CRUD (tenant-scoped) with cron validation, pause/resume, audit logging. | CRUD operations tested; invalid cron inputs rejected; audit entries persisted. |
| SCHED-WEB-16-103 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-102 | Runs API (list/detail/cancel), ad-hoc run POST, and impact preview endpoints. | Integration tests cover run lifecycle; preview returns counts/sample; cancellation honoured. |
| SCHED-WEB-16-104 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-QUEUE-16-401, SCHED-STORAGE-16-201 | Webhook endpoints for Feeder/Vexer exports with mTLS/HMAC validation and rate limiting. | Webhooks validated via tests; invalid signatures rejected; rate limits documented. |
@@ -42,5 +42,4 @@
| SCHED-VULN-29-002 | TODO | Scheduler WebService Guild, Observability Guild | SCHED-VULN-29-001 | Provide projector lag metrics endpoint and webhook notifications for backlog breaches consumed by DevOps dashboards. | Lag metrics exposed; webhook events triggered on thresholds; docs updated. |
## Notes
- 2025-10-19: SCHED-MODELS-16-101 (schemas/DTOs) is DONE, so API contracts for schedules/runs are ready to consume.
- Next steps for SCHED-WEB-16-101: create Minimal API host project scaffold, wire Authority OpTok + DPoP authentication via existing DI helpers, expose `/healthz` + `/readyz`, and load restart-only plugins per architecture §§12. Capture configuration validation and log shape aligned with Scheduler platform guidance before moving to CRUD implementation.
- 2025-10-27: Minimal API host now wires Authority, health endpoints, and restart-only plug-in discovery per architecture §§12.

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class ImpactShardPlannerTests
{
[Fact]
public void PlanShards_ReturnsSingleShardWhenParallelismNotSpecified()
{
var impactSet = CreateImpactSet(count: 3);
var planner = new ImpactShardPlanner();
var shards = planner.PlanShards(impactSet, maxJobs: null, parallelism: null);
Assert.Single(shards);
Assert.Equal(3, shards[0].Count);
}
[Fact]
public void PlanShards_RespectsMaxJobsLimit()
{
var impactSet = CreateImpactSet(count: 5);
var planner = new ImpactShardPlanner();
var shards = planner.PlanShards(impactSet, maxJobs: 2, parallelism: 4);
Assert.Equal(2, shards.Sum(shard => shard.Count));
Assert.True(shards.All(shard => shard.Count <= 1));
}
[Fact]
public void PlanShards_DistributesImagesEvenly()
{
var impactSet = CreateImpactSet(count: 10);
var planner = new ImpactShardPlanner();
var shards = planner.PlanShards(impactSet, maxJobs: null, parallelism: 3);
Assert.Equal(3, shards.Length);
var counts = shards.Select(shard => shard.Count).OrderBy(count => count).ToArray();
Assert.Equal(new[] {3, 3, 4}, counts);
var flattened = shards.SelectMany(shard => shard.Images).ToArray();
Assert.Equal(impactSet.Images, flattened, ImpactImageEqualityComparer.Instance);
}
private static ImpactSet CreateImpactSet(int count)
{
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var images = Enumerable.Range(0, count)
.Select(i => new ImpactImage(
$"sha256:{i:D64}",
"registry",
"repo/app",
namespaces: new[] { "team" },
tags: new[] { "prod" },
usedByEntrypoint: true))
.ToImmutableArray();
return new ImpactSet(selector, images, usageOnly: true, DateTimeOffset.UtcNow, total: count, snapshotId: null, schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
private sealed class ImpactImageEqualityComparer : IEqualityComparer<ImpactImage>
{
public static ImpactImageEqualityComparer Instance { get; } = new();
public bool Equals(ImpactImage? x, ImpactImage? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.ImageDigest, y.ImageDigest, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(ImpactImage obj)
=> StringComparer.OrdinalIgnoreCase.GetHashCode(obj.ImageDigest);
}
}

View File

@@ -1,3 +1,5 @@
using System.Collections.Generic;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class ImpactTargetingServiceTests
@@ -24,7 +26,7 @@ public sealed class ImpactTargetingServiceTests
usageOnly: false,
selector);
Assert.Same(expected, result);
Assert.Equal(expected, result);
mockIndex.VerifyAll();
}
@@ -56,9 +58,126 @@ public sealed class ImpactTargetingServiceTests
var service = new ImpactTargetingService(mockIndex.Object);
var result = await service.ResolveAllAsync(selector, usageOnly: true);
Assert.Same(expected, result);
Assert.Equal(expected, result);
mockIndex.VerifyAll();
}
}
[Fact]
public async Task ResolveByPurlsAsync_DeduplicatesImpactImagesByDigest()
{
var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha");
var indexResult = new ImpactSet(
selector,
new[]
{
new ImpactImage(
"sha256:111",
"registry-1",
"repo/app",
namespaces: new[] { "team-a" },
tags: new[] { "v1" },
usedByEntrypoint: false,
labels: new[]
{
KeyValuePair.Create("env", "prod")
}),
new ImpactImage(
"sha256:111",
"registry-1",
"repo/app",
namespaces: new[] { "team-b" },
tags: new[] { "v2" },
usedByEntrypoint: true,
labels: new[]
{
KeyValuePair.Create("env", "prod"),
KeyValuePair.Create("component", "api")
})
},
usageOnly: false,
DateTimeOffset.UtcNow,
total: 2,
snapshotId: "snap-1",
schemaVersion: SchedulerSchemaVersions.ImpactSet);
var mockIndex = new Mock<IImpactIndex>(MockBehavior.Strict);
mockIndex
.Setup(index => index.ResolveByPurlsAsync(
It.IsAny<IEnumerable<string>>(),
false,
selector,
It.IsAny<CancellationToken>()))
.ReturnsAsync(indexResult);
var service = new ImpactTargetingService(mockIndex.Object);
var result = await service.ResolveByPurlsAsync(new[] { "pkg:npm/a" }, usageOnly: false, selector);
Assert.Single(result.Images);
var image = result.Images[0];
Assert.Equal("sha256:111", image.ImageDigest);
Assert.Equal(new[] { "team-a", "team-b" }, image.Namespaces);
Assert.Equal(new[] { "v1", "v2" }, image.Tags);
Assert.True(image.UsedByEntrypoint);
Assert.Equal("registry-1", image.Registry);
Assert.Equal("repo/app", image.Repository);
Assert.Equal(2, result.Total);
Assert.Equal("prod", image.Labels["env"]);
Assert.Equal("api", image.Labels["component"]);
}
[Fact]
public async Task ResolveByPurlsAsync_FiltersImagesBySelectorConstraints()
{
var selector = new Selector(
SelectorScope.ByNamespace,
tenantId: "tenant-alpha",
namespaces: new[] { "team-a" },
includeTags: new[] { "prod-*" },
labels: new[] { new LabelSelector("env", new[] { "prod" }) });
var matching = new ImpactImage(
"sha256:aaa",
"registry-1",
"repo/app",
namespaces: new[] { "team-a" },
tags: new[] { "prod-202510" },
usedByEntrypoint: true,
labels: new[] { KeyValuePair.Create("env", "prod") });
var nonMatching = new ImpactImage(
"sha256:bbb",
"registry-1",
"repo/app",
namespaces: new[] { "team-b" },
tags: new[] { "dev" },
usedByEntrypoint: false,
labels: new[] { KeyValuePair.Create("env", "dev") });
var indexResult = new ImpactSet(
selector,
new[] { matching, nonMatching },
usageOnly: true,
DateTimeOffset.UtcNow,
total: 2,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
var mockIndex = new Mock<IImpactIndex>(MockBehavior.Strict);
mockIndex
.Setup(index => index.ResolveByPurlsAsync(
It.IsAny<IEnumerable<string>>(),
true,
selector,
It.IsAny<CancellationToken>()))
.ReturnsAsync(indexResult);
var service = new ImpactTargetingService(mockIndex.Object);
var result = await service.ResolveByPurlsAsync(new[] { "pkg:npm/a" }, usageOnly: true, selector);
Assert.Single(result.Images);
Assert.Equal("sha256:aaa", result.Images[0].ImageDigest);
}
private static ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly)
{

View File

@@ -0,0 +1,226 @@
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Storage.Mongo.Projections;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Worker.Options;
using StellaOps.Scheduler.Worker.Planning;
namespace StellaOps.Scheduler.Worker.Tests;
public sealed class PlannerExecutionServiceTests
{
[Fact]
public async Task ProcessAsync_WithImpactedImages_QueuesPlannerMessage()
{
var schedule = CreateSchedule();
var run = CreateRun(schedule.Id);
var impactSet = CreateImpactSet(schedule.Selection, images: 2);
var scheduleRepository = new Mock<IScheduleRepository>();
scheduleRepository
.Setup(repo => repo.GetAsync(run.TenantId, run.ScheduleId!, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(schedule);
var runRepository = new Mock<IRunRepository>();
runRepository
.Setup(repo => repo.UpdateAsync(It.IsAny<Run>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var snapshotRepository = new Mock<IImpactSnapshotRepository>();
snapshotRepository
.Setup(repo => repo.UpsertAsync(It.IsAny<ImpactSet>(), null, It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var runSummaryService = new Mock<IRunSummaryService>();
runSummaryService
.Setup(service => service.ProjectAsync(It.IsAny<Run>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(default(RunSummaryProjection)!));
var targetingService = new Mock<IImpactTargetingService>();
targetingService
.Setup(service => service.ResolveAllAsync(schedule.Selection, true, It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ImpactSet>(impactSet));
var plannerQueue = new Mock<ISchedulerPlannerQueue>();
plannerQueue
.Setup(queue => queue.EnqueueAsync(It.IsAny<PlannerQueueMessage>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new SchedulerQueueEnqueueResult("msg-1", false));
var options = new SchedulerWorkerOptions();
var service = new PlannerExecutionService(
scheduleRepository.Object,
runRepository.Object,
snapshotRepository.Object,
runSummaryService.Object,
targetingService.Object,
plannerQueue.Object,
options,
TimeProvider.System,
CreateLogger());
var result = await service.ProcessAsync(run, CancellationToken.None);
Assert.Equal(PlannerExecutionStatus.Enqueued, result.Status);
Assert.NotNull(result.UpdatedRun);
Assert.Equal(RunState.Queued, result.UpdatedRun!.State);
Assert.Equal(impactSet.Images.Length, result.UpdatedRun.Stats.Queued);
plannerQueue.Verify(queue => queue.EnqueueAsync(It.IsAny<PlannerQueueMessage>(), It.IsAny<CancellationToken>()), Times.Once);
snapshotRepository.Verify(repo => repo.UpsertAsync(It.IsAny<ImpactSet>(), null, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ProcessAsync_WithNoImpactedImages_CompletesWithoutWork()
{
var schedule = CreateSchedule();
var run = CreateRun(schedule.Id);
var impactSet = CreateImpactSet(schedule.Selection, images: 0);
var scheduleRepository = new Mock<IScheduleRepository>();
scheduleRepository
.Setup(repo => repo.GetAsync(run.TenantId, run.ScheduleId!, null, It.IsAny<CancellationToken>()))
.ReturnsAsync(schedule);
var runRepository = new Mock<IRunRepository>();
runRepository
.Setup(repo => repo.UpdateAsync(It.IsAny<Run>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var snapshotRepository = new Mock<IImpactSnapshotRepository>();
var runSummaryService = new Mock<IRunSummaryService>();
runSummaryService
.Setup(service => service.ProjectAsync(It.IsAny<Run>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(default(RunSummaryProjection)!));
var targetingService = new Mock<IImpactTargetingService>();
targetingService
.Setup(service => service.ResolveAllAsync(schedule.Selection, true, It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ImpactSet>(impactSet));
var plannerQueue = new Mock<ISchedulerPlannerQueue>();
var options = new SchedulerWorkerOptions();
var service = new PlannerExecutionService(
scheduleRepository.Object,
runRepository.Object,
snapshotRepository.Object,
runSummaryService.Object,
targetingService.Object,
plannerQueue.Object,
options,
TimeProvider.System,
CreateLogger());
var result = await service.ProcessAsync(run, CancellationToken.None);
Assert.Equal(PlannerExecutionStatus.CompletedWithoutWork, result.Status);
Assert.NotNull(result.UpdatedRun);
Assert.Equal(RunState.Completed, result.UpdatedRun!.State);
plannerQueue.Verify(queue => queue.EnqueueAsync(It.IsAny<PlannerQueueMessage>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task ProcessAsync_WhenScheduleMissing_MarksRunAsFailed()
{
var run = CreateRun(scheduleId: "missing");
var scheduleRepository = new Mock<IScheduleRepository>();
scheduleRepository
.Setup(repo => repo.GetAsync(run.TenantId, run.ScheduleId!, null, It.IsAny<CancellationToken>()))
.ReturnsAsync((Schedule?)null);
var runRepository = new Mock<IRunRepository>();
runRepository
.Setup(repo => repo.UpdateAsync(It.IsAny<Run>(), null, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var snapshotRepository = new Mock<IImpactSnapshotRepository>();
var runSummaryService = new Mock<IRunSummaryService>();
runSummaryService
.Setup(service => service.ProjectAsync(It.IsAny<Run>(), It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(default(RunSummaryProjection)!));
var targetingService = new Mock<IImpactTargetingService>();
var plannerQueue = new Mock<ISchedulerPlannerQueue>();
var service = new PlannerExecutionService(
scheduleRepository.Object,
runRepository.Object,
snapshotRepository.Object,
runSummaryService.Object,
targetingService.Object,
plannerQueue.Object,
new SchedulerWorkerOptions(),
TimeProvider.System,
CreateLogger());
var result = await service.ProcessAsync(run, CancellationToken.None);
Assert.Equal(PlannerExecutionStatus.Failed, result.Status);
Assert.NotNull(result.UpdatedRun);
Assert.Equal(RunState.Error, result.UpdatedRun!.State);
targetingService.Verify(service => service.ResolveAllAsync(It.IsAny<Selector>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
plannerQueue.Verify(queue => queue.EnqueueAsync(It.IsAny<PlannerQueueMessage>(), It.IsAny<CancellationToken>()), Times.Never);
}
private static Run CreateRun(string scheduleId)
{
return new Run(
id: "run_001",
tenantId: "tenant-alpha",
trigger: RunTrigger.Cron,
state: RunState.Planning,
stats: RunStats.Empty,
createdAt: DateTimeOffset.UtcNow.AddMinutes(-5),
scheduleId: scheduleId);
}
private static Schedule CreateSchedule()
{
return new Schedule(
id: "sch_001",
tenantId: "tenant-alpha",
name: "Nightly",
enabled: true,
cronExpression: "0 2 * * *",
timezone: "UTC",
mode: ScheduleMode.AnalysisOnly,
selection: new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"),
onlyIf: ScheduleOnlyIf.Default,
notify: ScheduleNotify.Default,
limits: ScheduleLimits.Default,
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
createdBy: "system",
updatedAt: DateTimeOffset.UtcNow.AddHours(-1),
updatedBy: "system",
subscribers: ImmutableArray<string>.Empty);
}
private static ImpactSet CreateImpactSet(Selector selector, int images)
{
var imageList = Enumerable.Range(0, images)
.Select(index => new ImpactImage(
imageDigest: $"sha256:{index:D64}",
registry: "registry",
repository: "repo/api",
namespaces: new[] { "team-alpha" },
tags: new[] { "latest" },
usedByEntrypoint: true))
.ToImmutableArray();
return new ImpactSet(
selector,
imageList,
usageOnly: true,
generatedAt: DateTimeOffset.UtcNow.AddSeconds(-10),
total: imageList.Length,
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
private static ILogger<PlannerExecutionService> CreateLogger()
{
return LoggerFactory.Create(builder => { }).CreateLogger<PlannerExecutionService>();
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Immutable;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Worker;
/// <summary>
/// Represents a deterministic batch of impacted images scheduled for execution.
/// </summary>
public sealed record ImpactShard
{
public ImpactShard(int index, ImmutableArray<ImpactImage> images)
{
Index = index;
Images = images.IsDefault ? ImmutableArray<ImpactImage>.Empty : images;
}
public int Index { get; }
public ImmutableArray<ImpactImage> Images { get; }
public int Count => Images.Length;
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Worker;
public interface IImpactShardPlanner
{
ImmutableArray<ImpactShard> PlanShards(ImpactSet impactSet, int? maxJobs, int? parallelism);
}
public sealed class ImpactShardPlanner : IImpactShardPlanner
{
public ImmutableArray<ImpactShard> PlanShards(ImpactSet impactSet, int? maxJobs, int? parallelism)
{
ArgumentNullException.ThrowIfNull(impactSet);
var images = impactSet.Images;
if (images.Length == 0)
{
return ImmutableArray<ImpactShard>.Empty;
}
IReadOnlyList<ImpactImage> boundedImages = images;
if (maxJobs is > 0 && maxJobs.Value < images.Length)
{
boundedImages = images.Take(maxJobs.Value).ToArray();
}
if (boundedImages.Count == 0)
{
return ImmutableArray<ImpactShard>.Empty;
}
var shardCount = parallelism.GetValueOrDefault(1);
if (shardCount <= 0)
{
shardCount = 1;
}
shardCount = Math.Min(shardCount, boundedImages.Count);
if (shardCount == 1)
{
return ImmutableArray.Create(new ImpactShard(0, boundedImages.ToImmutableArray()));
}
var ordered = boundedImages
.OrderBy(static image => image.ImageDigest, StringComparer.OrdinalIgnoreCase)
.ToArray();
var baseSize = ordered.Length / shardCount;
var remainder = ordered.Length % shardCount;
var offset = 0;
var shards = new List<ImpactShard>(shardCount);
for (var index = 0; index < shardCount; index++)
{
var size = baseSize + (index < remainder ? 1 : 0);
if (size <= 0)
{
continue;
}
var shardImages = ImmutableArray.Create(ordered, offset, size);
shards.Add(new ImpactShard(index, shardImages));
offset += size;
}
return shards.ToImmutableArray();
}
}

View File

@@ -1,4 +1,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using System.Linq;
using StellaOps.Scheduler.ImpactIndex;
using StellaOps.Scheduler.Models;
@@ -55,7 +58,8 @@ public sealed class ImpactTargetingService : IImpactTargetingService
return CreateEmptyImpactSet(selector, usageOnly);
}
return await _impactIndex.ResolveByPurlsAsync(distinct, usageOnly, selector, cancellationToken).ConfigureAwait(false);
var impactSet = await _impactIndex.ResolveByPurlsAsync(distinct, usageOnly, selector, cancellationToken).ConfigureAwait(false);
return SanitizeImpactSet(impactSet, selector);
}
public async ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(
@@ -78,16 +82,19 @@ public sealed class ImpactTargetingService : IImpactTargetingService
return CreateEmptyImpactSet(selector, usageOnly);
}
return await _impactIndex.ResolveByVulnerabilitiesAsync(distinct, usageOnly, selector, cancellationToken).ConfigureAwait(false);
var impactSet = await _impactIndex.ResolveByVulnerabilitiesAsync(distinct, usageOnly, selector, cancellationToken).ConfigureAwait(false);
return SanitizeImpactSet(impactSet, selector);
}
public ValueTask<ImpactSet> ResolveAllAsync(
public async ValueTask<ImpactSet> ResolveAllAsync(
Selector selector,
bool usageOnly,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(selector);
return _impactIndex.ResolveAllAsync(selector, usageOnly, cancellationToken);
var impactSet = await _impactIndex.ResolveAllAsync(selector, usageOnly, cancellationToken).ConfigureAwait(false);
return SanitizeImpactSet(impactSet, selector);
}
private ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly)
@@ -101,4 +108,266 @@ public sealed class ImpactTargetingService : IImpactTargetingService
snapshotId: null,
schemaVersion: SchedulerSchemaVersions.ImpactSet);
}
private static ImpactSet SanitizeImpactSet(ImpactSet impactSet, Selector selector)
{
ArgumentNullException.ThrowIfNull(impactSet);
ArgumentNullException.ThrowIfNull(selector);
if (impactSet.Images.Length == 0)
{
return impactSet;
}
var filteredImages = FilterAndDeduplicate(impactSet.Images, selector);
if (filteredImages.Length == impactSet.Images.Length && filteredImages.SequenceEqual(impactSet.Images))
{
return impactSet;
}
return new ImpactSet(
impactSet.Selector,
filteredImages,
impactSet.UsageOnly,
impactSet.GeneratedAt,
impactSet.Total,
impactSet.SnapshotId,
impactSet.SchemaVersion);
}
private static ImmutableArray<ImpactImage> FilterAndDeduplicate(
IReadOnlyList<ImpactImage> images,
Selector selector)
{
var digestFilter = selector.Digests.Length == 0
? null
: new HashSet<string>(selector.Digests, StringComparer.OrdinalIgnoreCase);
var namespaceFilter = selector.Namespaces.Length == 0
? null
: new HashSet<string>(selector.Namespaces, StringComparer.Ordinal);
var repositoryFilter = selector.Repositories.Length == 0
? null
: new HashSet<string>(selector.Repositories, StringComparer.Ordinal);
var tagMatchers = BuildTagMatchers(selector.IncludeTags);
var labelFilters = BuildLabelFilters(selector.Labels);
var filtered = new List<ImpactImage>(images.Count);
foreach (var image in images)
{
if (image is null)
{
continue;
}
if (!MatchesSelector(image, digestFilter, namespaceFilter, repositoryFilter, tagMatchers, labelFilters))
{
continue;
}
filtered.Add(image);
}
if (filtered.Count == 0)
{
return ImmutableArray<ImpactImage>.Empty;
}
return DeduplicateByDigest(filtered);
}
private static bool MatchesSelector(
ImpactImage image,
HashSet<string>? digestFilter,
HashSet<string>? namespaceFilter,
HashSet<string>? repositoryFilter,
IReadOnlyList<Func<string, bool>> tagMatchers,
IReadOnlyList<LabelFilter> labelFilters)
{
if (digestFilter is not null && !digestFilter.Contains(image.ImageDigest))
{
return false;
}
if (namespaceFilter is not null)
{
var matchesNamespace = image.Namespaces.Any(namespaceFilter.Contains);
if (!matchesNamespace)
{
return false;
}
}
if (repositoryFilter is not null && !repositoryFilter.Contains(image.Repository))
{
return false;
}
if (tagMatchers.Count > 0)
{
var tagMatches = image.Tags.Any(tag => tagMatchers.Any(matcher => matcher(tag)));
if (!tagMatches)
{
return false;
}
}
if (labelFilters.Count > 0)
{
foreach (var labelFilter in labelFilters)
{
if (!image.Labels.TryGetValue(labelFilter.Key, out var value))
{
return false;
}
if (labelFilter.AcceptedValues is not null && !labelFilter.AcceptedValues.Contains(value))
{
return false;
}
}
}
return true;
}
private static IReadOnlyList<Func<string, bool>> BuildTagMatchers(ImmutableArray<string> includeTags)
{
if (includeTags.Length == 0)
{
return Array.Empty<Func<string, bool>>();
}
var matchers = new List<Func<string, bool>>(includeTags.Length);
foreach (var pattern in includeTags)
{
if (string.IsNullOrWhiteSpace(pattern))
{
continue;
}
matchers.Add(CreateTagMatcher(pattern));
}
return matchers;
}
private static Func<string, bool> CreateTagMatcher(string pattern)
{
if (pattern == "*")
{
return static _ => true;
}
if (!pattern.Contains('*', StringComparison.Ordinal))
{
return tag => string.Equals(tag, pattern, StringComparison.OrdinalIgnoreCase);
}
var regexPattern = "^" + Regex.Escape(pattern).Replace("\\*", ".*", StringComparison.Ordinal) + "$";
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return tag => regex.IsMatch(tag);
}
private static IReadOnlyList<LabelFilter> BuildLabelFilters(ImmutableArray<LabelSelector> labelSelectors)
{
if (labelSelectors.Length == 0)
{
return Array.Empty<LabelFilter>();
}
var filters = new List<LabelFilter>(labelSelectors.Length);
foreach (var selector in labelSelectors)
{
var key = selector.Key.ToLowerInvariant();
HashSet<string>? values = null;
if (selector.Values.Length > 0)
{
values = new HashSet<string>(selector.Values, StringComparer.OrdinalIgnoreCase);
}
filters.Add(new LabelFilter(key, values));
}
return filters;
}
private static ImmutableArray<ImpactImage> DeduplicateByDigest(IEnumerable<ImpactImage> images)
{
var aggregators = new Dictionary<string, ImpactImageAggregator>(StringComparer.OrdinalIgnoreCase);
foreach (var image in images)
{
if (!aggregators.TryGetValue(image.ImageDigest, out var aggregator))
{
aggregator = new ImpactImageAggregator(image.ImageDigest);
aggregators.Add(image.ImageDigest, aggregator);
}
aggregator.Add(image);
}
return aggregators.Values
.Select(static aggregator => aggregator.Build())
.OrderBy(static image => image.ImageDigest, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private sealed record LabelFilter(string Key, HashSet<string>? AcceptedValues);
private sealed class ImpactImageAggregator
{
private readonly string _digest;
private readonly SortedSet<string> _registries = new(StringComparer.Ordinal);
private readonly SortedSet<string> _repositories = new(StringComparer.Ordinal);
private readonly SortedSet<string> _namespaces = new(StringComparer.Ordinal);
private readonly SortedSet<string> _tags = new(StringComparer.OrdinalIgnoreCase);
private readonly SortedDictionary<string, string> _labels = new(StringComparer.Ordinal);
private bool _usedByEntrypoint;
public ImpactImageAggregator(string digest)
{
_digest = digest;
}
public void Add(ImpactImage image)
{
_registries.Add(image.Registry);
_repositories.Add(image.Repository);
foreach (var ns in image.Namespaces)
{
_namespaces.Add(ns);
}
foreach (var tag in image.Tags)
{
_tags.Add(tag);
}
foreach (var label in image.Labels)
{
_labels[label.Key] = label.Value;
}
_usedByEntrypoint |= image.UsedByEntrypoint;
}
public ImpactImage Build()
{
var registry = _registries.Count > 0 ? _registries.Min! : string.Empty;
var repository = _repositories.Count > 0 ? _repositories.Min! : string.Empty;
var namespaces = _namespaces.Count == 0 ? Enumerable.Empty<string>() : _namespaces;
var tags = _tags.Count == 0 ? Enumerable.Empty<string>() : _tags;
return new ImpactImage(
_digest,
registry,
repository,
namespaces,
tags,
_usedByEntrypoint,
_labels);
}
}
}

View File

@@ -0,0 +1,82 @@
using System;
namespace StellaOps.Scheduler.Worker.Options;
/// <summary>
/// Strongly typed options for the scheduler worker host.
/// </summary>
public sealed class SchedulerWorkerOptions
{
public PlannerOptions Planner { get; set; } = new();
public void Validate()
{
Planner.Validate();
}
public sealed class PlannerOptions
{
/// <summary>
/// Maximum number of planning runs to fetch per polling iteration.
/// </summary>
public int BatchSize { get; set; } = 20;
/// <summary>
/// Polling cadence for the planner loop when work is available.
/// </summary>
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Delay applied between polls when no work is available.
/// </summary>
public TimeSpan IdleDelay { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>
/// Maximum number of tenants that can be processed concurrently.
/// </summary>
public int MaxConcurrentTenants { get; set; } = Environment.ProcessorCount;
/// <summary>
/// Maximum number of planning runs allowed per minute (global throttle).
/// </summary>
public int MaxRunsPerMinute { get; set; } = 120;
/// <summary>
/// Lease duration requested from the planner queue transport for deduplication.
/// </summary>
public TimeSpan QueueLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
public void Validate()
{
if (BatchSize <= 0)
{
throw new InvalidOperationException("Planner batch size must be greater than zero.");
}
if (PollInterval <= TimeSpan.Zero)
{
throw new InvalidOperationException("Planner poll interval must be greater than zero.");
}
if (IdleDelay <= TimeSpan.Zero)
{
throw new InvalidOperationException("Planner idle delay must be greater than zero.");
}
if (MaxConcurrentTenants <= 0)
{
throw new InvalidOperationException("Planner max concurrent tenants must be greater than zero.");
}
if (MaxRunsPerMinute <= 0)
{
throw new InvalidOperationException("Planner max runs per minute must be greater than zero.");
}
if (QueueLeaseDuration <= TimeSpan.Zero)
{
throw new InvalidOperationException("Planner queue lease duration must be greater than zero.");
}
}
}
}

View File

@@ -0,0 +1,168 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Worker.Options;
namespace StellaOps.Scheduler.Worker.Planning;
internal sealed class PlannerBackgroundService : BackgroundService
{
private readonly IRunRepository _runRepository;
private readonly PlannerExecutionService _executionService;
private readonly SchedulerWorkerOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PlannerBackgroundService> _logger;
private readonly TimeSpan _rateLimitInterval;
private DateTimeOffset _nextAllowedExecution;
public PlannerBackgroundService(
IRunRepository runRepository,
PlannerExecutionService executionService,
SchedulerWorkerOptions options,
TimeProvider? timeProvider,
ILogger<PlannerBackgroundService> logger)
{
_runRepository = runRepository ?? throw new ArgumentNullException(nameof(runRepository));
_executionService = executionService ?? throw new ArgumentNullException(nameof(executionService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_rateLimitInterval = options.Planner.MaxRunsPerMinute > 0
? TimeSpan.FromMinutes(1d / Math.Max(1d, options.Planner.MaxRunsPerMinute))
: TimeSpan.Zero;
_nextAllowedExecution = _timeProvider.GetUtcNow();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Scheduler planner loop started.");
while (!stoppingToken.IsCancellationRequested)
{
IReadOnlyList<Run> planningRuns;
try
{
planningRuns = await _runRepository
.ListByStateAsync(RunState.Planning, _options.Planner.BatchSize, cancellationToken: stoppingToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch planning runs; backing off.");
await DelayAsync(_options.Planner.IdleDelay, stoppingToken).ConfigureAwait(false);
continue;
}
if (planningRuns.Count == 0)
{
await DelayAsync(_options.Planner.IdleDelay, stoppingToken).ConfigureAwait(false);
continue;
}
var processed = 0;
var tenantsInFlight = new HashSet<string>(StringComparer.Ordinal);
foreach (var run in planningRuns)
{
if (!tenantsInFlight.Contains(run.TenantId) ||
tenantsInFlight.Count < _options.Planner.MaxConcurrentTenants)
{
tenantsInFlight.Add(run.TenantId);
}
else
{
continue;
}
await WaitForRateLimitAsync(stoppingToken).ConfigureAwait(false);
try
{
var result = await _executionService.ProcessAsync(run, stoppingToken).ConfigureAwait(false);
processed++;
switch (result.Status)
{
case PlannerExecutionStatus.Enqueued:
_logger.LogDebug(
"Run {RunId} queued with {ImageCount} impacted images.",
result.UpdatedRun?.Id,
result.ImpactSet?.Images.Length ?? 0);
break;
case PlannerExecutionStatus.CompletedWithoutWork:
_logger.LogDebug(
"Run {RunId} completed without impacted images.",
result.UpdatedRun?.Id);
break;
case PlannerExecutionStatus.Failed:
_logger.LogWarning(
"Planner failed for run {RunId}: {Reason}",
run.Id,
result.FailureReason);
break;
case PlannerExecutionStatus.Skipped:
_logger.LogDebug("Skipped run {RunId}.", run.Id);
break;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception while planning run {RunId}.", run.Id);
}
}
var delay = processed > 0 ? _options.Planner.PollInterval : _options.Planner.IdleDelay;
await DelayAsync(delay, stoppingToken).ConfigureAwait(false);
}
_logger.LogInformation("Scheduler planner loop stopping.");
}
private async ValueTask WaitForRateLimitAsync(CancellationToken cancellationToken)
{
if (_rateLimitInterval <= TimeSpan.Zero)
{
return;
}
var now = _timeProvider.GetUtcNow();
if (now < _nextAllowedExecution)
{
var wait = _nextAllowedExecution - now;
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, cancellationToken).ConfigureAwait(false);
}
}
_nextAllowedExecution = _timeProvider.GetUtcNow().Add(_rateLimitInterval);
}
private static async Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
if (delay <= TimeSpan.Zero)
{
return;
}
try
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
}
}
}

View File

@@ -0,0 +1,17 @@
using StellaOps.Scheduler.Models;
namespace StellaOps.Scheduler.Worker.Planning;
internal enum PlannerExecutionStatus
{
Enqueued,
CompletedWithoutWork,
Skipped,
Failed,
}
internal sealed record PlannerExecutionResult(
PlannerExecutionStatus Status,
Run? UpdatedRun = null,
ImpactSet? ImpactSet = null,
string? FailureReason = null);

View File

@@ -0,0 +1,242 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Scheduler.Storage.Mongo.Repositories;
using StellaOps.Scheduler.Storage.Mongo.Services;
using StellaOps.Scheduler.Worker.Options;
namespace StellaOps.Scheduler.Worker.Planning;
internal sealed class PlannerExecutionService
{
private readonly IScheduleRepository _scheduleRepository;
private readonly IRunRepository _runRepository;
private readonly IImpactSnapshotRepository _impactSnapshotRepository;
private readonly IRunSummaryService _runSummaryService;
private readonly IImpactTargetingService _impactTargetingService;
private readonly ISchedulerPlannerQueue _plannerQueue;
private readonly SchedulerWorkerOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PlannerExecutionService> _logger;
public PlannerExecutionService(
IScheduleRepository scheduleRepository,
IRunRepository runRepository,
IImpactSnapshotRepository impactSnapshotRepository,
IRunSummaryService runSummaryService,
IImpactTargetingService impactTargetingService,
ISchedulerPlannerQueue plannerQueue,
SchedulerWorkerOptions options,
TimeProvider? timeProvider,
ILogger<PlannerExecutionService> logger)
{
_scheduleRepository = scheduleRepository ?? throw new ArgumentNullException(nameof(scheduleRepository));
_runRepository = runRepository ?? throw new ArgumentNullException(nameof(runRepository));
_impactSnapshotRepository = impactSnapshotRepository ?? throw new ArgumentNullException(nameof(impactSnapshotRepository));
_runSummaryService = runSummaryService ?? throw new ArgumentNullException(nameof(runSummaryService));
_impactTargetingService = impactTargetingService ?? throw new ArgumentNullException(nameof(impactTargetingService));
_plannerQueue = plannerQueue ?? throw new ArgumentNullException(nameof(plannerQueue));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PlannerExecutionResult> ProcessAsync(Run run, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(run);
if (run.State != RunState.Planning)
{
_logger.LogDebug("Skipping run {RunId} because state is {State} (expected Planning).", run.Id, run.State);
return new PlannerExecutionResult(PlannerExecutionStatus.Skipped, run);
}
if (string.IsNullOrWhiteSpace(run.ScheduleId))
{
_logger.LogWarning("Run {RunId} has no scheduleId; marking as failed.", run.Id);
var failed = run with
{
State = RunState.Error,
Error = "Run missing schedule identifier.",
FinishedAt = _timeProvider.GetUtcNow()
};
await PersistRunAsync(failed, cancellationToken).ConfigureAwait(false);
return new PlannerExecutionResult(
PlannerExecutionStatus.Failed,
failed,
FailureReason: failed.Error);
}
Schedule? schedule;
try
{
schedule = await _scheduleRepository
.GetAsync(run.TenantId, run.ScheduleId!, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to load schedule {ScheduleId} for run {RunId}.", run.ScheduleId, run.Id);
schedule = null;
}
if (schedule is null)
{
var failed = run with
{
State = RunState.Error,
Error = $"Schedule '{run.ScheduleId}' not found.",
FinishedAt = _timeProvider.GetUtcNow()
};
await PersistRunAsync(failed, cancellationToken).ConfigureAwait(false);
return new PlannerExecutionResult(
PlannerExecutionStatus.Failed,
failed,
FailureReason: failed.Error);
}
var selector = schedule.Selection;
if (!string.Equals(selector.TenantId, run.TenantId, StringComparison.Ordinal))
{
selector = new Selector(
selector.Scope,
run.TenantId,
selector.Namespaces,
selector.Repositories,
selector.Digests,
selector.IncludeTags,
selector.Labels,
selector.ResolvesTags);
}
var usageOnly = schedule.Mode != ScheduleMode.ContentRefresh;
ImpactSet impactSet;
try
{
impactSet = await _impactTargetingService
.ResolveAllAsync(selector, usageOnly, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Impact targeting failed for run {RunId}.", run.Id);
var failed = run with
{
State = RunState.Error,
Error = $"Impact targeting failed: {ex.Message}",
FinishedAt = _timeProvider.GetUtcNow()
};
await PersistRunAsync(failed, cancellationToken).ConfigureAwait(false);
return new PlannerExecutionResult(
PlannerExecutionStatus.Failed,
failed,
FailureReason: failed.Error);
}
if (impactSet.Images.IsDefault)
{
impactSet = new ImpactSet(
impactSet.Selector,
ImmutableArray<ImpactImage>.Empty,
impactSet.UsageOnly,
impactSet.GeneratedAt,
impactSet.Total,
impactSet.SnapshotId,
impactSet.SchemaVersion);
}
var now = _timeProvider.GetUtcNow();
var plannedStats = new RunStats(
candidates: impactSet.Total,
deduped: impactSet.Images.Length,
queued: impactSet.Images.Length,
completed: 0,
deltas: 0,
newCriticals: 0,
newHigh: 0,
newMedium: 0,
newLow: 0);
if (impactSet.Images.Length == 0)
{
var completed = run with
{
State = RunState.Completed,
Stats = plannedStats,
StartedAt = now,
FinishedAt = now,
Error = null
};
await PersistRunAsync(completed, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Run {RunId} produced no impacted images; marking Completed.", run.Id);
return new PlannerExecutionResult(
PlannerExecutionStatus.CompletedWithoutWork,
completed,
impactSet);
}
var snapshotId = $"impact::{run.Id}";
var snapshot = new ImpactSet(
impactSet.Selector,
impactSet.Images,
impactSet.UsageOnly,
impactSet.GeneratedAt,
impactSet.Total,
snapshotId,
impactSet.SchemaVersion);
var queuedRun = run with
{
State = RunState.Queued,
Stats = plannedStats,
StartedAt = now,
Error = null
};
await _impactSnapshotRepository.UpsertAsync(snapshot, cancellationToken: cancellationToken).ConfigureAwait(false);
await PersistRunAsync(queuedRun, cancellationToken).ConfigureAwait(false);
var message = new PlannerQueueMessage(queuedRun, snapshot, schedule);
await _plannerQueue.EnqueueAsync(message, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Run {RunId} planned {ImageCount} images for tenant {TenantId} schedule {ScheduleId}.",
run.Id,
snapshot.Images.Length,
run.TenantId,
schedule.Id);
return new PlannerExecutionResult(
PlannerExecutionStatus.Enqueued,
queuedRun,
snapshot);
}
private async Task PersistRunAsync(Run updated, CancellationToken cancellationToken)
{
var success = await _runRepository.UpdateAsync(updated, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!success)
{
_logger.LogWarning("Failed to persist updated run {RunId}.", updated.Id);
}
if (!string.IsNullOrWhiteSpace(updated.ScheduleId))
{
try
{
await _runSummaryService.ProjectAsync(updated, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed projecting run summary for run {RunId}.", updated.Id);
}
}
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Scheduler.Worker.Tests")]

View File

@@ -7,5 +7,9 @@
<ItemGroup>
<ProjectReference Include="../StellaOps.Scheduler.ImpactIndex/StellaOps.Scheduler.ImpactIndex.csproj" />
<ProjectReference Include="../StellaOps.Scheduler.Models/StellaOps.Scheduler.Models.csproj" />
<ProjectReference Include="../StellaOps.Scheduler.Storage.Mongo/StellaOps.Scheduler.Storage.Mongo.csproj" />
<ProjectReference Include="../StellaOps.Scheduler.Queue/StellaOps.Scheduler.Queue.csproj" />
<PackageReference Include="Cronos" Version="0.10.0" />
<PackageReference Include="System.Threading.RateLimiting" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -2,12 +2,15 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-WORKER-16-201 | TODO | Scheduler Worker Guild | SCHED-QUEUE-16-401 | Planner loop (cron + event triggers) with lease management, fairness, and rate limiting (§6). | Planner integration tests cover cron/event triggers; rate limits enforced; logs include run IDs. |
| SCHED-WORKER-16-202 | DOING (2025-10-26) | Scheduler Worker Guild | SCHED-IMPACT-16-301 | Wire ImpactIndex targeting (ResolveByPurls/vulns), dedupe, shard planning. | Targeting tests confirm correct image selection; dedupe documented; shards evenly distributed. |
| SCHED-WORKER-16-201 | DOING (2025-10-27) | Scheduler Worker Guild | SCHED-QUEUE-16-401 | Planner loop (cron + event triggers) with lease management, fairness, and rate limiting (§6). | Planner integration tests cover cron/event triggers; rate limits enforced; logs include run IDs. |
| SCHED-WORKER-16-202 | DONE (2025-10-27) | Scheduler Worker Guild | SCHED-IMPACT-16-301 | Wire ImpactIndex targeting (ResolveByPurls/vulns), dedupe, shard planning. | Targeting tests confirm correct image selection; dedupe documented; shards evenly distributed. |
| SCHED-WORKER-16-203 | TODO | Scheduler Worker Guild | SCHED-WORKER-16-202 | Runner execution: call Scanner `/reports` (analysis-only) or `/scans` when configured; collect deltas; handle retries. | Runner tests stub Scanner; retries/backoff validated; deltas aggregated deterministically. |
| SCHED-WORKER-16-204 | TODO | Scheduler Worker Guild | SCHED-WORKER-16-203 | Emit events (`scheduler.rescan.delta`, `scanner.report.ready`) for Notify/UI with summaries. | Events published to queue; payload schema documented; integration tests verify consumption. |
| SCHED-WORKER-16-205 | TODO | Scheduler Worker Guild | SCHED-WORKER-16-201 | Metrics/telemetry: run stats, queue depth, planner latency, delta counts. | Metrics exported per spec; dashboards updated; alerts configured. |
> 2025-10-27: Impact targeting sanitizes selector-constrained results, dedupes digests, and documents shard planning in `docs/SCHED-WORKER-16-202-IMPACT-TARGETING.md`.
> 2025-10-27: Planner loop processes Planning runs via PlannerExecutionService; documented in docs/SCHED-WORKER-16-201-PLANNER.md.
## Policy Engine v2 (Sprint 20)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
@@ -39,8 +42,11 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SCHED-WORKER-27-301 | TODO | Scheduler Worker Guild, Policy Registry Guild | SCHED-WORKER-20-301, REGISTRY-API-27-005 | Implement policy batch simulation worker: shard SBOM inventories, invoke Policy Engine, emit partial results, handle retries/backoff, and publish progress events. | Worker processes seeded workloads, retries/backoff validated, metrics (`policy_simulation_shard_seconds`) emitted, integration tests cover failure recovery. |
> Docs dependency: `DOCS-POLICY-27-004` blocked until batch simulation worker shipping.
| SCHED-WORKER-27-302 | TODO | Scheduler Worker Guild, Observability Guild | SCHED-WORKER-27-301, REGISTRY-API-27-005 | Build reducer job aggregating shard outputs into final manifests (counts, deltas, samples) and writing to object storage with checksums; emit completion events. | Reducer produces deterministic manifests with checksums, events notify Registry/Web, dashboards updated with aggregate latency metrics. |
> Docs dependency: `DOCS-POLICY-27-004` requires reducer outputs for bundles.
| SCHED-WORKER-27-303 | TODO | Scheduler Worker Guild, Security Guild | SCHED-WORKER-27-301, AUTH-POLICY-27-002 | Enforce tenant isolation, scope checks, and attestation integration for simulation jobs; secret scanning pipeline for uploaded policy sources. | Jobs validate tenant scope before execution, attestation metadata attached to results, secret scan failures logged/blocked, security tests added. |
> Docs dependency: `DOCS-POLICY-27-009/012` need security/runbook details once delivered.
## Exceptions v1 (Sprint 25)

View File

@@ -0,0 +1,31 @@
# SCHED-WORKER-16-201 — Planner Loop
_Sprint 16 · Scheduler Worker Guild_
The planner loop is now materialised as a background service that picks up
`Run` records stuck in `Planning` state, resolves their impact, and enqueues
them for downstream execution.
## Highlights
- Uses `SchedulerWorkerOptions.Planner` to control poll cadence, batch size,
fairness (tenant cap), and global rate-limit.
- Fetches pending runs via `IRunRepository.ListByStateAsync` (new repository
surface) so we can operate across tenants without bespoke cursors.
- Delegates resolution to `PlannerExecutionService` which:
- Pulls the owning `Schedule` and normalises its selector to the run tenant.
- Invokes `IImpactTargetingService` to resolve impacted digests.
- Emits canonical `ImpactSet` snapshots to Mongo for reuse/debugging.
- Updates run stats/state and projects summaries via `IRunSummaryService`.
- Enqueues a deterministic `PlannerQueueMessage` to the planner queue when
impacted images exist; otherwise the run completes immediately.
- Fairness: one run per tenant per poll, keeping multi-tenant workloads from
starving smaller tenants.
- Rate limiting enforces a configurable minimum spacing between planned runs to
avoid queue floods.
## Follow-ups
- Wire schedule/event producers so that `Planning` runs are created from cron
ticks and webhook deltas.
- Introduce integration tests once the worker host is bootstrapped end-to-end.

View File

@@ -0,0 +1,32 @@
# SCHED-WORKER-16-202 — Impact Targeting & Shard Planning
_Sprint 16 · Scheduler Worker Guild_
This module wires the scheduler worker against the ImpactIndex while keeping execution deterministic and restart-safe.
## Impact targeting
`ImpactTargetingService` now normalizes change keys and filters the ImpactIndex result set using the schedule selector:
- tenant filters and per-scope digests/namespaces/repos
- case-insensitive tag globbing (supports `*` wildcards)
- label predicates with optional value allow-lists
Results are deduplicated by digest before they reach the planner. Multiple registry/repository observations of the same digest collapse into a single `ImpactImage`, preserving:
- union of namespaces/tags (sorted, de-duped)
- merged label metadata (case-insensitive keys)
- `usedByEntrypoint` propagated if any observation reported runtime usage
Empty candidate lists emit canonical empty `ImpactSet` instances so downstream code can stay branch-free.
## Shard planning
`ImpactShardPlanner` takes the sanitized `ImpactSet` and slices it into contiguous, digest-sorted ranges:
- honours `limits.maxJobs` before planning shards
- ensures shard count never exceeds remaining images
- produces near-even shard sizes (difference at most one image)
- uses stable digest ordering for deterministic queue semantics
Each shard carries an ordinal `Index` and the precise digest slice it should process. Planner tests cover max-job enforcement, even distribution, and continuity.

View File

@@ -0,0 +1,70 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace StellaOps.Signals.Tests;
public class SignalsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> factory;
public SignalsApiTests(WebApplicationFactory<Program> factory)
{
this.factory = factory;
}
[Fact]
public async Task Healthz_ReturnsOk()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/healthz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Readyz_ReturnsOk()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/readyz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(payload);
Assert.Equal("ready", payload!["status"]);
}
[Fact]
public async Task Ping_WithoutScopeHeader_ReturnsUnauthorized()
{
var client = factory.CreateClient();
var response = await client.GetAsync("/signals/ping");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Ping_WithMissingScope_ReturnsForbidden()
{
var client = factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "/signals/ping");
request.Headers.Add("X-Scopes", "signals:write");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Ping_WithReadScope_ReturnsNoContent()
{
var client = factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "/signals/ping");
request.Headers.Add("X-Scopes", "signals:read");
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Signals\StellaOps.Signals.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,29 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Signals.Authentication;
/// <summary>
/// Authentication handler used during development fallback.
/// </summary>
internal sealed class AnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public AnonymousAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity();
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}

View File

@@ -0,0 +1,61 @@
using System.Security.Claims;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Signals.Authentication;
/// <summary>
/// Header-based scope authorizer for development environments.
/// </summary>
internal static class HeaderScopeAuthorizer
{
internal static bool HasScope(ClaimsPrincipal principal, string requiredScope)
{
if (principal is null || string.IsNullOrWhiteSpace(requiredScope))
{
return false;
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var scope in scopes)
{
if (string.Equals(scope, requiredScope, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (string.Equals(claim.Value, requiredScope, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
internal static ClaimsPrincipal CreatePrincipal(string scopeBuffer)
{
var claims = new List<Claim>
{
new(StellaOpsClaimTypes.Scope, scopeBuffer)
};
foreach (var value in scopeBuffer.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, value));
}
var identity = new ClaimsIdentity(claims, authenticationType: "Header");
return new ClaimsPrincipal(identity);
}
}

View File

@@ -0,0 +1,41 @@
using System.Security.Claims;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Signals.Authentication;
/// <summary>
/// Helpers for evaluating token scopes.
/// </summary>
internal static class TokenScopeAuthorizer
{
internal static bool HasScope(ClaimsPrincipal principal, string requiredScope)
{
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (string.Equals(claim.Value, requiredScope, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
var normalized = StellaOpsScopes.Normalize(part);
if (normalized is not null && string.Equals(normalized, requiredScope, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Signals.Hosting;
/// <summary>
/// Tracks Signals service readiness state.
/// </summary>
public sealed class SignalsStartupState
{
/// <summary>
/// Indicates whether the service is ready to accept requests.
/// </summary>
public bool IsReady { get; set; } = true;
}

View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Signals.Options;
/// <summary>
/// Authority configuration for the Signals service.
/// </summary>
public sealed class SignalsAuthorityOptions
{
/// <summary>
/// Enables Authority-backed authentication.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Allows header-based development fallback when Authority is disabled.
/// </summary>
public bool AllowAnonymousFallback { get; set; } = true;
/// <summary>
/// Authority issuer URL.
/// </summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// Indicates whether HTTPS metadata is required.
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Optional metadata address override.
/// </summary>
public string? MetadataAddress { get; set; }
/// <summary>
/// Back-channel timeout (seconds).
/// </summary>
public int BackchannelTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Token clock skew allowance (seconds).
/// </summary>
public int TokenClockSkewSeconds { get; set; } = 60;
/// <summary>
/// Accepted token audiences.
/// </summary>
public IList<string> Audiences { get; } = new List<string>();
/// <summary>
/// Required scopes.
/// </summary>
public IList<string> RequiredScopes { get; } = new List<string>();
/// <summary>
/// Required tenants.
/// </summary>
public IList<string> RequiredTenants { get; } = new List<string>();
/// <summary>
/// Networks allowed to bypass scope enforcement.
/// </summary>
public IList<string> BypassNetworks { get; } = new List<string>();
/// <summary>
/// Validates the configured options.
/// </summary>
public void Validate()
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(Issuer))
{
throw new InvalidOperationException("Signals Authority issuer must be configured when Authority integration is enabled.");
}
if (!Uri.TryCreate(Issuer.Trim(), UriKind.Absolute, out var issuerUri))
{
throw new InvalidOperationException("Signals Authority issuer must be an absolute URI.");
}
if (RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Signals Authority issuer must use HTTPS unless running on loopback.");
}
if (BackchannelTimeoutSeconds <= 0)
{
throw new InvalidOperationException("Signals Authority back-channel timeout must be greater than zero seconds.");
}
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
{
throw new InvalidOperationException("Signals Authority token clock skew must be between 0 and 300 seconds.");
}
}
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Linq;
using StellaOps.Signals.Routing;
namespace StellaOps.Signals.Options;
/// <summary>
/// Applies Signals-specific defaults to <see cref="SignalsAuthorityOptions"/>.
/// </summary>
internal static class SignalsAuthorityOptionsConfigurator
{
/// <summary>
/// Ensures required defaults are populated.
/// </summary>
public static void ApplyDefaults(SignalsAuthorityOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (!options.Audiences.Any())
{
options.Audiences.Add("api://signals");
}
EnsureScope(options, SignalsPolicies.Read);
EnsureScope(options, SignalsPolicies.Write);
EnsureScope(options, SignalsPolicies.Admin);
}
private static void EnsureScope(SignalsAuthorityOptions options, string scope)
{
if (options.RequiredScopes.Any(existing => string.Equals(existing, scope, StringComparison.OrdinalIgnoreCase)))
{
return;
}
options.RequiredScopes.Add(scope);
}
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Signals.Options;
/// <summary>
/// Root configuration for the Signals service.
/// </summary>
public sealed class SignalsOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Signals";
/// <summary>
/// Authority integration settings.
/// </summary>
public SignalsAuthorityOptions Authority { get; } = new();
/// <summary>
/// Validates configured options.
/// </summary>
public void Validate()
{
Authority.Validate();
}
}

View File

@@ -0,0 +1,164 @@
using System.IO;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using NetEscapades.Configuration.Yaml;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Signals.Authentication;
using StellaOps.Signals.Hosting;
using StellaOps.Signals.Options;
using StellaOps.Signals.Routing;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddStellaOpsDefaults(options =>
{
options.BasePath = builder.Environment.ContentRootPath;
options.EnvironmentPrefix = "SIGNALS_";
options.ConfigureBuilder = configurationBuilder =>
{
var contentRoot = builder.Environment.ContentRootPath;
foreach (var relative in new[]
{
"../etc/signals.yaml",
"../etc/signals.local.yaml",
"signals.yaml",
"signals.local.yaml"
})
{
var path = Path.Combine(contentRoot, relative);
configurationBuilder.AddYamlFile(path, optional: true);
}
};
});
var bootstrap = builder.Configuration.BindOptions<SignalsOptions>(
SignalsOptions.SectionName,
static (options, _) =>
{
SignalsAuthorityOptionsConfigurator.ApplyDefaults(options.Authority);
options.Validate();
});
builder.Services.AddOptions<SignalsOptions>()
.Bind(builder.Configuration.GetSection(SignalsOptions.SectionName))
.PostConfigure(static options =>
{
SignalsAuthorityOptionsConfigurator.ApplyDefaults(options.Authority);
options.Validate();
})
.Validate(static options =>
{
try
{
options.Validate();
return true;
}
catch (Exception ex)
{
throw new OptionsValidationException(
SignalsOptions.SectionName,
typeof(SignalsOptions),
new[] { ex.Message });
}
})
.ValidateOnStart();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<SignalsOptions>>().Value);
builder.Services.AddSingleton<SignalsStartupState>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddAuthorization();
if (bootstrap.Authority.Enabled)
{
builder.Services.AddHttpContextAccessor();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{SignalsOptions.SectionName}:Authority",
configure: resourceOptions =>
{
resourceOptions.Authority = bootstrap.Authority.Issuer;
resourceOptions.RequireHttpsMetadata = bootstrap.Authority.RequireHttpsMetadata;
resourceOptions.MetadataAddress = bootstrap.Authority.MetadataAddress;
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(bootstrap.Authority.BackchannelTimeoutSeconds);
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(bootstrap.Authority.TokenClockSkewSeconds);
resourceOptions.Audiences.Clear();
foreach (var audience in bootstrap.Authority.Audiences)
{
resourceOptions.Audiences.Add(audience);
}
resourceOptions.RequiredScopes.Clear();
foreach (var scope in bootstrap.Authority.RequiredScopes)
{
resourceOptions.RequiredScopes.Add(scope);
}
foreach (var tenant in bootstrap.Authority.RequiredTenants)
{
resourceOptions.RequiredTenants.Add(tenant);
}
foreach (var network in bootstrap.Authority.BypassNetworks)
{
resourceOptions.BypassNetworks.Add(network);
}
});
}
else
{
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "Anonymous";
options.DefaultChallengeScheme = "Anonymous";
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
}
var app = builder.Build();
if (!bootstrap.Authority.Enabled)
{
app.Logger.LogWarning("Signals Authority authentication is disabled; relying on header-based development fallback.");
}
app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/healthz").AllowAnonymous();
app.MapGet("/readyz", static (SignalsStartupState state) =>
state.IsReady ? Results.Ok(new { status = "ready" }) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
.AllowAnonymous();
app.MapGet("/signals/ping", static (HttpContext context) =>
{
const string requiredScope = SignalsPolicies.Read;
if (context.User?.Identity?.IsAuthenticated == true)
{
return TokenScopeAuthorizer.HasScope(context.User, requiredScope)
? Results.NoContent()
: Results.StatusCode(StatusCodes.Status403Forbidden);
}
if (!context.Request.Headers.TryGetValue("X-Scopes", out var values) ||
string.IsNullOrWhiteSpace(values.ToString()))
{
return Results.Unauthorized();
}
var principal = HeaderScopeAuthorizer.CreatePrincipal(values.ToString());
return HeaderScopeAuthorizer.HasScope(principal, requiredScope)
? Results.NoContent()
: Results.StatusCode(StatusCodes.Status403Forbidden);
})
.WithName("SignalsPing");
app.Run();
public partial class Program;

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Signals.Routing;
/// <summary>
/// Signals service authorization policy names and scope constants.
/// </summary>
public static class SignalsPolicies
{
/// <summary>
/// Scope required for read operations.
/// </summary>
public const string Read = "signals:read";
/// <summary>
/// Scope required for write operations.
/// </summary>
public const string Write = "signals:write";
/// <summary>
/// Scope required for administrative operations.
/// </summary>
public const string Admin = "signals:admin";
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,8 +1,13 @@
# Signals Service Task Board — Reachability v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SIGNALS-24-001 | TODO | Signals Guild, Architecture Guild | SBOM-GRAPH-24-002 | Implement Signals API skeleton (ASP.NET Minimal API) with auth middleware, health checks, and configuration binding. | Service boots with configuration validation, `/healthz`/`/readyz` return 200, RBAC enforced in integration tests. |
| SIGNALS-24-002 | TODO | Signals Guild, Language Specialists | SIGNALS-24-001 | Build callgraph ingestion pipeline (Java/Node/Python/Go parsers) normalizing into `callgraphs` collection and storing artifact metadata in object storage. | Parsers accept sample artifacts; data persisted with schema validation; unit tests cover malformed inputs. |
| SIGNALS-24-003 | TODO | Signals Guild, Runtime Guild | SIGNALS-24-001 | Implement runtime facts ingestion endpoint and normalizer (process, sockets, container metadata) populating `context_facts` with AOC provenance. | Endpoint ingests fixture batches; duplicates deduped; schema enforced; tests cover privacy filters. |
| SIGNALS-24-004 | TODO | Signals Guild, Data Science | SIGNALS-24-002, SIGNALS-24-003 | Deliver reachability scoring engine producing states/scores and writing to `reachability_facts`; expose configuration for weights. | Scoring engine deterministic; tests cover state transitions; metrics emitted. |
| SIGNALS-24-005 | TODO | Signals Guild, Platform Events Guild | SIGNALS-24-004 | Implement Redis caches (`reachability_cache:*`), invalidation on new facts, and publish `signals.fact.updated` events. | Cache hit rate tracked; invalidations working; events delivered with idempotent ids; integration tests pass. |
| SIGNALS-24-001 | BLOCKED (2025-10-27) | Signals Guild, Architecture Guild | SBOM-GRAPH-24-002 | Implement Signals API skeleton (ASP.NET Minimal API) with auth middleware, health checks, and configuration binding. | Service boots with configuration validation, `/healthz`/`/readyz` return 200, RBAC enforced in integration tests. |
> 2025-10-27: Skeleton host landed, awaiting `AUTH-SIG-26-001` for production scope issuance and tenant enforcement before marking complete. Coordination opened with Authority Guild (#signals-auth) to publish scope mapping.
| SIGNALS-24-002 | BLOCKED (2025-10-27) | Signals Guild, Language Specialists | SIGNALS-24-001 | Build callgraph ingestion pipeline (Java/Node/Python/Go parsers) normalizing into `callgraphs` collection and storing artifact metadata in object storage. | Parsers accept sample artifacts; data persisted with schema validation; unit tests cover malformed inputs. |
> 2025-10-27: Awaiting Signals API skeleton (SIGNALS-24-001) and scope issuance before landing storage schemas and endpoints.
| SIGNALS-24-003 | BLOCKED (2025-10-27) | Signals Guild, Runtime Guild | SIGNALS-24-001 | Implement runtime facts ingestion endpoint and normalizer (process, sockets, container metadata) populating `context_facts` with AOC provenance. | Endpoint ingests fixture batches; duplicates deduped; schema enforced; tests cover privacy filters. |
> 2025-10-27: Depends on SIGNALS-24-001 for base API host + authentication plumbing.
| SIGNALS-24-004 | BLOCKED (2025-10-27) | Signals Guild, Data Science | SIGNALS-24-002, SIGNALS-24-003 | Deliver reachability scoring engine producing states/scores and writing to `reachability_facts`; expose configuration for weights. | Scoring engine deterministic; tests cover state transitions; metrics emitted. |
> 2025-10-27: Upstream ingestion pipelines (SIGNALS-24-002/003) blocked; scoring engine cannot proceed.
| SIGNALS-24-005 | BLOCKED (2025-10-27) | Signals Guild, Platform Events Guild | SIGNALS-24-004 | Implement Redis caches (`reachability_cache:*`), invalidation on new facts, and publish `signals.fact.updated` events. | Cache hit rate tracked; invalidations working; events delivered with idempotent ids; integration tests pass. |
> 2025-10-27: Awaiting scoring engine and ingestion layers before wiring cache/events.

View File

@@ -26,9 +26,9 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| UI-LNM-22-001 | TODO | UI Guild, Policy Guild | SCANNER-LNM-21-002, WEB-LNM-21-001 | Build Evidence panel showing policy decision with advisory observations/linksets side-by-side, conflict badges, AOC chain, and raw doc download links. | Panel renders multiple sources; conflict badges accessible; e2e tests cover high-volume linksets. |
| UI-LNM-22-002 | TODO | UI Guild | UI-LNM-22-001 | Implement filters (source, severity bucket, conflict-only, CVSS vector presence) and pagination/lazy loading for large linksets. | Filters respond within 500 ms; virtualization validated; unit/e2e tests added. |
| UI-LNM-22-003 | TODO | UI Guild, Excititor Guild | UI-LNM-22-001, WEB-LNM-21-002 | Add VEX tab with status/justification summaries, conflict indicators, and export actions. | VEX tab displays multiple observations; exports produce zipped OSV/CycloneDX; tests updated. |
| UI-LNM-22-001 | TODO | UI Guild, Policy Guild | SCANNER-LNM-21-002, WEB-LNM-21-001 | Build Evidence panel showing policy decision with advisory observations/linksets side-by-side, conflict badges, AOC chain, and raw doc download links. Docs `DOCS-LNM-22-005` waiting on delivered UI for screenshots + flows. | Panel renders multiple sources; conflict badges accessible; e2e tests cover high-volume linksets. |
| UI-LNM-22-002 | TODO | UI Guild | UI-LNM-22-001 | Implement filters (source, severity bucket, conflict-only, CVSS vector presence) and pagination/lazy loading for large linksets. Docs depend on finalized filtering UX. | Filters respond within 500 ms; virtualization validated; unit/e2e tests added. |
| UI-LNM-22-003 | TODO | UI Guild, Excititor Guild | UI-LNM-22-001, WEB-LNM-21-002 | Add VEX tab with status/justification summaries, conflict indicators, and export actions. Required for `DOCS-LNM-22-005` coverage of VEX evidence tab. | VEX tab displays multiple observations; exports produce zipped OSV/CycloneDX; tests updated. |
| UI-LNM-22-004 | TODO | UI Guild | UI-LNM-22-001 | Provide permalink + copy-to-clipboard for selected component/linkset/policy combination; ensure high-contrast theme support. | Permalink reproduces state; accessibility audit passes; telemetry events logged. |
## Policy Engine + Editor v1 (Sprint 23)

Some files were not shown because too many files have changed in this diff Show More