feat: Implement Scheduler Worker Options and Planner Loop
- 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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (120 s OpTok, 300 s 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 (120 s OpTok TTL, 300 s 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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" }));
|
||||
|
||||
|
||||
3
src/StellaOps.Cartographer/Properties/AssemblyInfo.cs
Normal file
3
src/StellaOps.Cartographer/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Cartographer.Tests")]
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>(), "{}"));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
234
src/StellaOps.Cli/Services/ConcelierObservationsClient.cs
Normal file
234
src/StellaOps.Cli/Services/ConcelierObservationsClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
12
src/StellaOps.Cli/Services/IConcelierObservationsClient.cs
Normal file
12
src/StellaOps.Cli/Services/IConcelierObservationsClient.cs
Normal 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);
|
||||
}
|
||||
109
src/StellaOps.Cli/Services/Models/AdvisoryObservationsModels.cs
Normal file
109
src/StellaOps.Cli/Services/Models/AdvisoryObservationsModels.cs
Normal 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>();
|
||||
}
|
||||
93
src/StellaOps.Cli/Services/Models/AocIngestDryRunModels.cs
Normal file
93
src/StellaOps.Cli/Services/Models/AocIngestDryRunModels.cs
Normal 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; }
|
||||
}
|
||||
46
src/StellaOps.Cli/Services/Models/PolicyFindingsModels.cs
Normal file
46
src/StellaOps.Cli/Services/Models/PolicyFindingsModels.cs
Normal 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);
|
||||
26
src/StellaOps.Cli/Services/Models/PolicySimulationModels.cs
Normal file
26
src/StellaOps.Cli/Services/Models/PolicySimulationModels.cs
Normal 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);
|
||||
@@ -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; }
|
||||
}
|
||||
18
src/StellaOps.Cli/Services/PolicyApiException.cs
Normal file
18
src/StellaOps.Cli/Services/PolicyApiException.cs
Normal 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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"StellaOps": {
|
||||
"ApiKey": "",
|
||||
"BackendUrl": "",
|
||||
"ScannerCacheDirectory": "scanners",
|
||||
"ApiKey": "",
|
||||
"BackendUrl": "",
|
||||
"ConcelierUrl": "",
|
||||
"ScannerCacheDirectory": "scanners",
|
||||
"ResultsDirectory": "results",
|
||||
"DefaultRunner": "dotnet",
|
||||
"ScannerSignaturePublicKeyPath": "",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 §§1–2. | 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 §§1–2. | 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 §§1–2. 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 §§1–2.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
22
src/StellaOps.Scheduler.Worker/ImpactShard.cs
Normal file
22
src/StellaOps.Scheduler.Worker/ImpactShard.cs
Normal 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;
|
||||
}
|
||||
74
src/StellaOps.Scheduler.Worker/ImpactShardPlanner.cs
Normal file
74
src/StellaOps.Scheduler.Worker/ImpactShardPlanner.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scheduler.Worker.Tests")]
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
70
src/StellaOps.Signals.Tests/SignalsApiTests.cs
Normal file
70
src/StellaOps.Signals.Tests/SignalsApiTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj
Normal file
20
src/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj
Normal 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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
41
src/StellaOps.Signals/Authentication/TokenScopeAuthorizer.cs
Normal file
41
src/StellaOps.Signals/Authentication/TokenScopeAuthorizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/StellaOps.Signals/Hosting/SignalsStartupState.cs
Normal file
12
src/StellaOps.Signals/Hosting/SignalsStartupState.cs
Normal 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;
|
||||
}
|
||||
101
src/StellaOps.Signals/Options/SignalsAuthorityOptions.cs
Normal file
101
src/StellaOps.Signals/Options/SignalsAuthorityOptions.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
25
src/StellaOps.Signals/Options/SignalsOptions.cs
Normal file
25
src/StellaOps.Signals/Options/SignalsOptions.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
164
src/StellaOps.Signals/Program.cs
Normal file
164
src/StellaOps.Signals/Program.cs
Normal 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;
|
||||
22
src/StellaOps.Signals/Routing/SignalsPolicies.cs
Normal file
22
src/StellaOps.Signals/Routing/SignalsPolicies.cs
Normal 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";
|
||||
}
|
||||
16
src/StellaOps.Signals/StellaOps.Signals.csproj
Normal file
16
src/StellaOps.Signals/StellaOps.Signals.csproj
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user