feat: Implement Scheduler Worker Options and Planner Loop
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			- Added `SchedulerWorkerOptions` class to encapsulate configuration for the scheduler worker. - Introduced `PlannerBackgroundService` to manage the planner loop, fetching and processing planning runs. - Created `PlannerExecutionService` to handle the execution logic for planning runs, including impact targeting and run persistence. - Developed `PlannerExecutionResult` and `PlannerExecutionStatus` to standardize execution outcomes. - Implemented validation logic within `SchedulerWorkerOptions` to ensure proper configuration. - Added documentation for the planner loop and impact targeting features. - Established health check endpoints and authentication mechanisms for the Signals service. - Created unit tests for the Signals API to ensure proper functionality and response handling. - Configured options for authority integration and fallback authentication methods.
This commit is contained in:
		| @@ -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