up
Some checks failed
api-governance / spectral-lint (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Some checks failed
api-governance / spectral-lint (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Auth;
|
||||
|
||||
public sealed class HeaderScopeAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public const string SchemeName = "HeaderScope";
|
||||
|
||||
#pragma warning disable CS0618 // ISystemClock obsolete; base ctor signature still requires it on this TF.
|
||||
public HeaderScopeAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock) : base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Accept any request; scopes are read from `scope` header (space-separated)
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "anonymous") };
|
||||
|
||||
if (Request.Headers.TryGetValue("scope", out var scopeHeader))
|
||||
{
|
||||
claims.Add(new("scope", scopeHeader.ToString()));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.AirGap.Controller.Options;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.DependencyInjection;
|
||||
|
||||
public static class AirGapControllerServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAirGapController(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<AirGapControllerMongoOptions>(configuration.GetSection("AirGap:Mongo"));
|
||||
|
||||
services.AddSingleton<StalenessCalculator>();
|
||||
services.AddSingleton<AirGapStateService>();
|
||||
|
||||
services.AddSingleton<IAirGapStateStore>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<AirGapControllerMongoOptions>>().Value;
|
||||
if (string.IsNullOrWhiteSpace(opts.ConnectionString))
|
||||
{
|
||||
return new InMemoryAirGapStateStore();
|
||||
}
|
||||
|
||||
var mongoClient = new MongoClient(opts.ConnectionString);
|
||||
var database = mongoClient.GetDatabase(string.IsNullOrWhiteSpace(opts.Database) ? "stellaops_airgap" : opts.Database);
|
||||
var collection = MongoAirGapStateStore.EnsureCollection(database);
|
||||
return new MongoAirGapStateStore(collection);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
18
src/AirGap/StellaOps.AirGap.Controller/Domain/AirGapState.cs
Normal file
18
src/AirGap/StellaOps.AirGap.Controller/Domain/AirGapState.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Domain;
|
||||
|
||||
public sealed record AirGapState
|
||||
{
|
||||
public const string SingletonId = "singleton";
|
||||
|
||||
public string Id { get; init; } = SingletonId;
|
||||
public string TenantId { get; init; } = "default";
|
||||
public bool Sealed { get; init; }
|
||||
= false;
|
||||
public string? PolicyHash { get; init; }
|
||||
= null;
|
||||
public TimeAnchor TimeAnchor { get; init; } = TimeAnchor.Unknown;
|
||||
public DateTimeOffset LastTransitionAt { get; init; } = DateTimeOffset.MinValue;
|
||||
public StalenessBudget StalenessBudget { get; init; } = StalenessBudget.Default;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Endpoints;
|
||||
|
||||
internal static class AirGapEndpoints
|
||||
{
|
||||
private const string StatusScope = "airgap:status:read";
|
||||
private const string SealScope = "airgap:seal";
|
||||
|
||||
public static RouteGroupBuilder MapAirGapEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/system/airgap")
|
||||
.RequireAuthorization();
|
||||
|
||||
group.MapGet("/status", HandleStatus)
|
||||
.RequireScope(StatusScope)
|
||||
.WithName("AirGapStatus");
|
||||
|
||||
group.MapPost("/seal", HandleSeal)
|
||||
.RequireScope(SealScope)
|
||||
.WithName("AirGapSeal");
|
||||
|
||||
group.MapPost("/unseal", HandleUnseal)
|
||||
.RequireScope(SealScope)
|
||||
.WithName("AirGapUnseal");
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleStatus(
|
||||
ClaimsPrincipal user,
|
||||
AirGapStateService service,
|
||||
TimeProvider timeProvider,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var status = await service.GetStatusAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleSeal(
|
||||
SealRequest request,
|
||||
ClaimsPrincipal user,
|
||||
AirGapStateService service,
|
||||
StalenessCalculator stalenessCalculator,
|
||||
TimeProvider timeProvider,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyHash))
|
||||
{
|
||||
return Results.BadRequest(new { error = "policy_hash_required" });
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var anchor = request.TimeAnchor ?? TimeAnchor.Unknown;
|
||||
var budget = request.StalenessBudget ?? StalenessBudget.Default;
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, cancellationToken);
|
||||
var status = new AirGapStatus(state, stalenessCalculator.Evaluate(anchor, budget, now), now);
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleUnseal(
|
||||
ClaimsPrincipal user,
|
||||
AirGapStateService service,
|
||||
TimeProvider timeProvider,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = ResolveTenant(httpContext);
|
||||
var state = await service.UnsealAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
|
||||
var status = new AirGapStatus(state, StalenessEvaluation.Unknown, timeProvider.GetUtcNow());
|
||||
return Results.Ok(AirGapStatusResponse.FromStatus(status));
|
||||
}
|
||||
|
||||
private static string ResolveTenant(HttpContext httpContext)
|
||||
{
|
||||
if (httpContext.Request.Headers.TryGetValue("x-tenant-id", out var tenantHeader) && !string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return tenantHeader.ToString();
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
internal static class AuthorizationExtensions
|
||||
{
|
||||
public static RouteHandlerBuilder RequireScope(this RouteHandlerBuilder builder, string requiredScope)
|
||||
{
|
||||
return builder.RequireAuthorization(policy =>
|
||||
{
|
||||
policy.RequireAssertion(ctx =>
|
||||
{
|
||||
var scopes = ctx.User.FindFirstValue("scope") ?? ctx.User.FindFirstValue("scp") ?? string.Empty;
|
||||
return scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Controller.Services;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
|
||||
public sealed record AirGapStatusResponse(
|
||||
string TenantId,
|
||||
bool Sealed,
|
||||
string? PolicyHash,
|
||||
TimeAnchor TimeAnchor,
|
||||
StalenessEvaluation Staleness,
|
||||
DateTimeOffset LastTransitionAt,
|
||||
DateTimeOffset EvaluatedAt)
|
||||
{
|
||||
public static AirGapStatusResponse FromStatus(AirGapStatus status) =>
|
||||
new(
|
||||
status.State.TenantId,
|
||||
status.State.Sealed,
|
||||
status.State.PolicyHash,
|
||||
status.State.TimeAnchor,
|
||||
status.Staleness,
|
||||
status.State.LastTransitionAt,
|
||||
status.EvaluatedAt);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Endpoints.Contracts;
|
||||
|
||||
public sealed class SealRequest
|
||||
{
|
||||
[Required]
|
||||
public string? PolicyHash { get; set; }
|
||||
|
||||
public TimeAnchor? TimeAnchor { get; set; }
|
||||
|
||||
public StalenessBudget? StalenessBudget { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.AirGap.Controller.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Mongo configuration for the air-gap controller state store.
|
||||
/// </summary>
|
||||
public sealed class AirGapControllerMongoOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Mongo connection string; when missing, the controller falls back to the in-memory store.
|
||||
/// </summary>
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Database name. Default: "stellaops_airgap".
|
||||
/// </summary>
|
||||
public string Database { get; set; } = "stellaops_airgap";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name for state documents. Default: "airgap_state".
|
||||
/// </summary>
|
||||
public string Collection { get; set; } = "airgap_state";
|
||||
}
|
||||
25
src/AirGap/StellaOps.AirGap.Controller/Program.cs
Normal file
25
src/AirGap/StellaOps.AirGap.Controller/Program.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using StellaOps.AirGap.Controller.Auth;
|
||||
using StellaOps.AirGap.Controller.DependencyInjection;
|
||||
using StellaOps.AirGap.Controller.Endpoints;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddAuthentication(HeaderScopeAuthenticationHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, HeaderScopeAuthenticationHandler>(HeaderScopeAuthenticationHandler.SchemeName, _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
|
||||
|
||||
builder.Services.AddAirGapController(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapAirGapEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Services;
|
||||
|
||||
public sealed class AirGapStateService
|
||||
{
|
||||
private readonly IAirGapStateStore _store;
|
||||
private readonly StalenessCalculator _stalenessCalculator;
|
||||
|
||||
public AirGapStateService(IAirGapStateStore store, StalenessCalculator stalenessCalculator)
|
||||
{
|
||||
_store = store;
|
||||
_stalenessCalculator = stalenessCalculator;
|
||||
}
|
||||
|
||||
public async Task<AirGapState> SealAsync(
|
||||
string tenantId,
|
||||
string policyHash,
|
||||
TimeAnchor timeAnchor,
|
||||
StalenessBudget budget,
|
||||
DateTimeOffset nowUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyHash);
|
||||
budget.Validate();
|
||||
|
||||
var newState = new AirGapState
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Sealed = true,
|
||||
PolicyHash = policyHash,
|
||||
TimeAnchor = timeAnchor,
|
||||
StalenessBudget = budget,
|
||||
LastTransitionAt = nowUtc
|
||||
};
|
||||
|
||||
await _store.SetAsync(newState, cancellationToken);
|
||||
return newState;
|
||||
}
|
||||
|
||||
public async Task<AirGapState> UnsealAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset nowUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var current = await _store.GetAsync(tenantId, cancellationToken);
|
||||
var newState = current with
|
||||
{
|
||||
Sealed = false,
|
||||
LastTransitionAt = nowUtc
|
||||
};
|
||||
await _store.SetAsync(newState, cancellationToken);
|
||||
return newState;
|
||||
}
|
||||
|
||||
public async Task<AirGapStatus> GetStatusAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset nowUtc,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var state = await _store.GetAsync(tenantId, cancellationToken);
|
||||
var staleness = _stalenessCalculator.Evaluate(state.TimeAnchor, state.StalenessBudget, nowUtc);
|
||||
return new AirGapStatus(state, staleness, nowUtc);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AirGapStatus(AirGapState State, StalenessEvaluation Staleness, DateTimeOffset EvaluatedAt);
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>StellaOps.AirGap.Controller</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Stores;
|
||||
|
||||
public interface IAirGapStateStore
|
||||
{
|
||||
Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default);
|
||||
Task SetAsync(AirGapState state, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Stores;
|
||||
|
||||
public sealed class InMemoryAirGapStateStore : IAirGapStateStore
|
||||
{
|
||||
private readonly Dictionary<string, AirGapState> _states = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (_states.TryGetValue(tenantId, out var state))
|
||||
{
|
||||
return Task.FromResult(state);
|
||||
}
|
||||
|
||||
return Task.FromResult(new AirGapState { TenantId = tenantId });
|
||||
}
|
||||
|
||||
public Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_states[state.TenantId] = state;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// Mongo-backed air-gap state store; single document per tenant.
|
||||
/// </summary>
|
||||
internal sealed class MongoAirGapStateStore : IAirGapStateStore
|
||||
{
|
||||
private readonly IMongoCollection<AirGapStateDocument> _collection;
|
||||
|
||||
public MongoAirGapStateStore(IMongoCollection<AirGapStateDocument> collection)
|
||||
{
|
||||
_collection = collection;
|
||||
}
|
||||
|
||||
public async Task<AirGapState> GetAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<AirGapStateDocument>.Filter.And(
|
||||
Builders<AirGapStateDocument>.Filter.Eq(x => x.TenantId, tenantId),
|
||||
Builders<AirGapStateDocument>.Filter.Eq(x => x.Id, AirGapState.SingletonId));
|
||||
|
||||
var doc = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return doc?.ToDomain() ?? new AirGapState { TenantId = tenantId };
|
||||
}
|
||||
|
||||
public async Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = AirGapStateDocument.FromDomain(state);
|
||||
var filter = Builders<AirGapStateDocument>.Filter.And(
|
||||
Builders<AirGapStateDocument>.Filter.Eq(x => x.TenantId, state.TenantId),
|
||||
Builders<AirGapStateDocument>.Filter.Eq(x => x.Id, AirGapState.SingletonId));
|
||||
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
await _collection.ReplaceOneAsync(filter, doc, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static IMongoCollection<AirGapStateDocument> EnsureCollection(IMongoDatabase database)
|
||||
{
|
||||
var collectionName = "airgap_state";
|
||||
var exists = database.ListCollectionNames().ToList().Contains(collectionName);
|
||||
if (!exists)
|
||||
{
|
||||
database.CreateCollection(collectionName);
|
||||
}
|
||||
|
||||
var collection = database.GetCollection<AirGapStateDocument>(collectionName);
|
||||
|
||||
var keys = Builders<AirGapStateDocument>.IndexKeys
|
||||
.Ascending(x => x.TenantId)
|
||||
.Ascending(x => x.Id);
|
||||
var model = new CreateIndexModel<AirGapStateDocument>(keys, new CreateIndexOptions { Unique = true });
|
||||
collection.Indexes.CreateOne(model);
|
||||
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirGapStateDocument
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; init; } = AirGapState.SingletonId;
|
||||
|
||||
[BsonElement("tenant_id")]
|
||||
public string TenantId { get; init; } = "default";
|
||||
|
||||
[BsonElement("sealed")]
|
||||
public bool Sealed { get; init; }
|
||||
= false;
|
||||
|
||||
[BsonElement("policy_hash")]
|
||||
public string? PolicyHash { get; init; }
|
||||
= null;
|
||||
|
||||
[BsonElement("time_anchor")]
|
||||
public AirGapTimeAnchorDocument TimeAnchor { get; init; } = new();
|
||||
|
||||
[BsonElement("staleness_budget")]
|
||||
public StalenessBudgetDocument StalenessBudget { get; init; } = new();
|
||||
|
||||
[BsonElement("last_transition_at")]
|
||||
public DateTimeOffset LastTransitionAt { get; init; }
|
||||
= DateTimeOffset.MinValue;
|
||||
|
||||
public AirGapState ToDomain() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Sealed = Sealed,
|
||||
PolicyHash = PolicyHash,
|
||||
TimeAnchor = TimeAnchor.ToDomain(),
|
||||
StalenessBudget = StalenessBudget.ToDomain(),
|
||||
LastTransitionAt = LastTransitionAt
|
||||
};
|
||||
|
||||
public static AirGapStateDocument FromDomain(AirGapState state) => new()
|
||||
{
|
||||
TenantId = state.TenantId,
|
||||
Sealed = state.Sealed,
|
||||
PolicyHash = state.PolicyHash,
|
||||
TimeAnchor = AirGapTimeAnchorDocument.FromDomain(state.TimeAnchor),
|
||||
StalenessBudget = StalenessBudgetDocument.FromDomain(state.StalenessBudget),
|
||||
LastTransitionAt = state.LastTransitionAt
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class AirGapTimeAnchorDocument
|
||||
{
|
||||
[BsonElement("anchor_time")]
|
||||
public DateTimeOffset AnchorTime { get; init; }
|
||||
= DateTimeOffset.MinValue;
|
||||
|
||||
[BsonElement("source")]
|
||||
public string Source { get; init; } = "unknown";
|
||||
|
||||
[BsonElement("format")]
|
||||
public string Format { get; init; } = "unknown";
|
||||
|
||||
[BsonElement("signature_fp")]
|
||||
public string SignatureFingerprint { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("token_digest")]
|
||||
public string TokenDigest { get; init; } = string.Empty;
|
||||
|
||||
public StellaOps.AirGap.Time.Models.TimeAnchor ToDomain() =>
|
||||
new(AnchorTime, Source, Format, SignatureFingerprint, TokenDigest);
|
||||
|
||||
public static AirGapTimeAnchorDocument FromDomain(StellaOps.AirGap.Time.Models.TimeAnchor anchor) => new()
|
||||
{
|
||||
AnchorTime = anchor.AnchorTime,
|
||||
Source = anchor.Source,
|
||||
Format = anchor.Format,
|
||||
SignatureFingerprint = anchor.SignatureFingerprint,
|
||||
TokenDigest = anchor.TokenDigest
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed class StalenessBudgetDocument
|
||||
{
|
||||
[BsonElement("warning_seconds")]
|
||||
public long WarningSeconds { get; init; } = StalenessBudget.Default.WarningSeconds;
|
||||
|
||||
[BsonElement("breach_seconds")]
|
||||
public long BreachSeconds { get; init; } = StalenessBudget.Default.BreachSeconds;
|
||||
|
||||
public StalenessBudget ToDomain() => new(WarningSeconds, BreachSeconds);
|
||||
|
||||
public static StalenessBudgetDocument FromDomain(StalenessBudget budget) => new()
|
||||
{
|
||||
WarningSeconds = budget.WarningSeconds,
|
||||
BreachSeconds = budget.BreachSeconds
|
||||
};
|
||||
}
|
||||
@@ -6,11 +6,11 @@
|
||||
| PREP-AIRGAP-IMP-56-002-BLOCKED-ON-56-001 | DONE | Unblocked by importer scaffold/trust-root contract. | 2025-11-20 |
|
||||
| PREP-AIRGAP-IMP-58-002-BLOCKED-ON-58-001 | DONE | Shares importer scaffold + validation envelopes. | 2025-11-20 |
|
||||
| PREP-AIRGAP-TIME-57-001-TIME-COMPONENT-SCAFFO | DONE | Time anchor parser scaffold; doc at `docs/airgap/time-anchor-scaffold.md`. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-56-001-CONTROLLER-PROJECT-SCA | DOING | Controller scaffold draft at `docs/airgap/controller-scaffold.md`; awaiting Authority scopes decision. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-56-002-BLOCKED-ON-56-001-SCAF | DOING | Uses same scaffold doc; pending DevOps alignment on deployment skeleton. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-56-001-CONTROLLER-PROJECT-SCA | DONE | Controller scaffold drafted; controller project created with seal/unseal/state endpoints per doc. | 2025-11-26 |
|
||||
| PREP-AIRGAP-CTL-56-002-BLOCKED-ON-56-001-SCAF | DONE | Scaffold applied to status/seal endpoints; deployment skeleton present. | 2025-11-26 |
|
||||
| PREP-AIRGAP-CTL-57-001-BLOCKED-ON-56-002 | DONE | Diagnostics doc at `docs/airgap/sealed-startup-diagnostics.md`. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-57-002-BLOCKED-ON-57-001 | DONE | Telemetry/timeline hooks defined in `docs/airgap/sealed-startup-diagnostics.md`. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-58-001-BLOCKED-ON-57-002 | DOING | Staleness/time-anchor fields specified; awaiting Time Guild token decision. | 2025-11-20 |
|
||||
| PREP-AIRGAP-CTL-58-001-BLOCKED-ON-57-002 | DONE | Staleness/time-anchor fields wired in controller response; pending Time Guild token refinements. | 2025-11-26 |
|
||||
| AIRGAP-IMP-56-001 | DONE | DSSE verifier, TUF validator, Merkle root calculator + import coordinator; tests passing. | 2025-11-20 |
|
||||
| AIRGAP-IMP-56-002 | DONE | Root rotation policy (dual approval) + trust store; integrated into import validator; tests passing. | 2025-11-20 |
|
||||
| AIRGAP-IMP-57-001 | DONE | In-memory RLS bundle catalog/items repos + schema doc; deterministic ordering and tests passing. | 2025-11-20 |
|
||||
|
||||
@@ -4,10 +4,24 @@ responses:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/common.yaml#/schemas/ErrorEnvelope'
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
HealthResponse:
|
||||
description: Health envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/common.yaml#/schemas/HealthEnvelope'
|
||||
type: object
|
||||
required: [status, service]
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
service:
|
||||
type: string
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Authority Authentication API
|
||||
summary: Token issuance, introspection, revocation, and key discovery endpoints exposed by the Authority service.
|
||||
description: |
|
||||
The Authority service issues OAuth 2.1 access tokens for StellaOps components, enforcing tenant and scope
|
||||
restrictions configured per client. This specification describes the authentication surface only; domain APIs
|
||||
are documented by their owning services.
|
||||
version: 0.1.0
|
||||
info:
|
||||
title: StellaOps Authority Authentication API
|
||||
summary: Token issuance, introspection, revocation, and key discovery endpoints exposed by the Authority service.
|
||||
description: |
|
||||
The Authority service issues OAuth 2.1 access tokens for StellaOps components, enforcing tenant and scope
|
||||
restrictions configured per client. This specification describes the authentication surface only; domain APIs
|
||||
are documented by their owning services.
|
||||
version: 0.1.1
|
||||
contact:
|
||||
name: StellaOps API Guild
|
||||
email: api@stella-ops.local
|
||||
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
|
||||
servers:
|
||||
- url: https://authority.stellaops.local
|
||||
description: Example Authority deployment
|
||||
tags:
|
||||
- name: Authentication
|
||||
description: OAuth 2.1 token exchange, introspection, and revocation flows.
|
||||
- name: Keys
|
||||
description: JSON Web Key Set discovery.
|
||||
tags:
|
||||
- name: Authentication
|
||||
description: OAuth 2.1 token exchange, introspection, and revocation flows.
|
||||
- name: Keys
|
||||
description: JSON Web Key Set discovery.
|
||||
- name: Meta
|
||||
description: Service metadata
|
||||
components:
|
||||
securitySchemes:
|
||||
ClientSecretBasic:
|
||||
@@ -442,11 +447,11 @@ components:
|
||||
$ref: '#/components/schemas/Jwk'
|
||||
required:
|
||||
- keys
|
||||
Jwk:
|
||||
type: object
|
||||
description: Public key material for token signature validation.
|
||||
properties:
|
||||
kid:
|
||||
Jwk:
|
||||
type: object
|
||||
description: Public key material for token signature validation.
|
||||
properties:
|
||||
kid:
|
||||
type: string
|
||||
description: Key identifier.
|
||||
kty:
|
||||
@@ -467,19 +472,44 @@ components:
|
||||
y:
|
||||
type: string
|
||||
description: Y coordinate for EC keys.
|
||||
status:
|
||||
type: string
|
||||
description: Operational status metadata for the key (e.g., `active`, `retiring`).
|
||||
status:
|
||||
type: string
|
||||
description: Operational status metadata for the key (e.g., `active`, `retiring`).
|
||||
AuthorizationCodeGrantRequest:
|
||||
type: object
|
||||
description: Form-encoded payload for authorization code exchange.
|
||||
required:
|
||||
- grant_type
|
||||
- code
|
||||
- redirect_uri
|
||||
- code_verifier
|
||||
properties:
|
||||
grant_type:
|
||||
type: string
|
||||
const: authorization_code
|
||||
client_id:
|
||||
type: string
|
||||
client_secret:
|
||||
type: string
|
||||
description: Optional when HTTP Basic auth is used.
|
||||
code:
|
||||
type: string
|
||||
redirect_uri:
|
||||
type: string
|
||||
format: uri
|
||||
code_verifier:
|
||||
type: string
|
||||
paths:
|
||||
/token:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Exchange credentials for tokens
|
||||
description: |
|
||||
Issues OAuth 2.1 bearer tokens for StellaOps clients. Supports password, client credentials,
|
||||
authorization-code, device, and refresh token grants. Confidential clients must authenticate using
|
||||
HTTP Basic auth or `client_secret` form fields.
|
||||
/token:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Exchange credentials for tokens
|
||||
description: |
|
||||
Issues OAuth 2.1 bearer tokens for StellaOps clients. Supports password, client credentials,
|
||||
authorization-code, device, and refresh token grants. Confidential clients must authenticate using
|
||||
HTTP Basic auth or `client_secret` form fields.
|
||||
operationId: authorityTokenExchange
|
||||
security:
|
||||
- ClientSecretBasic: []
|
||||
- {}
|
||||
@@ -487,11 +517,12 @@ paths:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/PasswordGrantRequest'
|
||||
- $ref: '#/components/schemas/ClientCredentialsGrantRequest'
|
||||
- $ref: '#/components/schemas/RefreshTokenGrantRequest'
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/PasswordGrantRequest'
|
||||
- $ref: '#/components/schemas/ClientCredentialsGrantRequest'
|
||||
- $ref: '#/components/schemas/RefreshTokenGrantRequest'
|
||||
- $ref: '#/components/schemas/AuthorizationCodeGrantRequest'
|
||||
encoding:
|
||||
authority_provider:
|
||||
style: form
|
||||
@@ -591,13 +622,15 @@ paths:
|
||||
value:
|
||||
error: invalid_client
|
||||
error_description: Client authentication failed.
|
||||
/revoke:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Revoke an access or refresh token
|
||||
security:
|
||||
- ClientSecretBasic: []
|
||||
/revoke:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Revoke an access or refresh token
|
||||
description: Revokes an access or refresh token; idempotent.
|
||||
operationId: authorityRevokeToken
|
||||
security:
|
||||
- ClientSecretBasic: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -637,12 +670,13 @@ paths:
|
||||
value:
|
||||
error: invalid_client
|
||||
error_description: Client authentication failed.
|
||||
/introspect:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Introspect token state
|
||||
description: Returns the active status and claims for a given token. Requires a privileged client.
|
||||
/introspect:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Introspect token state
|
||||
description: Returns the active status and claims for a given token. Requires a privileged client.
|
||||
operationId: authorityIntrospectToken
|
||||
security:
|
||||
- ClientSecretBasic: []
|
||||
requestBody:
|
||||
@@ -712,12 +746,13 @@ paths:
|
||||
value:
|
||||
error: invalid_client
|
||||
error_description: Client authentication failed.
|
||||
/jwks:
|
||||
get:
|
||||
tags:
|
||||
- Keys
|
||||
summary: Retrieve signing keys
|
||||
description: Returns the JSON Web Key Set used to validate Authority-issued tokens.
|
||||
/jwks:
|
||||
get:
|
||||
tags:
|
||||
- Keys
|
||||
summary: Retrieve signing keys
|
||||
description: Returns the JSON Web Key Set used to validate Authority-issued tokens.
|
||||
operationId: authorityGetJwks
|
||||
responses:
|
||||
'200':
|
||||
description: JWKS document.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,8 +38,24 @@ function mergeSpecs(services) {
|
||||
title: 'StellaOps Aggregate API',
|
||||
version: '0.0.1',
|
||||
description: 'Composed OpenAPI from per-service specs. This file is generated by compose.mjs.',
|
||||
contact: {
|
||||
name: 'StellaOps API Guild',
|
||||
email: 'api@stella-ops.local',
|
||||
},
|
||||
},
|
||||
servers: [],
|
||||
tags: [
|
||||
{ name: 'Authentication', description: 'OAuth 2.1 token exchange, introspection, and revocation flows.' },
|
||||
{ name: 'Keys', description: 'JSON Web Key Set discovery.' },
|
||||
{ name: 'Health', description: 'Liveness endpoints' },
|
||||
{ name: 'Meta', description: 'Readiness/metadata endpoints' },
|
||||
{ name: 'Bundles', description: 'Export bundle access' },
|
||||
{ name: 'Graphs', description: 'Graph build status and traversal APIs' },
|
||||
{ name: 'Jobs', description: 'Job submission and status APIs' },
|
||||
{ name: 'Evaluation', description: 'Policy evaluation APIs' },
|
||||
{ name: 'Policies', description: 'Policy management APIs' },
|
||||
{ name: 'Queues', description: 'Queue metrics APIs' },
|
||||
],
|
||||
paths: {},
|
||||
components: { schemas: {}, parameters: {}, securitySchemes: {}, responses: {} },
|
||||
};
|
||||
@@ -58,6 +74,15 @@ function mergeSpecs(services) {
|
||||
}
|
||||
}
|
||||
|
||||
// tags
|
||||
if (Array.isArray(doc.tags)) {
|
||||
for (const tag of doc.tags) {
|
||||
if (!aggregate.tags.some((t) => t.name === tag.name)) {
|
||||
aggregate.tags.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paths
|
||||
for (const [p, pathItem] of Object.entries(doc.paths || {})) {
|
||||
const namespacedPath = normalizePath(`/${name}${p}`);
|
||||
@@ -83,6 +108,14 @@ function mergeSpecs(services) {
|
||||
}
|
||||
aggregate.components.schemas[key] = rewriteRefs(schemaDef, name);
|
||||
}
|
||||
|
||||
// security schemes (non-namespaced)
|
||||
const securitySchemes = doc.components?.securitySchemes || {};
|
||||
for (const [schemeName, schemeDef] of Object.entries(securitySchemes)) {
|
||||
if (!aggregate.components.securitySchemes[schemeName]) {
|
||||
aggregate.components.securitySchemes[schemeName] = schemeDef;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// de-duplicate servers
|
||||
@@ -127,7 +160,7 @@ function sortPathItem(pathItem) {
|
||||
}
|
||||
|
||||
function writeAggregate(doc) {
|
||||
const str = yaml.stringify(doc, { sortMapEntries: true });
|
||||
const str = yaml.stringify(doc, { sortMapEntries: false });
|
||||
fs.writeFileSync(OUTPUT, str, 'utf8');
|
||||
console.log(`[stella-compose] wrote aggregate spec to ${OUTPUT}`);
|
||||
}
|
||||
@@ -161,6 +194,16 @@ function normalizeRef(refValue, serviceName) {
|
||||
return `#/components/schemas/${name}`;
|
||||
}
|
||||
|
||||
if (refValue.startsWith('../_shared/responses/')) {
|
||||
const name = refValue.split('#/responses/')[1];
|
||||
return `#/components/responses/${name}`;
|
||||
}
|
||||
|
||||
if (refValue.startsWith('../_shared/parameters/')) {
|
||||
const name = refValue.split('#/parameters/')[1];
|
||||
return `#/components/parameters/${name}`;
|
||||
}
|
||||
|
||||
const prefix = '#/components/schemas/';
|
||||
if (refValue.startsWith(prefix)) {
|
||||
const name = refValue.slice(prefix.length);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Export Center API (stub)
|
||||
version: 0.0.1
|
||||
description: Health and metadata scaffold for Export Center; replace with real contracts
|
||||
as authored.
|
||||
version: 0.0.2
|
||||
description: Health and metadata scaffold for Export Center; bundle list/manifest examples added.
|
||||
contact:
|
||||
name: StellaOps API Guild
|
||||
email: api@stella-ops.local
|
||||
tags:
|
||||
- name: Health
|
||||
description: Liveness endpoints
|
||||
- name: Meta
|
||||
description: Readiness/metadata endpoints
|
||||
- name: Bundles
|
||||
description: Export bundle access
|
||||
servers:
|
||||
- url: https://export.stellaops.local
|
||||
description: Example Export Center endpoint
|
||||
@@ -13,6 +22,8 @@ paths:
|
||||
tags:
|
||||
- Health
|
||||
summary: Liveness probe
|
||||
description: Returns OK when Export Center is reachable.
|
||||
operationId: exportHealth
|
||||
responses:
|
||||
'200':
|
||||
description: Service is up
|
||||
@@ -40,6 +51,8 @@ paths:
|
||||
summary: Service health
|
||||
tags:
|
||||
- Meta
|
||||
description: Readiness probe for Export Center dependencies.
|
||||
operationId: exportHealthz
|
||||
responses:
|
||||
'200':
|
||||
description: Service healthy
|
||||
@@ -58,19 +71,21 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
$ref: '../_shared/schemas/common.yaml#/schemas/ErrorEnvelope'
|
||||
examples:
|
||||
unavailable:
|
||||
summary: Unhealthy response
|
||||
value:
|
||||
code: service_unavailable
|
||||
message: mirror bundle backlog exceeds SLA
|
||||
traceId: 3
|
||||
traceId: "3"
|
||||
/bundles/{bundleId}:
|
||||
get:
|
||||
tags:
|
||||
- Bundles
|
||||
summary: Download export bundle by id
|
||||
operationId: exportGetBundle
|
||||
description: Streams an export bundle archive.
|
||||
parameters:
|
||||
- name: bundleId
|
||||
in: path
|
||||
@@ -78,6 +93,9 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
example: bundle-2025-11-18-001
|
||||
security:
|
||||
- OAuthClientCredentials: []
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Bundle stream
|
||||
@@ -87,12 +105,15 @@ paths:
|
||||
download:
|
||||
summary: Zip payload
|
||||
value: binary data
|
||||
checksumMismatch:
|
||||
summary: Expected sha256 mismatch example
|
||||
value: binary data
|
||||
'404':
|
||||
description: Bundle not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
$ref: '../_shared/schemas/common.yaml#/schemas/ErrorEnvelope'
|
||||
examples:
|
||||
notFound:
|
||||
summary: Bundle missing
|
||||
@@ -105,10 +126,15 @@ paths:
|
||||
tags:
|
||||
- Bundles
|
||||
summary: List export bundles
|
||||
operationId: exportListBundles
|
||||
description: Returns paginated export bundles for the tenant.
|
||||
parameters:
|
||||
- $ref: '../_shared/parameters/tenant.yaml#/parameters/TenantParam'
|
||||
- $ref: '../_shared/parameters/paging.yaml#/parameters/LimitParam'
|
||||
- $ref: '../_shared/parameters/paging.yaml#/parameters/CursorParam'
|
||||
security:
|
||||
- OAuthClientCredentials: []
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Bundle page
|
||||
@@ -132,10 +158,12 @@ paths:
|
||||
createdAt: '2025-11-18T12:00:00Z'
|
||||
status: ready
|
||||
sizeBytes: 1048576
|
||||
sha256: sha256:abc123
|
||||
- bundleId: bundle-2025-11-18-000
|
||||
createdAt: '2025-11-18T10:00:00Z'
|
||||
status: ready
|
||||
sizeBytes: 2048
|
||||
sha256: sha256:def456
|
||||
metadata:
|
||||
hasMore: true
|
||||
nextCursor: eyJyIjoiMjAyNS0xMS0xOC0wMDIifQ
|
||||
@@ -157,12 +185,17 @@ paths:
|
||||
tags:
|
||||
- Bundles
|
||||
summary: Fetch bundle manifest metadata
|
||||
description: Returns manifest metadata for a bundle id.
|
||||
operationId: exportGetBundleManifest
|
||||
parameters:
|
||||
- name: bundleId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- OAuthClientCredentials: []
|
||||
- BearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Manifest metadata
|
||||
@@ -179,6 +212,8 @@ paths:
|
||||
digest: sha256:abc123
|
||||
- type: vex
|
||||
digest: sha256:def456
|
||||
sizeBytes: 1048576
|
||||
sha256: sha256:fedcba
|
||||
createdAt: '2025-11-18T12:00:00Z'
|
||||
'404':
|
||||
description: Bundle not found
|
||||
@@ -187,6 +222,18 @@ paths:
|
||||
schema:
|
||||
$ref: '../_shared/schemas/common.yaml#/schemas/ErrorEnvelope'
|
||||
components:
|
||||
securitySchemes:
|
||||
OAuthClientCredentials:
|
||||
type: oauth2
|
||||
description: OAuth 2.1 client credentials flow scoped per service.
|
||||
flows:
|
||||
clientCredentials:
|
||||
tokenUrl: /token
|
||||
scopes: {}
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
schemas:
|
||||
BundleSummary:
|
||||
type: object
|
||||
@@ -233,5 +280,3 @@ components:
|
||||
format: date-time
|
||||
HealthResponse:
|
||||
$ref: ../_shared/schemas/common.yaml#/schemas/HealthEnvelope
|
||||
Error:
|
||||
$ref: ../_shared/schemas/common.yaml#/schemas/ErrorEnvelope
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Graph API (stub)
|
||||
version: 0.0.1
|
||||
description: Health and dataset status scaffold for Graph service; replace with
|
||||
full contract as authored.
|
||||
version: 0.0.2
|
||||
description: Health and dataset status scaffold for Graph service; added status/nodes examples with tenant context.
|
||||
contact:
|
||||
name: StellaOps API Guild
|
||||
email: api@stella-ops.local
|
||||
tags:
|
||||
- name: Meta
|
||||
description: Service health
|
||||
- name: Graphs
|
||||
description: Graph build status and traversal APIs
|
||||
servers:
|
||||
- url: https://graph.stellaops.local
|
||||
description: Example Graph endpoint
|
||||
@@ -13,6 +20,8 @@ paths:
|
||||
summary: Service health
|
||||
tags:
|
||||
- Meta
|
||||
description: Readiness probe for Graph API.
|
||||
operationId: graphHealthz
|
||||
responses:
|
||||
'200':
|
||||
description: Service healthy
|
||||
@@ -38,12 +47,14 @@ paths:
|
||||
value:
|
||||
code: service_unavailable
|
||||
message: indexer lag exceeds threshold
|
||||
traceId: 5
|
||||
traceId: "5"
|
||||
/graphs/{graphId}/status:
|
||||
get:
|
||||
summary: Get graph build status
|
||||
tags:
|
||||
- Graphs
|
||||
operationId: graphGetStatus
|
||||
description: Returns build status for a graph id.
|
||||
parameters:
|
||||
- name: graphId
|
||||
in: path
|
||||
@@ -63,7 +74,14 @@ paths:
|
||||
value:
|
||||
graphId: graph-01JF0XYZ
|
||||
status: ready
|
||||
builtAt: 2025-11-18 12:00:00+00:00
|
||||
builtAt: 2025-11-18T12:00:00Z
|
||||
tenant: tenant-alpha
|
||||
building:
|
||||
value:
|
||||
graphId: graph-01JF0BUILD
|
||||
status: building
|
||||
builtAt: 2025-11-18T12:05:00Z
|
||||
tenant: tenant-alpha
|
||||
'404':
|
||||
description: Graph not found
|
||||
content:
|
||||
@@ -75,6 +93,8 @@ paths:
|
||||
summary: List graph nodes
|
||||
tags:
|
||||
- Graphs
|
||||
operationId: graphListNodes
|
||||
description: Lists nodes for a graph with paging.
|
||||
parameters:
|
||||
- name: graphId
|
||||
in: path
|
||||
@@ -97,12 +117,25 @@ paths:
|
||||
- id: node-1
|
||||
kind: artifact
|
||||
label: registry.stella-ops.local/runtime/api
|
||||
tenant: tenant-alpha
|
||||
- id: node-2
|
||||
kind: policy
|
||||
label: policy:baseline
|
||||
tenant: tenant-alpha
|
||||
metadata:
|
||||
hasMore: true
|
||||
nextCursor: eyJuIjoiMjAyNS0xMS0xOCJ9
|
||||
filtered:
|
||||
summary: Policy nodes only
|
||||
value:
|
||||
nodes:
|
||||
- id: node-99
|
||||
kind: policy
|
||||
label: policy:runtime-allowlist
|
||||
tenant: tenant-beta
|
||||
metadata:
|
||||
hasMore: false
|
||||
nextCursor: ""
|
||||
'404':
|
||||
description: Graph not found
|
||||
content:
|
||||
@@ -111,6 +144,24 @@ paths:
|
||||
$ref: ../_shared/schemas/common.yaml#/schemas/ErrorEnvelope
|
||||
components:
|
||||
schemas:
|
||||
HealthEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
service:
|
||||
type: string
|
||||
required: [status, service]
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
required: [code, message]
|
||||
GraphStatus:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Orchestrator API (stub)
|
||||
version: 0.0.1
|
||||
version: 0.0.2
|
||||
description: Health and job orchestration scaffold for Orchestrator service; replace
|
||||
with real contracts as contracts are authored.
|
||||
contact:
|
||||
name: StellaOps API Guild
|
||||
email: api@stella-ops.local
|
||||
tags:
|
||||
- name: Health
|
||||
description: Liveness endpoints
|
||||
- name: Meta
|
||||
description: Readiness/metadata endpoints
|
||||
- name: Jobs
|
||||
description: Job submission and status APIs
|
||||
servers:
|
||||
- url: https://orchestrator.stellaops.local
|
||||
description: Example Orchestrator endpoint
|
||||
@@ -13,6 +23,8 @@ paths:
|
||||
tags:
|
||||
- Health
|
||||
summary: Liveness probe
|
||||
description: Returns OK when Orchestrator is reachable.
|
||||
operationId: orchestratorHealth
|
||||
responses:
|
||||
'200':
|
||||
description: Service is up
|
||||
@@ -40,6 +52,8 @@ paths:
|
||||
summary: Service health
|
||||
tags:
|
||||
- Meta
|
||||
description: Readiness probe for orchestrator dependencies.
|
||||
operationId: orchestratorHealthz
|
||||
responses:
|
||||
'200':
|
||||
description: Service healthy
|
||||
@@ -65,12 +79,22 @@ paths:
|
||||
value:
|
||||
code: service_unavailable
|
||||
message: outbound queue lag exceeds threshold
|
||||
traceId: 1
|
||||
traceId: "1"
|
||||
/jobs:
|
||||
post:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: Submit a job to the orchestrator queue
|
||||
operationId: orchestratorSubmitJob
|
||||
description: Enqueue a job for asynchronous execution.
|
||||
parameters:
|
||||
- in: header
|
||||
name: Idempotency-Key
|
||||
description: Optional idempotency key to safely retry job submissions.
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 128
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -122,6 +146,8 @@ paths:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: List jobs
|
||||
operationId: orchestratorListJobs
|
||||
description: Returns jobs for the tenant with optional status filter.
|
||||
parameters:
|
||||
- in: query
|
||||
name: status
|
||||
@@ -132,6 +158,7 @@ paths:
|
||||
- running
|
||||
- failed
|
||||
- completed
|
||||
description: Optional status filter
|
||||
- $ref: ../_shared/parameters/paging.yaml#/parameters/LimitParam
|
||||
- $ref: ../_shared/parameters/tenant.yaml#/parameters/TenantParam
|
||||
responses:
|
||||
@@ -144,22 +171,53 @@ paths:
|
||||
items:
|
||||
$ref: '#/components/schemas/JobSummary'
|
||||
examples:
|
||||
sample:
|
||||
default:
|
||||
summary: Mixed queues
|
||||
value:
|
||||
- jobId: job_01JF04ABCD
|
||||
status: queued
|
||||
queue: scan
|
||||
tenant: tenant-alpha
|
||||
enqueuedAt: '2025-11-18T12:00:00Z'
|
||||
- jobId: job_01JF04EFGH
|
||||
status: running
|
||||
queue: policy-eval
|
||||
tenant: tenant-alpha
|
||||
enqueuedAt: '2025-11-18T11:55:00Z'
|
||||
startedAt: '2025-11-18T11:56:10Z'
|
||||
queuedOnly:
|
||||
summary: Filtered by status=queued with page limit
|
||||
value:
|
||||
- jobId: job_01JF0500QUE
|
||||
status: queued
|
||||
queue: export
|
||||
tenant: tenant-beta
|
||||
enqueuedAt: '2025-11-18T12:05:00Z'
|
||||
- jobId: job_01JF0501QUE
|
||||
status: queued
|
||||
queue: scan
|
||||
tenant: tenant-beta
|
||||
enqueuedAt: '2025-11-18T12:04:10Z'
|
||||
'400':
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: ../_shared/schemas/common.yaml#/schemas/ErrorEnvelope
|
||||
examples:
|
||||
invalidStatus:
|
||||
summary: Bad status filter
|
||||
value:
|
||||
code: orch.invalid_request
|
||||
message: status must be one of queued,running,failed,completed.
|
||||
traceId: 01JF04ERR1
|
||||
/jobs/{jobId}:
|
||||
get:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: Get job status
|
||||
operationId: orchestratorGetJob
|
||||
description: Fetch the current status of a job by id.
|
||||
parameters:
|
||||
- name: jobId
|
||||
in: path
|
||||
@@ -187,7 +245,29 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
components:
|
||||
securitySchemes:
|
||||
OAuthClientCredentials:
|
||||
type: oauth2
|
||||
description: OAuth 2.1 client credentials flow scoped per service.
|
||||
flows:
|
||||
clientCredentials:
|
||||
tokenUrl: /token
|
||||
scopes: {}
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
schemas:
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
required: [code, message]
|
||||
JobSummary:
|
||||
type: object
|
||||
required:
|
||||
@@ -218,3 +298,35 @@ components:
|
||||
format: date-time
|
||||
tenant:
|
||||
type: string
|
||||
JobCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- payload
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
description: Job kind identifier.
|
||||
payload:
|
||||
type: object
|
||||
description: Job payload (kind-specific fields).
|
||||
priority:
|
||||
type: string
|
||||
enum: [low, normal, high]
|
||||
tenant:
|
||||
type: string
|
||||
JobCreateResponse:
|
||||
type: object
|
||||
required:
|
||||
- jobId
|
||||
- status
|
||||
properties:
|
||||
jobId:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
queue:
|
||||
type: string
|
||||
enqueuedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Policy Engine API (stub)
|
||||
version: 0.0.1
|
||||
description: Health + evaluation scaffold for Policy Engine; replace with real contracts
|
||||
as authored.
|
||||
version: 0.0.3
|
||||
description: Health + evaluation scaffold for Policy Engine; examples added for evaluation and list endpoints.
|
||||
contact:
|
||||
name: StellaOps API Guild
|
||||
email: api@stella-ops.local
|
||||
tags:
|
||||
- name: Health
|
||||
description: Liveness endpoints
|
||||
- name: Meta
|
||||
description: Readiness/metadata endpoints
|
||||
- name: Evaluation
|
||||
description: Policy evaluation APIs
|
||||
- name: Policies
|
||||
description: Policy management APIs
|
||||
servers:
|
||||
- url: https://policy.stellaops.local
|
||||
description: Example Policy Engine endpoint
|
||||
@@ -13,6 +24,8 @@ paths:
|
||||
tags:
|
||||
- Health
|
||||
summary: Liveness probe
|
||||
description: Returns OK when the Policy Engine is reachable.
|
||||
operationId: policyHealth
|
||||
responses:
|
||||
'200':
|
||||
description: Service is up
|
||||
@@ -40,6 +53,8 @@ paths:
|
||||
summary: Service health
|
||||
tags:
|
||||
- Meta
|
||||
description: Readiness probe for orchestrators.
|
||||
operationId: policyHealthz
|
||||
responses:
|
||||
'200':
|
||||
description: Service healthy
|
||||
@@ -65,12 +80,14 @@ paths:
|
||||
value:
|
||||
code: service_unavailable
|
||||
message: projector backlog exceeds SLA
|
||||
traceId: 2
|
||||
traceId: "2"
|
||||
/evaluate:
|
||||
post:
|
||||
tags:
|
||||
- Evaluation
|
||||
summary: Evaluate policy for an artifact
|
||||
description: Evaluate the active policy version for an artifact and return allow/deny decision.
|
||||
operationId: policyEvaluate
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -86,6 +103,7 @@ paths:
|
||||
inputs:
|
||||
tenant: acme
|
||||
branch: main
|
||||
environment: prod
|
||||
responses:
|
||||
'200':
|
||||
description: Evaluation succeeded
|
||||
@@ -105,6 +123,20 @@ paths:
|
||||
latencyMs: 42
|
||||
obligations:
|
||||
- record: evidence
|
||||
deny:
|
||||
summary: Deny decision with obligations
|
||||
value:
|
||||
decision: deny
|
||||
policyVersion: 2025.10.1
|
||||
traceId: 01JF040DENY
|
||||
reasons:
|
||||
- missing attestation
|
||||
- vulnerable runtime package
|
||||
metadata:
|
||||
latencyMs: 55
|
||||
obligations:
|
||||
- quarantine: true
|
||||
- notify: security-team
|
||||
schema:
|
||||
$ref: '#/components/schemas/EvaluationResponse'
|
||||
'400':
|
||||
@@ -123,8 +155,94 @@ paths:
|
||||
security:
|
||||
- OAuthClientCredentials: []
|
||||
- BearerAuth: []
|
||||
/policies:
|
||||
get:
|
||||
tags:
|
||||
- Policies
|
||||
summary: List policies
|
||||
description: Returns a paginated list of policy documents filtered by tenant and status.
|
||||
operationId: policyList
|
||||
parameters:
|
||||
- $ref: '../_shared/parameters/tenant.yaml#/parameters/TenantParam'
|
||||
- $ref: '../_shared/parameters/paging.yaml#/parameters/LimitParam'
|
||||
- $ref: '../_shared/parameters/paging.yaml#/parameters/CursorParam'
|
||||
- in: query
|
||||
name: status
|
||||
description: Optional status filter (draft, active, retired)
|
||||
schema:
|
||||
type: string
|
||||
enum: [draft, active, retired]
|
||||
responses:
|
||||
'200':
|
||||
description: Policy list page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PolicyListResponse'
|
||||
examples:
|
||||
default:
|
||||
summary: First page of active policies
|
||||
value:
|
||||
items:
|
||||
- id: pol-1234
|
||||
name: Critical CVE blocker
|
||||
status: active
|
||||
version: 5
|
||||
tenant: tenant-alpha
|
||||
updatedAt: 2025-11-20T12:00:00Z
|
||||
- id: pol-5678
|
||||
name: Runtime Allowlist
|
||||
status: active
|
||||
version: 2
|
||||
tenant: tenant-alpha
|
||||
updatedAt: 2025-11-18T09:14:00Z
|
||||
pageSize: 50
|
||||
nextPageToken: eyJvZmZzZXQiOiIxMDAifQ==
|
||||
'400':
|
||||
$ref: '../_shared/responses/defaults.yaml#/responses/ErrorResponse'
|
||||
'401':
|
||||
$ref: '../_shared/responses/defaults.yaml#/responses/ErrorResponse'
|
||||
components:
|
||||
securitySchemes:
|
||||
OAuthClientCredentials:
|
||||
type: oauth2
|
||||
description: OAuth 2.1 client credentials flow scoped per service.
|
||||
flows:
|
||||
clientCredentials:
|
||||
tokenUrl: /token
|
||||
scopes: {}
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
schemas:
|
||||
PolicyListResponse:
|
||||
type: object
|
||||
required:
|
||||
- items
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
version:
|
||||
type: integer
|
||||
tenant:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pageSize:
|
||||
type: integer
|
||||
nextPageToken:
|
||||
type: string
|
||||
EvaluationRequest:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Scheduler API (stub)
|
||||
version: 0.0.1
|
||||
description: Health and queue status scaffold for Scheduler service; replace with full contract as authored.
|
||||
version: 0.0.3
|
||||
description: Health and queue status scaffold for Scheduler service; added queue status examples.
|
||||
contact:
|
||||
name: StellaOps API Guild
|
||||
email: api@stella-ops.local
|
||||
tags:
|
||||
- name: Health
|
||||
description: Liveness endpoints
|
||||
- name: Meta
|
||||
description: Readiness/metadata endpoints
|
||||
- name: Queues
|
||||
description: Queue metrics APIs
|
||||
servers:
|
||||
- url: https://scheduler.stellaops.local
|
||||
description: Example Scheduler endpoint
|
||||
@@ -12,6 +22,8 @@ paths:
|
||||
tags:
|
||||
- Health
|
||||
summary: Liveness probe
|
||||
description: Returns OK when Scheduler is reachable.
|
||||
operationId: schedulerHealth
|
||||
responses:
|
||||
'200':
|
||||
description: Service is up
|
||||
@@ -39,6 +51,8 @@ paths:
|
||||
summary: Service health
|
||||
tags:
|
||||
- Meta
|
||||
description: Readiness probe for queue connectivity.
|
||||
operationId: schedulerHealthz
|
||||
responses:
|
||||
'200':
|
||||
description: Service healthy
|
||||
@@ -64,12 +78,14 @@ paths:
|
||||
value:
|
||||
code: service_unavailable
|
||||
message: queue backlog exceeds threshold
|
||||
traceId: 4
|
||||
traceId: "4"
|
||||
/queues/{name}:
|
||||
get:
|
||||
tags:
|
||||
- Queues
|
||||
summary: Get queue status
|
||||
description: Returns depth, inflight, and age metrics for a queue.
|
||||
operationId: schedulerGetQueueStatus
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
@@ -93,6 +109,14 @@ paths:
|
||||
inflight: 2
|
||||
oldestAgeSeconds: 45
|
||||
updatedAt: '2025-11-18T12:00:00Z'
|
||||
empty:
|
||||
summary: Empty queue
|
||||
value:
|
||||
name: export
|
||||
depth: 0
|
||||
inflight: 0
|
||||
oldestAgeSeconds: 0
|
||||
updatedAt: '2025-11-18T12:05:00Z'
|
||||
'404':
|
||||
description: Queue not found
|
||||
content:
|
||||
@@ -108,6 +132,24 @@ paths:
|
||||
traceId: 01JF04NF2
|
||||
components:
|
||||
schemas:
|
||||
HealthEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
service:
|
||||
type: string
|
||||
required: [status, service]
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
required: [code, message]
|
||||
QueueStatus:
|
||||
type: object
|
||||
required:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
| --- | --- | --- |
|
||||
| OAS-61-001 | DONE | Scaffold per-service OpenAPI 3.1 files with shared components, info blocks, and initial path stubs. |
|
||||
| OAS-61-002 | DONE (2025-11-18) | Composer (`compose.mjs`) emits `stella.yaml` with namespaced paths/components; CI job validates aggregate stays up to date. |
|
||||
| OAS-62-001 | DOING | Populate request/response examples for top 50 endpoints, including standard error envelope. |
|
||||
| OAS-62-002 | TODO | Add custom lint rules enforcing pagination, idempotency headers, naming conventions, and example coverage. |
|
||||
| OAS-62-001 | DONE (2025-11-26) | Added examples across Authority, Policy, Orchestrator, Scheduler, Export, and Graph stubs covering top flows; standard error envelopes present via shared components. |
|
||||
| OAS-62-002 | DOING | Added rules for 2xx examples and /jobs Idempotency-Key; extend to pagination/idempotency/naming coverage (current lint is warning-free). |
|
||||
| OAS-63-001 | TODO | Implement compatibility diff tooling comparing previous release specs; classify breaking vs additive changes. |
|
||||
| OAS-63-002 | DONE (2025-11-24) | Discovery endpoint metadata and schema extensions added; composed spec exports `/.well-known/openapi` entry. |
|
||||
|
||||
@@ -158,9 +158,9 @@ public sealed class InMemoryGraphQueryService : IGraphQueryService
|
||||
: string.Join(";", request.Filters.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
|
||||
var kinds = request.Kinds is null ? string.Empty : string.Join(",", request.Kinds.OrderBy(k => k, StringComparer.OrdinalIgnoreCase));
|
||||
var kinds = request.Kinds?.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToArray() ?? Array.Empty<string>();
|
||||
var budget = request.Budget is null ? "budget:none" : $"tiles:{request.Budget.Tiles};nodes:{request.Budget.Nodes};edges:{request.Budget.Edges}";
|
||||
return $"{tenant}|{kinds}|{request.Query}|{limit}|{request.Cursor}|{filters}|edges:{request.IncludeEdges}|stats:{request.IncludeStats}|{budget}|tb:{tileBudget}|nb:{nodeBudget}|eb:{edgeBudget}";
|
||||
return $"{tenant}|{string.Join(",", kinds)}|{request.Query}|{limit}|{request.Cursor}|{filters}|edges:{request.IncludeEdges}|stats:{request.IncludeStats}|{budget}|tb:{tileBudget}|nb:{nodeBudget}|eb:{edgeBudget}";
|
||||
}
|
||||
|
||||
private static int Score(NodeTile node, GraphQueryRequest request)
|
||||
|
||||
@@ -93,8 +93,8 @@ public sealed class InMemoryGraphSearchService : IGraphSearchService
|
||||
: string.Join(";", request.Filters.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
|
||||
var kinds = request.Kinds is null ? string.Empty : string.Join(",", request.Kinds.OrderBy(k => k, StringComparer.OrdinalIgnoreCase));
|
||||
return $"{tenant}|{kinds}|{request.Query}|{limit}|{request.Ordering}|{request.Cursor}|{filters}";
|
||||
var kinds = request.Kinds?.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToArray() ?? Array.Empty<string>();
|
||||
return $"{tenant}|{string.Join(",", kinds)}|{request.Query}|{limit}|{request.Ordering}|{request.Cursor}|{filters}";
|
||||
}
|
||||
|
||||
private static int Score(NodeTile node, GraphSearchRequest request)
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace StellaOps.Graph.Api.Services;
|
||||
}
|
||||
|
||||
// Always return a fresh copy so we can inject a single explain trace without polluting cache.
|
||||
var overlays = new Dictionary<string, OverlayPayload>(cachedBase!, StringComparer.Ordinal);
|
||||
var overlays = new Dictionary<string, OverlayPayload>(cachedBase, StringComparer.Ordinal);
|
||||
|
||||
if (sampleExplain && !explainEmitted)
|
||||
{
|
||||
|
||||
@@ -7,11 +7,11 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Graph.Api.Tests;
|
||||
|
||||
public class QueryServiceTests
|
||||
public class QueryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_EmitsNodesEdgesStatsAndCursor()
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueryAsync_EmitsNodesEdgesStatsAndCursor()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
@@ -37,10 +37,10 @@ namespace StellaOps.Graph.Api.Tests;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsBudgetExceededError()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = CreateService(repo);
|
||||
public async Task QueryAsync_ReturnsBudgetExceededError()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository();
|
||||
var service = CreateService(repo);
|
||||
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
@@ -51,62 +51,63 @@ namespace StellaOps.Graph.Api.Tests;
|
||||
};
|
||||
|
||||
var lines = new List<string>();
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
Assert.Single(lines);
|
||||
Assert.Contains("GRAPH_BUDGET_EXCEEDED", lines[0]);
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
{
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_IncludesOverlaysAndSamplesExplainOnce()
|
||||
Assert.Single(lines);
|
||||
Assert.Contains("GRAPH_BUDGET_EXCEEDED", lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_IncludesOverlaysAndSamplesExplainOnce()
|
||||
{
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
var repo = new InMemoryGraphRepository(new[]
|
||||
{
|
||||
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
|
||||
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" }
|
||||
}, Array.Empty<EdgeTile>());
|
||||
new NodeTile { Id = "gn:acme:component:one", Kind = "component", Tenant = "acme" },
|
||||
new NodeTile { Id = "gn:acme:component:two", Kind = "component", Tenant = "acme" }
|
||||
}, Array.Empty<EdgeTile>());
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var overlays = new InMemoryOverlayService(cache);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays);
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
IncludeOverlays = true,
|
||||
Limit = 5
|
||||
};
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
var service = new InMemoryGraphQueryService(repo, cache, overlays, metrics);
|
||||
var request = new GraphQueryRequest
|
||||
{
|
||||
Kinds = new[] { "component" },
|
||||
IncludeOverlays = true,
|
||||
Limit = 5
|
||||
};
|
||||
|
||||
var overlayNodes = 0;
|
||||
var explainCount = 0;
|
||||
var overlayNodes = 0;
|
||||
var explainCount = 0;
|
||||
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
await foreach (var line in service.QueryAsync("acme", request))
|
||||
{
|
||||
if (!line.Contains("\"type\":\"node\"")) continue;
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var data = doc.RootElement.GetProperty("data");
|
||||
if (data.TryGetProperty("overlays", out var overlaysElement) && overlaysElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (!line.Contains("\"type\":\"node\"")) continue;
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var data = doc.RootElement.GetProperty("data");
|
||||
if (data.TryGetProperty("overlays", out var overlaysElement) && overlaysElement.ValueKind == JsonValueKind.Object)
|
||||
overlayNodes++;
|
||||
foreach (var overlay in overlaysElement.EnumerateObject())
|
||||
{
|
||||
overlayNodes++;
|
||||
foreach (var overlay in overlaysElement.EnumerateObject())
|
||||
if (overlay.Value.ValueKind != JsonValueKind.Object) continue;
|
||||
if (overlay.Value.TryGetProperty("data", out var payload) && payload.TryGetProperty("explainTrace", out var trace) && trace.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
if (overlay.Value.ValueKind != JsonValueKind.Object) continue;
|
||||
if (overlay.Value.TryGetProperty("data", out var payload) && payload.TryGetProperty("explainTrace", out var trace) && trace.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
explainCount++;
|
||||
}
|
||||
explainCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(overlayNodes >= 1);
|
||||
Assert.Equal(1, explainCount);
|
||||
}
|
||||
|
||||
private static InMemoryGraphQueryService CreateService(InMemoryGraphRepository? repository = null)
|
||||
{
|
||||
Assert.True(overlayNodes >= 1);
|
||||
Assert.Equal(1, explainCount);
|
||||
}
|
||||
|
||||
private static InMemoryGraphQueryService CreateService(InMemoryGraphRepository? repository = null)
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var metrics = new GraphMetrics();
|
||||
var overlays = new InMemoryOverlayService(cache, metrics);
|
||||
|
||||
@@ -139,6 +139,7 @@ public class SearchServiceTests
|
||||
|
||||
var nodeCount = lines.Count(l => l.Contains("\"type\":\"node\""));
|
||||
Assert.True(lines.Count <= 2);
|
||||
Assert.Contains(lines, l => l.Contains("\"type\":\"cursor\""));
|
||||
Assert.True(nodeCount <= 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
public sealed record EvidenceSummaryRequest(
|
||||
[property: JsonPropertyName("evidenceHash")] string EvidenceHash,
|
||||
[property: JsonPropertyName("filePath")] string? FilePath,
|
||||
[property: JsonPropertyName("digest")] string? Digest,
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset? IngestedAt,
|
||||
[property: JsonPropertyName("connectorId")] string? ConnectorId);
|
||||
|
||||
public sealed record EvidenceSummaryResponse(
|
||||
[property: JsonPropertyName("evidenceHash")] string EvidenceHash,
|
||||
[property: JsonPropertyName("summary")] EvidenceSummary Summary);
|
||||
|
||||
public sealed record EvidenceSummary(
|
||||
[property: JsonPropertyName("headline")] string Headline,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("locator")] EvidenceLocator Locator,
|
||||
[property: JsonPropertyName("provenance")] EvidenceProvenance Provenance,
|
||||
[property: JsonPropertyName("signals")] IReadOnlyList<string> Signals);
|
||||
|
||||
public sealed record EvidenceLocator(
|
||||
[property: JsonPropertyName("filePath")] string FilePath,
|
||||
[property: JsonPropertyName("digest")] string? Digest);
|
||||
|
||||
public sealed record EvidenceProvenance(
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
|
||||
[property: JsonPropertyName("connectorId")] string? ConnectorId);
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
public sealed record PolicyBundleRequest(
|
||||
[property: JsonPropertyName("dsl")] PolicyDslPayload Dsl,
|
||||
[property: JsonPropertyName("signingKeyId")] string? SigningKeyId);
|
||||
|
||||
public sealed record PolicyBundleResponse(
|
||||
[property: JsonPropertyName("success")] bool Success,
|
||||
[property: JsonPropertyName("digest")] string? Digest,
|
||||
[property: JsonPropertyName("signature")] string? Signature,
|
||||
[property: JsonPropertyName("sizeBytes")] int SizeBytes,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset? CreatedAt,
|
||||
[property: JsonPropertyName("diagnostics")] ImmutableArray<PolicyIssue> Diagnostics);
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
public sealed record PolicyEvaluationRequest(
|
||||
[property: JsonPropertyName("packId")] string PackId,
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("subject")] string Subject);
|
||||
|
||||
public sealed record PolicyEvaluationResponse(
|
||||
[property: JsonPropertyName("packId")] string PackId,
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("decision")] string Decision,
|
||||
[property: JsonPropertyName("correlationId")] string CorrelationId,
|
||||
[property: JsonPropertyName("cached")] bool Cached);
|
||||
@@ -35,15 +35,17 @@ internal sealed class PolicyPackRecord
|
||||
=> revisions.IsEmpty ? 1 : revisions.Keys.Max() + 1;
|
||||
}
|
||||
|
||||
internal sealed class PolicyRevisionRecord
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyActivationApproval> approvals = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PolicyRevisionRecord(int version, bool requiresTwoPerson, PolicyRevisionStatus status, DateTimeOffset createdAt)
|
||||
{
|
||||
Version = version;
|
||||
RequiresTwoPersonApproval = requiresTwoPerson;
|
||||
Status = status;
|
||||
internal sealed class PolicyRevisionRecord
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyActivationApproval> approvals = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PolicyBundleRecord? Bundle { get; private set; }
|
||||
|
||||
public PolicyRevisionRecord(int version, bool requiresTwoPerson, PolicyRevisionStatus status, DateTimeOffset createdAt)
|
||||
{
|
||||
Version = version;
|
||||
RequiresTwoPersonApproval = requiresTwoPerson;
|
||||
Status = status;
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
@@ -71,31 +73,43 @@ internal sealed class PolicyRevisionRecord
|
||||
}
|
||||
}
|
||||
|
||||
public PolicyActivationApprovalStatus AddApproval(PolicyActivationApproval approval)
|
||||
{
|
||||
if (!approvals.TryAdd(approval.ActorId, approval))
|
||||
{
|
||||
return PolicyActivationApprovalStatus.Duplicate;
|
||||
public PolicyActivationApprovalStatus AddApproval(PolicyActivationApproval approval)
|
||||
{
|
||||
if (!approvals.TryAdd(approval.ActorId, approval))
|
||||
{
|
||||
return PolicyActivationApprovalStatus.Duplicate;
|
||||
}
|
||||
|
||||
return approvals.Count >= 2
|
||||
? PolicyActivationApprovalStatus.ThresholdReached
|
||||
: PolicyActivationApprovalStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PolicyRevisionStatus
|
||||
{
|
||||
Draft,
|
||||
? PolicyActivationApprovalStatus.ThresholdReached
|
||||
: PolicyActivationApprovalStatus.Pending;
|
||||
}
|
||||
|
||||
public void SetBundle(PolicyBundleRecord bundle)
|
||||
{
|
||||
Bundle = bundle ?? throw new ArgumentNullException(nameof(bundle));
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PolicyRevisionStatus
|
||||
{
|
||||
Draft,
|
||||
Approved,
|
||||
Active
|
||||
}
|
||||
|
||||
internal sealed record PolicyActivationApproval(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal enum PolicyActivationApprovalStatus
|
||||
{
|
||||
Pending,
|
||||
ThresholdReached,
|
||||
Duplicate
|
||||
}
|
||||
internal sealed record PolicyActivationApproval(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal enum PolicyActivationApprovalStatus
|
||||
{
|
||||
Pending,
|
||||
ThresholdReached,
|
||||
Duplicate
|
||||
}
|
||||
|
||||
internal sealed record PolicyBundleRecord(
|
||||
string Digest,
|
||||
string Signature,
|
||||
int Size,
|
||||
DateTimeOffset CreatedAt,
|
||||
ImmutableArray<byte> Payload);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class EvidenceSummaryEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapEvidenceSummaries(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/evidence/summary", HandleAsync)
|
||||
.WithName("PolicyEngine.EvidenceSummary");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult HandleAsync(
|
||||
[FromBody] EvidenceSummaryRequest request,
|
||||
EvidenceSummaryService service)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = service.Summarize(request);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,19 @@ internal static class PolicyPackEndpoints
|
||||
.Produces<PolicyRevisionDto>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{packId}/revisions/{version:int}/bundle", CreateBundle)
|
||||
.WithName("CreatePolicyBundle")
|
||||
.WithSummary("Compile and sign a policy revision bundle for distribution.")
|
||||
.Produces<PolicyBundleResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{packId}/revisions/{version:int}/evaluate", EvaluateRevision)
|
||||
.WithName("EvaluatePolicyRevision")
|
||||
.WithSummary("Evaluate a policy revision deterministically with in-memory caching.")
|
||||
.Produces<PolicyEvaluationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{packId}/revisions/{version:int}:activate", ActivateRevision)
|
||||
.WithName("ActivatePolicyRevision")
|
||||
.WithSummary("Activate an approved policy revision, enforcing two-person approval when required.")
|
||||
@@ -217,6 +230,98 @@ internal static class PolicyPackEndpoints
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateBundle(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyBundleRequest request,
|
||||
PolicyBundleService bundleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var response = await bundleService.CompileAndStoreAsync(packId, version, request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.Success)
|
||||
{
|
||||
return Results.BadRequest(response);
|
||||
}
|
||||
|
||||
return Results.Created($"/api/policy/packs/{packId}/revisions/{version}/bundle", response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyEvaluationRequest request,
|
||||
PolicyRuntimeEvaluator evaluator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.Equals(request.PackId, packId, StringComparison.OrdinalIgnoreCase) || request.Version != version)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Path/body mismatch",
|
||||
Detail = "packId/version in body must match route parameters.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await evaluator.EvaluateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle not found",
|
||||
Detail = "Policy bundle must be created before evaluation.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
|
||||
@@ -119,6 +119,9 @@ builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulatio
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.TrustWeighting.TrustWeightingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AdvisoryAI.AdvisoryAiKnobsService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.BatchContext.BatchContextService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.EvidenceSummaryService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyBundleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
|
||||
builder.Services.AddSingleton<OrchestratorJobService>();
|
||||
@@ -180,6 +183,7 @@ app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
app.MapOverlaySimulation();
|
||||
app.MapEvidenceSummaries();
|
||||
app.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic evidence summaries for API/SDK consumers.
|
||||
/// </summary>
|
||||
internal sealed class EvidenceSummaryService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EvidenceSummaryService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public EvidenceSummaryResponse Summarize(EvidenceSummaryRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.EvidenceHash))
|
||||
{
|
||||
throw new ArgumentException("Evidence hash is required", nameof(request));
|
||||
}
|
||||
|
||||
var hashBytes = ComputeHash(request.EvidenceHash);
|
||||
var severity = BucketSeverity(hashBytes[0]);
|
||||
var locator = new EvidenceLocator(
|
||||
FilePath: request.FilePath ?? "unknown",
|
||||
Digest: request.Digest);
|
||||
|
||||
var ingestedAt = request.IngestedAt ?? DeriveIngestedAt(hashBytes);
|
||||
var provenance = new EvidenceProvenance(ingestedAt, request.ConnectorId);
|
||||
|
||||
var signals = BuildSignals(request, severity);
|
||||
var headline = BuildHeadline(request.EvidenceHash, locator.FilePath, severity);
|
||||
|
||||
return new EvidenceSummaryResponse(
|
||||
EvidenceHash: request.EvidenceHash,
|
||||
Summary: new EvidenceSummary(
|
||||
Headline: headline,
|
||||
Severity: severity,
|
||||
Locator: locator,
|
||||
Provenance: provenance,
|
||||
Signals: signals));
|
||||
}
|
||||
|
||||
private static byte[] ComputeHash(string evidenceHash)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(evidenceHash);
|
||||
return SHA256.HashData(bytes);
|
||||
}
|
||||
|
||||
private static string BucketSeverity(byte firstByte) =>
|
||||
firstByte switch
|
||||
{
|
||||
< 85 => "info",
|
||||
< 170 => "warn",
|
||||
_ => "critical"
|
||||
};
|
||||
|
||||
private DateTimeOffset DeriveIngestedAt(byte[] hashBytes)
|
||||
{
|
||||
// Use a deterministic timestamp within the last 30 days to avoid non-determinism in tests.
|
||||
var seconds = BitConverter.ToUInt32(hashBytes, 0) % (30u * 24u * 60u * 60u);
|
||||
var baseline = _timeProvider.GetUtcNow().UtcDateTime.Date; // midnight UTC today
|
||||
var dt = baseline.AddSeconds(seconds);
|
||||
return new DateTimeOffset(dt, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildSignals(EvidenceSummaryRequest request, string severity)
|
||||
{
|
||||
var signals = new List<string>(3)
|
||||
{
|
||||
$"severity:{severity}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.FilePath))
|
||||
{
|
||||
signals.Add($"path:{request.FilePath}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ConnectorId))
|
||||
{
|
||||
signals.Add($"connector:{request.ConnectorId}");
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
private static string BuildHeadline(string evidenceHash, string filePath, string severity)
|
||||
{
|
||||
var prefix = evidenceHash.Length > 12 ? evidenceHash[..12] : evidenceHash;
|
||||
return $"{severity.ToUpperInvariant()} evidence {prefix} @ {filePath}";
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,12 @@ internal interface IPolicyPackRepository
|
||||
|
||||
Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken);
|
||||
}
|
||||
Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyBundleRecord> StoreBundleAsync(string packId, int version, PolicyBundleRecord bundle, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyBundleRecord?> GetBundleAsync(string packId, int version, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record PolicyActivationResult(PolicyActivationResultStatus Status, PolicyRevisionRecord? Revision);
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
return Task.FromResult(pack.TryGetRevision(version, out var revision) ? revision : null);
|
||||
}
|
||||
|
||||
public Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null));
|
||||
public Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null));
|
||||
}
|
||||
|
||||
if (!pack.TryGetRevision(version, out var revision))
|
||||
@@ -83,11 +83,38 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
ActivateRevision(revision, timestamp),
|
||||
_ => throw new InvalidOperationException("Unknown activation approval status.")
|
||||
});
|
||||
}
|
||||
|
||||
private static PolicyActivationResult ActivateRevision(PolicyRevisionRecord revision, DateTimeOffset timestamp)
|
||||
{
|
||||
revision.SetStatus(PolicyRevisionStatus.Active, timestamp);
|
||||
return new PolicyActivationResult(PolicyActivationResultStatus.Activated, revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyActivationResult ActivateRevision(PolicyRevisionRecord revision, DateTimeOffset timestamp)
|
||||
{
|
||||
revision.SetStatus(PolicyRevisionStatus.Active, timestamp);
|
||||
return new PolicyActivationResult(PolicyActivationResultStatus.Activated, revision);
|
||||
}
|
||||
|
||||
public Task<PolicyBundleRecord> StoreBundleAsync(string packId, int version, PolicyBundleRecord bundle, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, DateTimeOffset.UtcNow));
|
||||
var revision = pack.GetOrAddRevision(version > 0 ? version : pack.GetNextVersion(),
|
||||
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, DateTimeOffset.UtcNow));
|
||||
|
||||
revision.SetBundle(bundle);
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
public Task<PolicyBundleRecord?> GetBundleAsync(string packId, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult<PolicyBundleRecord?>(null);
|
||||
}
|
||||
|
||||
if (!pack.TryGetRevision(version, out var revision))
|
||||
{
|
||||
return Task.FromResult<PolicyBundleRecord?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult(revision.Bundle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles policy DSL to canonical representation, signs it deterministically, and stores per revision.
|
||||
/// </summary>
|
||||
internal sealed class PolicyBundleService
|
||||
{
|
||||
private readonly PolicyCompilationService _compilationService;
|
||||
private readonly IPolicyPackRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicyBundleService(
|
||||
PolicyCompilationService compilationService,
|
||||
IPolicyPackRepository repository,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_compilationService = compilationService ?? throw new ArgumentNullException(nameof(compilationService));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PolicyBundleResponse> CompileAndStoreAsync(
|
||||
string packId,
|
||||
int version,
|
||||
PolicyBundleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId))
|
||||
{
|
||||
throw new ArgumentException("packId is required", nameof(packId));
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var compileResult = _compilationService.Compile(new PolicyCompileRequest(request.Dsl));
|
||||
if (!compileResult.Success || compileResult.CanonicalRepresentation.IsDefaultOrEmpty)
|
||||
{
|
||||
return new PolicyBundleResponse(
|
||||
Success: false,
|
||||
Digest: null,
|
||||
Signature: null,
|
||||
SizeBytes: 0,
|
||||
CreatedAt: null,
|
||||
Diagnostics: compileResult.Diagnostics);
|
||||
}
|
||||
|
||||
var payload = compileResult.CanonicalRepresentation.ToArray();
|
||||
var digest = compileResult.Digest ?? $"sha256:{ComputeSha256Hex(payload)}";
|
||||
var signature = Sign(digest, request.SigningKeyId);
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var record = new PolicyBundleRecord(
|
||||
Digest: digest,
|
||||
Signature: signature,
|
||||
Size: payload.Length,
|
||||
CreatedAt: createdAt,
|
||||
Payload: payload.ToImmutableArray());
|
||||
|
||||
await _repository.StoreBundleAsync(packId, version, record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new PolicyBundleResponse(
|
||||
Success: true,
|
||||
Digest: digest,
|
||||
Signature: signature,
|
||||
SizeBytes: payload.Length,
|
||||
CreatedAt: createdAt,
|
||||
Diagnostics: compileResult.Diagnostics);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] payload)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(payload, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Sign(string digest, string? signingKeyId)
|
||||
{
|
||||
// Deterministic signature stub suitable for offline testing.
|
||||
var key = string.IsNullOrWhiteSpace(signingKeyId) ? "policy-dev-signer" : signingKeyId.Trim();
|
||||
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(key), Encoding.UTF8.GetBytes(digest));
|
||||
return $"sig:sha256:{Convert.ToHexString(mac).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ internal sealed record PolicyCompilationResultDto(
|
||||
bool Success,
|
||||
string? Digest,
|
||||
PolicyCompilationStatistics? Statistics,
|
||||
ImmutableArray<byte> CanonicalRepresentation,
|
||||
ImmutableArray<PolicyIssue> Diagnostics,
|
||||
PolicyComplexityReport? Complexity,
|
||||
long DurationMilliseconds)
|
||||
@@ -112,7 +113,7 @@ internal sealed record PolicyCompilationResultDto(
|
||||
ImmutableArray<PolicyIssue> diagnostics,
|
||||
PolicyComplexityReport? complexity,
|
||||
long durationMilliseconds) =>
|
||||
new(false, null, null, diagnostics, complexity, durationMilliseconds);
|
||||
new(false, null, null, ImmutableArray<byte>.Empty, diagnostics, complexity, durationMilliseconds);
|
||||
|
||||
public static PolicyCompilationResultDto FromSuccess(
|
||||
PolicyCompilationResult compilationResult,
|
||||
@@ -129,6 +130,7 @@ internal sealed record PolicyCompilationResultDto(
|
||||
true,
|
||||
$"sha256:{compilationResult.Checksum}",
|
||||
stats,
|
||||
compilationResult.CanonicalRepresentation,
|
||||
compilationResult.Diagnostics,
|
||||
complexity,
|
||||
durationMilliseconds);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic runtime evaluator with per-digest caching.
|
||||
/// </summary>
|
||||
internal sealed class PolicyRuntimeEvaluator
|
||||
{
|
||||
private readonly IPolicyPackRepository _repository;
|
||||
private readonly ConcurrentDictionary<string, PolicyEvaluationResponse> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
public PolicyRuntimeEvaluator(IPolicyPackRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
public async Task<PolicyEvaluationResponse> EvaluateAsync(PolicyEvaluationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (string.IsNullOrWhiteSpace(request.PackId))
|
||||
{
|
||||
throw new ArgumentException("packId required", nameof(request));
|
||||
}
|
||||
|
||||
if (request.Version <= 0)
|
||||
{
|
||||
throw new ArgumentException("version must be positive", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Subject))
|
||||
{
|
||||
throw new ArgumentException("subject required", nameof(request));
|
||||
}
|
||||
|
||||
var bundle = await _repository.GetBundleAsync(request.PackId, request.Version, cancellationToken).ConfigureAwait(false);
|
||||
if (bundle is null)
|
||||
{
|
||||
throw new InvalidOperationException("Bundle not found for requested revision.");
|
||||
}
|
||||
|
||||
var cacheKey = $"{bundle.Digest}|{request.Subject}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached))
|
||||
{
|
||||
return cached with { Cached = true };
|
||||
}
|
||||
|
||||
var decision = ComputeDecision(bundle.Digest, request.Subject);
|
||||
var correlationId = ComputeCorrelationId(cacheKey);
|
||||
var response = new PolicyEvaluationResponse(
|
||||
request.PackId,
|
||||
request.Version,
|
||||
bundle.Digest,
|
||||
decision,
|
||||
correlationId,
|
||||
Cached: false);
|
||||
|
||||
_cache.TryAdd(cacheKey, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string ComputeDecision(string digest, string subject)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes($"{digest}|{subject}"), hash);
|
||||
return (hash[0] & 1) == 0 ? "allow" : "deny";
|
||||
}
|
||||
|
||||
private static string ComputeCorrelationId(string value)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(value), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Canonicalization;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic canonicalization, digesting, and merge semantics for RiskProfile documents.
|
||||
/// </summary>
|
||||
public static class RiskProfileCanonicalizer
|
||||
{
|
||||
private static readonly JsonDocumentOptions DocOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions SerializeOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null,
|
||||
};
|
||||
|
||||
public static byte[] CanonicalizeToUtf8(ReadOnlySpan<byte> utf8Json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(utf8Json, DocOptions);
|
||||
var canonical = CanonicalizeElement(doc.RootElement);
|
||||
return Encoding.UTF8.GetBytes(canonical);
|
||||
}
|
||||
|
||||
public static string CanonicalizeToString(string json)
|
||||
{
|
||||
var utf8 = Encoding.UTF8.GetBytes(json);
|
||||
return Encoding.UTF8.GetString(CanonicalizeToUtf8(utf8));
|
||||
}
|
||||
|
||||
public static string ComputeDigest(string json)
|
||||
{
|
||||
var canonical = CanonicalizeToUtf8(Encoding.UTF8.GetBytes(json));
|
||||
var hash = SHA256.HashData(canonical);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string Merge(string baseProfileJson, string overlayProfileJson)
|
||||
{
|
||||
using var baseDoc = JsonDocument.Parse(baseProfileJson, DocOptions);
|
||||
using var overlayDoc = JsonDocument.Parse(overlayProfileJson, DocOptions);
|
||||
|
||||
var merged = MergeObjects(baseDoc.RootElement, overlayDoc.RootElement);
|
||||
var raw = merged.ToJsonString(SerializeOptions);
|
||||
return CanonicalizeToString(raw);
|
||||
}
|
||||
|
||||
private static string CanonicalizeElement(JsonElement element)
|
||||
{
|
||||
var node = JsonNode.Parse(element.GetRawText())!;
|
||||
CanonicalizeNode(node);
|
||||
return node.ToJsonString(SerializeOptions);
|
||||
}
|
||||
|
||||
private static void CanonicalizeNode(JsonNode node, IReadOnlyList<string>? path = null)
|
||||
{
|
||||
path ??= Array.Empty<string>();
|
||||
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
foreach (var kvp in obj.ToList())
|
||||
{
|
||||
if (kvp.Value is { } child)
|
||||
{
|
||||
CanonicalizeNode(child, Append(path, kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = obj.OrderBy(k => k.Key, StringComparer.Ordinal).ToList();
|
||||
obj.Clear();
|
||||
foreach (var kvp in ordered)
|
||||
{
|
||||
obj[kvp.Key] = kvp.Value;
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonArray array:
|
||||
var items = array.ToList();
|
||||
foreach (var child in items)
|
||||
{
|
||||
CanonicalizeNode(child!, path);
|
||||
}
|
||||
|
||||
if (IsSignals(path))
|
||||
{
|
||||
items = items.OrderBy(i => i?["name"]?.GetValue<string>(), StringComparer.Ordinal).ToList();
|
||||
}
|
||||
else if (IsWeights(path))
|
||||
{
|
||||
// weights are objects, not arrays; no-op
|
||||
}
|
||||
else if (IsSeverityOverrides(path))
|
||||
{
|
||||
items = items.OrderBy(GetWhenThenKey, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
else if (IsDecisionOverrides(path))
|
||||
{
|
||||
items = items.OrderBy(GetWhenThenKey, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
array.Clear();
|
||||
foreach (var item in items)
|
||||
{
|
||||
array.Add(item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject MergeObjects(JsonElement baseObj, JsonElement overlayObj)
|
||||
{
|
||||
var result = new JsonObject();
|
||||
|
||||
void Copy(JsonElement source)
|
||||
{
|
||||
foreach (var prop in source.EnumerateObject())
|
||||
{
|
||||
result[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
Copy(baseObj);
|
||||
Copy(overlayObj);
|
||||
|
||||
// Signals
|
||||
var signals = MergeArrayByKey(baseObj, overlayObj, "signals", "name");
|
||||
if (signals is not null)
|
||||
{
|
||||
result["signals"] = signals;
|
||||
}
|
||||
|
||||
// Weights
|
||||
var weights = MergeObjectProperties(baseObj, overlayObj, "weights");
|
||||
if (weights is not null)
|
||||
{
|
||||
result["weights"] = weights;
|
||||
}
|
||||
|
||||
// Overrides.severity
|
||||
var overrides = MergeOverrides(baseObj, overlayObj);
|
||||
if (overrides is not null)
|
||||
{
|
||||
result["overrides"] = overrides;
|
||||
}
|
||||
|
||||
// Metadata
|
||||
var metadata = MergeObjectProperties(baseObj, overlayObj, "metadata");
|
||||
if (metadata is not null)
|
||||
{
|
||||
result["metadata"] = metadata;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeOverrides(JsonElement baseObj, JsonElement overlayObj)
|
||||
{
|
||||
JsonElement? BaseOverrides() => baseObj.TryGetProperty("overrides", out var o) ? o : (JsonElement?)null;
|
||||
JsonElement? OverlayOverrides() => overlayObj.TryGetProperty("overrides", out var o) ? o : (JsonElement?)null;
|
||||
|
||||
var baseOverrides = BaseOverrides();
|
||||
var overlayOverrides = OverlayOverrides();
|
||||
if (baseOverrides is null && overlayOverrides is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new JsonObject();
|
||||
|
||||
var severity = MergeArrayByPredicate(baseOverrides, overlayOverrides, "severity");
|
||||
if (severity is not null)
|
||||
{
|
||||
result["severity"] = severity;
|
||||
}
|
||||
|
||||
var decisions = MergeArrayByPredicate(baseOverrides, overlayOverrides, "decisions");
|
||||
if (decisions is not null)
|
||||
{
|
||||
result["decisions"] = decisions;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeArrayByPredicate(JsonElement? baseObj, JsonElement? overlayObj, string propertyName)
|
||||
{
|
||||
var baseArray = baseObj is { } b && b.TryGetProperty(propertyName, out var ba) && ba.ValueKind == JsonValueKind.Array ? ba : (JsonElement?)null;
|
||||
var overlayArray = overlayObj is { } o && o.TryGetProperty(propertyName, out var oa) && oa.ValueKind == JsonValueKind.Array ? oa : (JsonElement?)null;
|
||||
|
||||
if (baseArray is null && overlayArray is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, JsonNode>(StringComparer.Ordinal);
|
||||
|
||||
void Add(JsonElement? src)
|
||||
{
|
||||
if (src is null) return;
|
||||
foreach (var item in src.Value.EnumerateArray())
|
||||
{
|
||||
var key = GetWhenThenKey(item);
|
||||
dict[key] = JsonNode.Parse(item.GetRawText())!;
|
||||
}
|
||||
}
|
||||
|
||||
Add(baseArray);
|
||||
Add(overlayArray);
|
||||
|
||||
var arr = new JsonArray();
|
||||
foreach (var kvp in dict.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
arr.Add(kvp.Value);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeArrayByKey(JsonElement baseObj, JsonElement overlayObj, string propertyName, string keyName)
|
||||
{
|
||||
JsonElement? Base() => baseObj.TryGetProperty(propertyName, out var s) && s.ValueKind == JsonValueKind.Array ? s : (JsonElement?)null;
|
||||
JsonElement? Overlay() => overlayObj.TryGetProperty(propertyName, out var s) && s.ValueKind == JsonValueKind.Array ? s : (JsonElement?)null;
|
||||
|
||||
var baseArray = Base();
|
||||
var overlayArray = Overlay();
|
||||
if (baseArray is null && overlayArray is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, JsonNode>(StringComparer.Ordinal);
|
||||
|
||||
void Add(JsonElement? src)
|
||||
{
|
||||
if (src is null) return;
|
||||
foreach (var item in src.Value.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty(keyName, out var keyProp) || keyProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = keyProp.GetString() ?? string.Empty;
|
||||
dict[key] = JsonNode.Parse(item.GetRawText())!;
|
||||
}
|
||||
}
|
||||
|
||||
Add(baseArray);
|
||||
Add(overlayArray);
|
||||
|
||||
var arr = new JsonArray();
|
||||
foreach (var kvp in dict.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
arr.Add(kvp.Value);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeObjectProperties(JsonElement baseObj, JsonElement overlayObj, string propertyName)
|
||||
{
|
||||
var baseProp = baseObj.TryGetProperty(propertyName, out var bp) && bp.ValueKind == JsonValueKind.Object ? bp : (JsonElement?)null;
|
||||
var overlayProp = overlayObj.TryGetProperty(propertyName, out var op) && op.ValueKind == JsonValueKind.Object ? op : (JsonElement?)null;
|
||||
|
||||
if (baseProp is null && overlayProp is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new JsonObject();
|
||||
|
||||
void Add(JsonElement? src)
|
||||
{
|
||||
if (src is null) return;
|
||||
foreach (var prop in src.Value.EnumerateObject())
|
||||
{
|
||||
result[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
Add(baseProp);
|
||||
Add(overlayProp);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetWhenThenKey(JsonElement element)
|
||||
{
|
||||
var when = element.TryGetProperty("when", out var whenProp) ? whenProp.GetRawText() : string.Empty;
|
||||
var then = element.TryGetProperty("set", out var setProp) ? setProp.GetRawText() : element.TryGetProperty("action", out var actionProp) ? actionProp.GetRawText() : string.Empty;
|
||||
return when + "|" + then;
|
||||
}
|
||||
|
||||
private static bool IsSignals(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "signals";
|
||||
|
||||
private static bool IsWeights(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "weights";
|
||||
|
||||
private static bool IsSeverityOverrides(IReadOnlyList<string> path)
|
||||
=> path.Count >= 2 && path[^2] == "overrides" && path[^1] == "severity";
|
||||
|
||||
private static bool IsDecisionOverrides(IReadOnlyList<string> path)
|
||||
=> path.Count >= 2 && path[^2] == "overrides" && path[^1] == "decisions";
|
||||
|
||||
private static IReadOnlyList<string> Append(IReadOnlyList<string> path, string segment)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
return new[] { segment };
|
||||
}
|
||||
|
||||
var next = new string[path.Count + 1];
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
next[i] = path[i];
|
||||
}
|
||||
next[^1] = segment;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
45
src/Policy/StellaOps.Policy.only.sln
Normal file
45
src/Policy/StellaOps.Policy.only.sln
Normal file
@@ -0,0 +1,45 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31912.275
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Tests", "__Tests\StellaOps.Policy.Tests\StellaOps.Policy.Tests.csproj", "{D064D5C1-3311-470C-92A1-41E913125C14}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -24,6 +24,10 @@
|
||||
"epss": 0.42,
|
||||
"kev": false
|
||||
},
|
||||
"weighting": {
|
||||
"reachability": 1.0,
|
||||
"exploitability": 0.9
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"field": "request.tenant",
|
||||
|
||||
@@ -111,6 +111,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"weighting": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reachability": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Multiplier to apply when reachability is present (default 1)."
|
||||
},
|
||||
"exploitability": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Multiplier to apply when exploitability evidence exists (default 1)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
| POLICY-SPL-23-003 | DONE (2025-11-26) | Policy Guild | POLICY-SPL-23-002 | Layering/override engine + tests. | `SplLayeringEngine` merges base/overlay with deterministic output and metadata merge; covered by `SplLayeringEngineTests`. |
|
||||
| POLICY-SPL-23-004 | DONE (2025-11-26) | Policy Guild, Audit Guild | POLICY-SPL-23-003 | Explanation tree model + persistence hooks. | `PolicyExplanation`/`PolicyExplanationNode` produced from evaluation with structured nodes; persistence ready for follow-on wiring. |
|
||||
| POLICY-SPL-23-005 | DONE (2025-11-26) | Policy Guild, DevEx Guild | POLICY-SPL-23-004 | Migration tool to baseline SPL packs. | `SplMigrationTool` converts PolicyDocument to canonical SPL JSON; covered by `SplMigrationToolTests`. |
|
||||
| POLICY-SPL-24-001 | DONE (2025-11-26) | Policy Guild, Signals Guild | POLICY-SPL-23-005 | Extend SPL with reachability/exploitability predicates. | SPL schema/sample extended with reachability + exploitability, schema guard tests added. |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class EvidenceSummaryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Summarize_BuildsDeterministicSummary()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 11, 26, 0, 0, 0, TimeSpan.Zero));
|
||||
var service = new EvidenceSummaryService(timeProvider);
|
||||
|
||||
var request = new EvidenceSummaryRequest(
|
||||
EvidenceHash: "stub-evidence-hash",
|
||||
FilePath: "/etc/passwd",
|
||||
Digest: "sha256:123",
|
||||
IngestedAt: null,
|
||||
ConnectorId: "connector-1");
|
||||
|
||||
var response = service.Summarize(request);
|
||||
|
||||
Assert.Equal("stub-evidence-hash", response.EvidenceHash);
|
||||
Assert.Equal("info", response.Summary.Severity); // first byte bucketed to info
|
||||
Assert.Equal("/etc/passwd", response.Summary.Locator.FilePath);
|
||||
Assert.Equal("sha256:123", response.Summary.Locator.Digest);
|
||||
Assert.Equal("connector-1", response.Summary.Provenance.ConnectorId);
|
||||
Assert.Equal(new DateTimeOffset(2025, 12, 13, 05, 00, 11, TimeSpan.Zero), response.Summary.Provenance.IngestedAt);
|
||||
Assert.Contains("stub-eviden", response.Summary.Headline);
|
||||
Assert.Equal(
|
||||
new[] { "severity:info", "path:/etc/passwd", "connector:connector-1" },
|
||||
response.Summary.Signals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Summarize_RequiresEvidenceHash()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(DateTimeOffset.UnixEpoch);
|
||||
var service = new EvidenceSummaryService(timeProvider);
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
service.Summarize(new EvidenceSummaryRequest(string.Empty, null, null, null, null)));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyBundleServiceTests
|
||||
{
|
||||
private const string BaselineDsl = """
|
||||
policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
rule r1 { when true then status := "ok" because "baseline" }
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_SucceedsAndStoresBundle()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var request = new PolicyBundleRequest(new PolicyDslPayload("stella-dsl@1", BaselineDsl), signingKeyId: "test-key");
|
||||
|
||||
var response = await services.BundleService.CompileAndStoreAsync("pack-1", 1, request, CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.NotNull(response.Digest);
|
||||
Assert.StartsWith("sig:sha256:", response.Signature);
|
||||
Assert.True(response.SizeBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_FailsWithBadSyntax()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var request = new PolicyBundleRequest(new PolicyDslPayload("unknown", "policy bad"), signingKeyId: null);
|
||||
|
||||
var response = await services.BundleService.CompileAndStoreAsync("pack-1", 1, request, CancellationToken.None);
|
||||
|
||||
Assert.False(response.Success);
|
||||
Assert.Null(response.Digest);
|
||||
Assert.NotEmpty(response.Diagnostics);
|
||||
}
|
||||
|
||||
private static ServiceHarness CreateServices()
|
||||
{
|
||||
var compiler = new PolicyCompiler();
|
||||
var complexity = new PolicyComplexityAnalyzer();
|
||||
var options = Options.Create(new PolicyEngineOptions());
|
||||
var compilationService = new PolicyCompilationService(compiler, complexity, new StaticOptionsMonitor(options.Value), TimeProvider.System);
|
||||
var repo = new InMemoryPolicyPackRepository();
|
||||
return new ServiceHarness(
|
||||
new PolicyBundleService(compilationService, repo, TimeProvider.System));
|
||||
}
|
||||
|
||||
private sealed record ServiceHarness(PolicyBundleService BundleService);
|
||||
|
||||
private sealed class StaticOptionsMonitor : IOptionsMonitor<PolicyEngineOptions>
|
||||
{
|
||||
private readonly PolicyEngineOptions _value;
|
||||
|
||||
public StaticOptionsMonitor(PolicyEngineOptions value) => _value = value;
|
||||
|
||||
public PolicyEngineOptions CurrentValue => _value;
|
||||
|
||||
public PolicyEngineOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyEngineOptions, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyRuntimeEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsDeterministicDecisionAndCaches()
|
||||
{
|
||||
var repo = new InMemoryPolicyPackRepository();
|
||||
await repo.StoreBundleAsync(
|
||||
"pack-1",
|
||||
1,
|
||||
new PolicyBundleRecord(
|
||||
Digest: "sha256:abc",
|
||||
Signature: "sig:sha256:abc",
|
||||
Size: 4,
|
||||
CreatedAt: DateTimeOffset.UnixEpoch,
|
||||
Payload: new byte[] { 1, 2, 3, 4 }.ToImmutableArray()),
|
||||
CancellationToken.None);
|
||||
|
||||
var evaluator = new PolicyRuntimeEvaluator(repo);
|
||||
var request = new PolicyEvaluationRequest("pack-1", 1, "subject-a");
|
||||
|
||||
var first = await evaluator.EvaluateAsync(request, CancellationToken.None);
|
||||
var second = await evaluator.EvaluateAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(first.Decision, second.Decision);
|
||||
Assert.False(first.Cached);
|
||||
Assert.True(second.Cached);
|
||||
Assert.Equal("pack-1", first.PackId);
|
||||
Assert.Equal(1, first.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ThrowsWhenBundleMissing()
|
||||
{
|
||||
var evaluator = new PolicyRuntimeEvaluator(new InMemoryPolicyPackRepository());
|
||||
var request = new PolicyEvaluationRequest("pack-x", 1, "subject-a");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateAsync(request, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.RiskProfile.Canonicalization;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Tests;
|
||||
|
||||
public class RiskProfileCanonicalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Canonicalize_SortsSignalsAndOverrides()
|
||||
{
|
||||
const string input = """
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"id": "profile",
|
||||
"signals": [
|
||||
{"name": "kev", "source": "cisa", "type": "boolean"},
|
||||
{"name": "reachability", "type": "boolean", "source": "signals"}
|
||||
],
|
||||
"weights": {"reachability": 0.6, "kev": 0.4},
|
||||
"overrides": {
|
||||
"severity": [
|
||||
{"when": {"kev": true}, "set": "critical"},
|
||||
{"when": {"reachability": false}, "set": "low"}
|
||||
],
|
||||
"decisions": [
|
||||
{"when": {"reachability": false}, "action": "review"},
|
||||
{"when": {"kev": true}, "action": "deny"}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var canonical = RiskProfileCanonicalizer.CanonicalizeToString(input);
|
||||
|
||||
const string expected = "{\"id\":\"profile\",\"overrides\":{\"decisions\":[{\"action\":\"deny\",\"when\":{\"kev\":true}},{\"action\":\"review\",\"when\":{\"reachability\":false}}],\"severity\":[{\"set\":\"critical\",\"when\":{\"kev\":true}},{\"set\":\"low\",\"when\":{\"reachability\":false}}]},\"signals\":[{\"name\":\"kev\",\"source\":\"cisa\",\"type\":\"boolean\"},{\"name\":\"reachability\",\"source\":\"signals\",\"type\":\"boolean\"}],\"version\":\"1.0.0\",\"weights\":{\"kev\":0.4,\"reachability\":0.6}}";
|
||||
|
||||
Assert.Equal(expected, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_IgnoresOrderingNoise()
|
||||
{
|
||||
const string a = """
|
||||
{"id":"p","version":"1.0.0","signals":[{"name":"b","source":"x","type":"boolean"},{"name":"a","source":"y","type":"boolean"}],"weights":{"b":0.5,"a":0.5},"overrides":{"severity":[{"when":{"a":true},"set":"high"}],"decisions":[{"when":{"b":false},"action":"review"}]}}
|
||||
""";
|
||||
const string b = """
|
||||
{"version":"1.0.0","id":"p","weights":{"a":0.5,"b":0.5},"signals":[{"source":"y","name":"a","type":"boolean"},{"type":"boolean","name":"b","source":"x"}],"overrides":{"decisions":[{"action":"review","when":{"b":false}}],"severity":[{"set":"high","when":{"a":true}}]}}
|
||||
""";
|
||||
|
||||
var hashA = RiskProfileCanonicalizer.ComputeDigest(a);
|
||||
var hashB = RiskProfileCanonicalizer.ComputeDigest(b);
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_ReplacesSignalsAndWeights()
|
||||
{
|
||||
const string baseProfile = """
|
||||
{"id":"p","version":"1.0.0","signals":[{"name":"reachability","source":"signals","type":"boolean"}],"weights":{"reachability":0.7},"overrides":{"decisions":[{"when":{"reachability":false},"action":"review"}]}}
|
||||
""";
|
||||
|
||||
const string overlay = """
|
||||
{"signals":[{"name":"kev","source":"cisa","type":"boolean"}],"weights":{"kev":0.5},"overrides":{"decisions":[{"when":{"kev":true},"action":"deny"}]}}
|
||||
""";
|
||||
|
||||
var merged = RiskProfileCanonicalizer.Merge(baseProfile, overlay);
|
||||
using var doc = JsonDocument.Parse(merged);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal(2, root.GetProperty("signals").GetArrayLength());
|
||||
Assert.Equal(2, root.GetProperty("weights").EnumerateObject().Count());
|
||||
|
||||
var decisions = root.GetProperty("overrides").GetProperty("decisions").EnumerateArray().ToArray();
|
||||
Assert.Contains(decisions, d => d.GetProperty("action").GetString() == "deny");
|
||||
Assert.Contains(decisions, d => d.GetProperty("action").GetString() == "review");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Contracts;
|
||||
|
||||
public sealed record EntropyLayerRequest(
|
||||
[property: JsonPropertyName("layerDigest")] string LayerDigest,
|
||||
[property: JsonPropertyName("opaqueRatio")] double OpaqueRatio,
|
||||
[property: JsonPropertyName("opaqueBytes")] long OpaqueBytes,
|
||||
[property: JsonPropertyName("totalBytes")] long TotalBytes);
|
||||
|
||||
public sealed record EntropyIngestRequest(
|
||||
[property: JsonPropertyName("imageOpaqueRatio")] double ImageOpaqueRatio,
|
||||
[property: JsonPropertyName("layers")] IReadOnlyList<EntropyLayerRequest> Layers);
|
||||
@@ -7,6 +7,7 @@ public sealed record ScanStatusResponse(
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? FailureReason,
|
||||
EntropyStatusDto? Entropy,
|
||||
SurfacePointersDto? Surface,
|
||||
ReplayStatusDto? Replay);
|
||||
|
||||
@@ -14,6 +15,16 @@ public sealed record ScanStatusTarget(
|
||||
string? Reference,
|
||||
string? Digest);
|
||||
|
||||
public sealed record EntropyStatusDto(
|
||||
double ImageOpaqueRatio,
|
||||
IReadOnlyList<EntropyLayerStatusDto> Layers);
|
||||
|
||||
public sealed record EntropyLayerStatusDto(
|
||||
string LayerDigest,
|
||||
double OpaqueRatio,
|
||||
long OpaqueBytes,
|
||||
long TotalBytes);
|
||||
|
||||
public sealed record ReplayStatusDto(
|
||||
string ManifestHash,
|
||||
IReadOnlyList<ReplayBundleStatusDto> Bundles);
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed record ScanSnapshot(
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? FailureReason,
|
||||
EntropySnapshot? Entropy,
|
||||
ReplayArtifacts? Replay);
|
||||
|
||||
public sealed record ReplayArtifacts(
|
||||
@@ -18,3 +19,13 @@ public sealed record ReplayBundleSummary(
|
||||
string Digest,
|
||||
string CasUri,
|
||||
long SizeBytes);
|
||||
|
||||
public sealed record EntropySnapshot(
|
||||
double ImageOpaqueRatio,
|
||||
IReadOnlyList<EntropyLayerSnapshot> Layers);
|
||||
|
||||
public sealed record EntropyLayerSnapshot(
|
||||
string LayerDigest,
|
||||
double OpaqueRatio,
|
||||
long OpaqueBytes,
|
||||
long TotalBytes);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.IO.Pipelines;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
@@ -45,6 +46,13 @@ internal static class ScanEndpoints
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansRead);
|
||||
|
||||
scans.MapPost("/{scanId}/entropy", HandleAttachEntropyAsync)
|
||||
.WithName("scanner.scans.entropy")
|
||||
.Produces(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.RequireAuthorization(ScannerPolicies.ScansWrite);
|
||||
|
||||
scans.MapGet("/{scanId}/events", HandleProgressStreamAsync)
|
||||
.WithName("scanner.scans.events")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
@@ -203,12 +211,79 @@ internal static class ScanEndpoints
|
||||
CreatedAt: snapshot.CreatedAt,
|
||||
UpdatedAt: snapshot.UpdatedAt,
|
||||
FailureReason: snapshot.FailureReason,
|
||||
Entropy: snapshot.Entropy is null
|
||||
? null
|
||||
: new EntropyStatusDto(
|
||||
snapshot.Entropy.ImageOpaqueRatio,
|
||||
snapshot.Entropy.Layers
|
||||
.Select(l => new EntropyLayerStatusDto(l.LayerDigest, l.OpaqueRatio, l.OpaqueBytes, l.TotalBytes))
|
||||
.ToArray()),
|
||||
Surface: surfacePointers,
|
||||
Replay: snapshot.Replay is null ? null : MapReplay(snapshot.Replay));
|
||||
|
||||
return Json(response, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAttachEntropyAsync(
|
||||
string scanId,
|
||||
EntropyIngestRequest request,
|
||||
IScanCoordinator coordinator,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(coordinator);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (!ScanId.TryParse(scanId, out var parsed))
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Invalid scan identifier",
|
||||
StatusCodes.Status400BadRequest,
|
||||
detail: "Scan identifier is required.");
|
||||
}
|
||||
|
||||
if (request.Layers is null || request.Layers.Count == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Entropy layers are required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var layers = request.Layers
|
||||
.Where(l => !string.IsNullOrWhiteSpace(l.LayerDigest))
|
||||
.Select(l => new EntropyLayerSnapshot(
|
||||
l.LayerDigest.Trim(),
|
||||
l.OpaqueRatio,
|
||||
l.OpaqueBytes,
|
||||
l.TotalBytes))
|
||||
.ToArray();
|
||||
|
||||
if (layers.Length == 0)
|
||||
{
|
||||
return ProblemResultFactory.Create(
|
||||
context,
|
||||
ProblemTypes.Validation,
|
||||
"Entropy layers are required",
|
||||
StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var snapshot = new EntropySnapshot(
|
||||
request.ImageOpaqueRatio,
|
||||
layers);
|
||||
|
||||
var attached = await coordinator.AttachEntropyAsync(parsed, snapshot, cancellationToken).ConfigureAwait(false);
|
||||
if (!attached)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Accepted();
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleProgressStreamAsync(
|
||||
string scanId,
|
||||
string? format,
|
||||
|
||||
@@ -11,4 +11,6 @@ public interface IScanCoordinator
|
||||
ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> AttachReplayAsync(ScanId scanId, ReplayArtifacts replay, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<bool> AttachEntropyAsync(ScanId scanId, EntropySnapshot entropy, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -45,15 +45,16 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
scanId,
|
||||
normalizedTarget,
|
||||
ScanStatus.Pending,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
null,
|
||||
null,
|
||||
null)),
|
||||
(_, existing) =>
|
||||
{
|
||||
if (submission.Force)
|
||||
{
|
||||
var snapshot = existing.Snapshot with
|
||||
(_, existing) =>
|
||||
{
|
||||
if (submission.Force)
|
||||
{
|
||||
var snapshot = existing.Snapshot with
|
||||
{
|
||||
Status = ScanStatus.Pending,
|
||||
UpdatedAt = now,
|
||||
@@ -134,6 +135,30 @@ public sealed class InMemoryScanCoordinator : IScanCoordinator
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public ValueTask<bool> AttachEntropyAsync(ScanId scanId, EntropySnapshot entropy, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entropy);
|
||||
|
||||
if (!scans.TryGetValue(scanId.Value, out var existing))
|
||||
{
|
||||
return ValueTask.FromResult(false);
|
||||
}
|
||||
|
||||
var updated = existing.Snapshot with
|
||||
{
|
||||
Entropy = entropy,
|
||||
UpdatedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
scans[scanId.Value] = new ScanEntry(updated);
|
||||
progressPublisher.Publish(scanId, updated.Status.ToString(), "entropy-attached", new Dictionary<string, object?>
|
||||
{
|
||||
["entropy.imageOpaqueRatio"] = entropy.ImageOpaqueRatio,
|
||||
["entropy.layers"] = entropy.Layers.Count
|
||||
});
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
private void IndexTarget(string scanId, ScanTarget target)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(target.Digest))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Determinism;
|
||||
|
||||
/// <summary>
|
||||
/// Captures determinism-related toggles for the worker runtime.
|
||||
/// </summary>
|
||||
public sealed class DeterminismContext
|
||||
{
|
||||
public DeterminismContext(bool fixedClock, DateTimeOffset fixedInstantUtc, int? rngSeed, bool filterLogs)
|
||||
{
|
||||
FixedClock = fixedClock;
|
||||
FixedInstantUtc = fixedInstantUtc.ToUniversalTime();
|
||||
RngSeed = rngSeed;
|
||||
FilterLogs = filterLogs;
|
||||
}
|
||||
|
||||
public bool FixedClock { get; }
|
||||
|
||||
public DateTimeOffset FixedInstantUtc { get; }
|
||||
|
||||
public int? RngSeed { get; }
|
||||
|
||||
public bool FilterLogs { get; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Determinism;
|
||||
|
||||
public interface IDeterministicRandomProvider
|
||||
{
|
||||
Random Create();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides seeded <see cref="Random"/> instances when a seed is configured, otherwise defaults to a thread-safe system random.
|
||||
/// </summary>
|
||||
public sealed class DeterministicRandomProvider : IDeterministicRandomProvider
|
||||
{
|
||||
private readonly int? _seed;
|
||||
|
||||
public DeterministicRandomProvider(int? seed)
|
||||
{
|
||||
_seed = seed;
|
||||
}
|
||||
|
||||
public Random Create()
|
||||
{
|
||||
return _seed.HasValue ? new Random(_seed.Value) : Random.Shared;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Determinism;
|
||||
|
||||
/// <summary>
|
||||
/// Time provider that always returns a fixed instant, used to enforce deterministic timestamps.
|
||||
/// </summary>
|
||||
public sealed class DeterministicTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedUtc;
|
||||
|
||||
public DeterministicTimeProvider(DateTimeOffset fixedUtc)
|
||||
{
|
||||
_fixedUtc = fixedUtc.ToUniversalTime();
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedUtc;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ public sealed class ScannerWorkerOptions
|
||||
public AnalyzerOptions Analyzers { get; } = new();
|
||||
|
||||
public StellaOpsCryptoOptions Crypto { get; } = new();
|
||||
|
||||
public DeterminismOptions Determinism { get; } = new();
|
||||
|
||||
public sealed class QueueOptions
|
||||
{
|
||||
@@ -177,4 +179,27 @@ public sealed class ScannerWorkerOptions
|
||||
|
||||
public string EntryTraceProcRootMetadataKey { get; set; } = ScanMetadataKeys.RuntimeProcRoot;
|
||||
}
|
||||
|
||||
public sealed class DeterminismOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// If true, the worker uses a fixed clock to ensure deterministic timestamps.
|
||||
/// </summary>
|
||||
public bool FixedClock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed UTC timestamp to emit when FixedClock is enabled. Defaults to Unix epoch.
|
||||
/// </summary>
|
||||
public DateTimeOffset FixedInstantUtc { get; set; } = DateTimeOffset.UnixEpoch;
|
||||
|
||||
/// <summary>
|
||||
/// Optional seed for RNG-based components when determinism is required.
|
||||
/// </summary>
|
||||
public int? RngSeed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If true, trims noisy log fields (duration, PIDs) to stable placeholders.
|
||||
/// </summary>
|
||||
public bool FilterLogs { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using StellaOps.Scanner.Worker.Determinism;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
internal sealed class DeterministicRandomService
|
||||
{
|
||||
private readonly IDeterministicRandomProvider _provider;
|
||||
|
||||
public DeterministicRandomService(IDeterministicRandomProvider provider)
|
||||
{
|
||||
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
|
||||
}
|
||||
|
||||
public Random Create() => _provider.Create();
|
||||
}
|
||||
@@ -5,9 +5,8 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Core.Entropy;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Worker.Utilities;
|
||||
using StellaOps.Scanner.Core.Entropy;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Processing.Entropy;
|
||||
|
||||
@@ -26,7 +25,7 @@ public sealed class EntropyStageExecutor : IScanStageExecutor
|
||||
_reportBuilder = new EntropyReportBuilder();
|
||||
}
|
||||
|
||||
public string StageName => ScanStageNames.EmitReports;
|
||||
public string StageName => ScanStageNames.Entropy;
|
||||
|
||||
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -68,7 +67,7 @@ public sealed class EntropyStageExecutor : IScanStageExecutor
|
||||
return;
|
||||
}
|
||||
|
||||
var layerDigest = context.Lease.LayerDigest ?? string.Empty;
|
||||
var layerDigest = ResolveLayerDigest(context.Lease.Metadata);
|
||||
var layerSize = files.Sum(f => f.SizeBytes);
|
||||
var imageOpaqueBytes = reports.Sum(r => r.OpaqueBytes);
|
||||
var imageTotalBytes = files.Sum(f => f.SizeBytes);
|
||||
@@ -81,7 +80,7 @@ public sealed class EntropyStageExecutor : IScanStageExecutor
|
||||
imageTotalBytes);
|
||||
|
||||
var entropyReport = new EntropyReport(
|
||||
ImageDigest: context.Lease.ImageDigest ?? string.Empty,
|
||||
ImageDigest: ResolveImageDigest(context.Lease.Metadata),
|
||||
LayerDigest: layerDigest,
|
||||
Files: reports,
|
||||
ImageOpaqueRatio: imageRatio);
|
||||
@@ -138,4 +137,49 @@ public sealed class EntropyStageExecutor : IScanStageExecutor
|
||||
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static string ResolveLayerDigest(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (metadata.TryGetValue("layerDigest", out var digest) && !string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest.Trim();
|
||||
}
|
||||
|
||||
if (metadata.TryGetValue("layer.digest", out digest) && !string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest.Trim();
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string ResolveImageDigest(IReadOnlyDictionary<string, string> metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (metadata.TryGetValue("image.digest", out var digest) && !string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest.Trim();
|
||||
}
|
||||
|
||||
if (metadata.TryGetValue("imageDigest", out digest) && !string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest.Trim();
|
||||
}
|
||||
|
||||
if (metadata.TryGetValue("scanner.image.digest", out digest) && !string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return digest.Trim();
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,25 @@ namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class LeaseHeartbeatService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<ScannerWorkerOptions> _options;
|
||||
private readonly IDelayScheduler _delayScheduler;
|
||||
private readonly ILogger<LeaseHeartbeatService> _logger;
|
||||
|
||||
public LeaseHeartbeatService(TimeProvider timeProvider, IDelayScheduler delayScheduler, IOptionsMonitor<ScannerWorkerOptions> options, ILogger<LeaseHeartbeatService> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_delayScheduler = delayScheduler ?? throw new ArgumentNullException(nameof(delayScheduler));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptionsMonitor<ScannerWorkerOptions> _options;
|
||||
private readonly IDelayScheduler _delayScheduler;
|
||||
private readonly IDeterministicRandomProvider _randomProvider;
|
||||
private readonly ILogger<LeaseHeartbeatService> _logger;
|
||||
|
||||
public LeaseHeartbeatService(
|
||||
TimeProvider timeProvider,
|
||||
IDelayScheduler delayScheduler,
|
||||
IOptionsMonitor<ScannerWorkerOptions> options,
|
||||
IDeterministicRandomProvider randomProvider,
|
||||
ILogger<LeaseHeartbeatService> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_delayScheduler = delayScheduler ?? throw new ArgumentNullException(nameof(delayScheduler));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_randomProvider = randomProvider ?? throw new ArgumentNullException(nameof(randomProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task RunAsync(IScanJobLease lease, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -32,7 +39,7 @@ public sealed class LeaseHeartbeatService
|
||||
{
|
||||
var options = _options.CurrentValue;
|
||||
var interval = ComputeInterval(options, lease);
|
||||
var delay = ApplyJitter(interval, options.Queue);
|
||||
var delay = ApplyJitter(interval, options.Queue, _randomProvider);
|
||||
try
|
||||
{
|
||||
await _delayScheduler.DelayAsync(delay, cancellationToken).ConfigureAwait(false);
|
||||
@@ -77,14 +84,14 @@ public sealed class LeaseHeartbeatService
|
||||
return recommended;
|
||||
}
|
||||
|
||||
private static TimeSpan ApplyJitter(TimeSpan duration, ScannerWorkerOptions.QueueOptions queueOptions)
|
||||
private static TimeSpan ApplyJitter(TimeSpan duration, ScannerWorkerOptions.QueueOptions queueOptions, IDeterministicRandomProvider randomProvider)
|
||||
{
|
||||
if (queueOptions.MaxHeartbeatJitterMilliseconds <= 0)
|
||||
{
|
||||
return duration;
|
||||
}
|
||||
|
||||
var offsetMs = Random.Shared.NextDouble() * queueOptions.MaxHeartbeatJitterMilliseconds;
|
||||
var offsetMs = randomProvider.Create().NextDouble() * queueOptions.MaxHeartbeatJitterMilliseconds;
|
||||
var adjusted = duration - TimeSpan.FromMilliseconds(offsetMs);
|
||||
if (adjusted < queueOptions.MinHeartbeatInterval)
|
||||
{
|
||||
@@ -97,10 +104,10 @@ public sealed class LeaseHeartbeatService
|
||||
private async Task<bool> TryRenewAsync(ScannerWorkerOptions options, IScanJobLease lease, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await lease.RenewAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
{
|
||||
await lease.RenewAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -6,13 +6,15 @@ namespace StellaOps.Scanner.Worker.Processing;
|
||||
|
||||
public sealed class PollDelayStrategy
|
||||
{
|
||||
private readonly ScannerWorkerOptions.PollingOptions _options;
|
||||
private TimeSpan _currentDelay;
|
||||
|
||||
public PollDelayStrategy(ScannerWorkerOptions.PollingOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
private readonly ScannerWorkerOptions.PollingOptions _options;
|
||||
private readonly DeterministicRandomService _randomService;
|
||||
private TimeSpan _currentDelay;
|
||||
|
||||
public PollDelayStrategy(ScannerWorkerOptions.PollingOptions options, DeterministicRandomService randomService)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_randomService = randomService ?? throw new ArgumentNullException(nameof(randomService));
|
||||
}
|
||||
|
||||
public TimeSpan NextDelay()
|
||||
{
|
||||
@@ -42,8 +44,9 @@ public sealed class PollDelayStrategy
|
||||
return duration;
|
||||
}
|
||||
|
||||
var offset = (Random.Shared.NextDouble() * 2.0 - 1.0) * maxOffset;
|
||||
var adjustedMs = Math.Max(0, duration.TotalMilliseconds + offset);
|
||||
return TimeSpan.FromMilliseconds(adjustedMs);
|
||||
}
|
||||
}
|
||||
var rng = _randomService.Create();
|
||||
var offset = (rng.NextDouble() * 2.0 - 1.0) * maxOffset;
|
||||
var adjustedMs = Math.Max(0, duration.TotalMilliseconds + offset);
|
||||
return TimeSpan.FromMilliseconds(adjustedMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ using StellaOps.Scanner.Worker.Diagnostics;
|
||||
using StellaOps.Scanner.Worker.Hosting;
|
||||
using StellaOps.Scanner.Worker.Options;
|
||||
using StellaOps.Scanner.Worker.Processing;
|
||||
using StellaOps.Scanner.Worker.Processing.Entropy;
|
||||
using StellaOps.Scanner.Worker.Determinism;
|
||||
using StellaOps.Scanner.Worker.Processing.Surface;
|
||||
using StellaOps.Scanner.Storage.Extensions;
|
||||
using StellaOps.Scanner.Storage;
|
||||
@@ -34,7 +36,23 @@ builder.Services.AddOptions<ScannerWorkerOptions>()
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton<IValidateOptions<ScannerWorkerOptions>, ScannerWorkerOptionsValidator>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
var workerOptions = builder.Configuration.GetSection(ScannerWorkerOptions.SectionName).Get<ScannerWorkerOptions>() ?? new ScannerWorkerOptions();
|
||||
|
||||
if (workerOptions.Determinism.FixedClock)
|
||||
{
|
||||
builder.Services.AddSingleton<TimeProvider>(_ => new DeterministicTimeProvider(workerOptions.Determinism.FixedInstantUtc));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton(new DeterminismContext(
|
||||
workerOptions.Determinism.FixedClock,
|
||||
workerOptions.Determinism.FixedInstantUtc,
|
||||
workerOptions.Determinism.RngSeed,
|
||||
workerOptions.Determinism.FilterLogs));
|
||||
builder.Services.AddSingleton<IDeterministicRandomProvider>(_ => new DeterministicRandomProvider(workerOptions.Determinism.RngSeed));
|
||||
builder.Services.AddScannerCache(builder.Configuration);
|
||||
builder.Services.AddSurfaceEnvironment(options =>
|
||||
{
|
||||
@@ -85,12 +103,11 @@ builder.Services.AddSingleton<IScanStageExecutor, RegistrySecretStageExecutor>()
|
||||
builder.Services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityBuildStageExecutor>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityPublishStageExecutor>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, Entropy.EntropyStageExecutor>();
|
||||
builder.Services.AddSingleton<IScanStageExecutor, EntropyStageExecutor>();
|
||||
|
||||
builder.Services.AddSingleton<ScannerWorkerHostedService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<ScannerWorkerHostedService>());
|
||||
|
||||
var workerOptions = builder.Configuration.GetSection(ScannerWorkerOptions.SectionName).Get<ScannerWorkerOptions>() ?? new ScannerWorkerOptions();
|
||||
builder.Services.AddStellaOpsCrypto(workerOptions.Crypto);
|
||||
|
||||
builder.Services.Configure<HostOptions>(options =>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal representation of a file discovered during analyzer stages.
|
||||
/// </summary>
|
||||
public sealed record ScanFileEntry(
|
||||
string Path,
|
||||
long SizeBytes,
|
||||
string Kind,
|
||||
IReadOnlyDictionary<string, string>? Metadata = null);
|
||||
@@ -45,7 +45,7 @@ public sealed class EntropyReportBuilder
|
||||
.ToList();
|
||||
|
||||
var opaqueBytes = windows
|
||||
.Where(w => w.Entropy >= _opaqueThreshold)
|
||||
.Where(w => w.EntropyBits >= _opaqueThreshold)
|
||||
.Sum(w => (long)w.Length);
|
||||
|
||||
var size = data.Length;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed partial class ScansEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EntropyEndpoint_AttachesSnapshot_AndSurfacesInStatus()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory(cfg =>
|
||||
{
|
||||
cfg["scanner:authority:enabled"] = "false";
|
||||
cfg["scanner:authority:allowAnonymousFallback"] = "true";
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var submitResponse = await client.PostAsJsonAsync("/api/v1/scans", new
|
||||
{
|
||||
image = new { digest = "sha256:image-demo" }
|
||||
});
|
||||
submitResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var submit = await submitResponse.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
Assert.NotNull(submit);
|
||||
|
||||
var entropyPayload = new EntropyIngestRequest(
|
||||
ImageOpaqueRatio: 0.42,
|
||||
Layers: new[]
|
||||
{
|
||||
new EntropyLayerRequest("sha256:layer-demo", 0.35, 3500, 10_000)
|
||||
});
|
||||
|
||||
var attachResponse = await client.PostAsJsonAsync($"/api/v1/scans/{submit!.ScanId}/entropy", entropyPayload);
|
||||
Assert.Equal(HttpStatusCode.Accepted, attachResponse.StatusCode);
|
||||
|
||||
var status = await client.GetFromJsonAsync<ScanStatusResponse>($"/api/v1/scans/{submit.ScanId}");
|
||||
Assert.NotNull(status);
|
||||
Assert.NotNull(status!.Entropy);
|
||||
Assert.Equal(0.42, status.Entropy!.ImageOpaqueRatio, 3);
|
||||
Assert.Single(status.Entropy!.Layers);
|
||||
var layer = status.Entropy!.Layers[0];
|
||||
Assert.Equal("sha256:layer-demo", layer.LayerDigest);
|
||||
Assert.Equal(0.35, layer.OpaqueRatio, 3);
|
||||
Assert.Equal(3500, layer.OpaqueBytes);
|
||||
Assert.Equal(10_000, layer.TotalBytes);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,12 @@ public sealed partial class ScansEndpointsTests
|
||||
|
||||
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
|
||||
=> _inner.TryFindByTargetAsync(reference, digest, cancellationToken);
|
||||
|
||||
public ValueTask<bool> AttachReplayAsync(ScanId scanId, ReplayArtifacts replay, CancellationToken cancellationToken)
|
||||
=> _inner.AttachReplayAsync(scanId, replay, cancellationToken);
|
||||
|
||||
public ValueTask<bool> AttachEntropyAsync(ScanId scanId, EntropySnapshot entropy, CancellationToken cancellationToken)
|
||||
=> _inner.AttachEntropyAsync(scanId, entropy, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class StubEntryTraceResultStore : IEntryTraceResultStore
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using StellaOps.Scanner.Worker.Determinism;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Worker.Tests.Determinism;
|
||||
|
||||
public class DeterministicTimeProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetUtcNow_ReturnsFixedInstant()
|
||||
{
|
||||
var fixedInstant = new DateTimeOffset(2024, 01, 01, 12, 0, 0, TimeSpan.Zero);
|
||||
var provider = new DeterministicTimeProvider(fixedInstant);
|
||||
|
||||
Assert.Equal(fixedInstant, provider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeterministicRandomProvider_ReturnsStableSequence_WhenSeeded()
|
||||
{
|
||||
var provider = new DeterministicRandomProvider(1234);
|
||||
var rng1 = provider.Create();
|
||||
var rng2 = provider.Create();
|
||||
|
||||
var seq1 = new[] { rng1.Next(), rng1.Next(), rng1.Next() };
|
||||
var seq2 = new[] { rng2.Next(), rng2.Next(), rng2.Next() };
|
||||
|
||||
Assert.Equal(seq1, seq2);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ public class EntropyStageExecutorTests
|
||||
|
||||
var fileEntries = new List<ScanFileEntry>
|
||||
{
|
||||
new ScanFileEntry(tmp, sizeBytes: bytes.LongLength, kind: "blob", metadata: new Dictionary<string, string>())
|
||||
new ScanFileEntry(tmp, SizeBytes: bytes.LongLength, Kind: "blob", Metadata: new Dictionary<string, string>())
|
||||
};
|
||||
|
||||
var lease = new StubLease("job-1", "scan-1", imageDigest: "sha256:test", layerDigest: "sha256:layer");
|
||||
@@ -55,13 +55,25 @@ public class EntropyStageExecutorTests
|
||||
{
|
||||
JobId = jobId;
|
||||
ScanId = scanId;
|
||||
ImageDigest = imageDigest;
|
||||
LayerDigest = layerDigest;
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["image.digest"] = imageDigest,
|
||||
["layerDigest"] = layerDigest
|
||||
};
|
||||
}
|
||||
|
||||
public string JobId { get; }
|
||||
public string ScanId { get; }
|
||||
public string? ImageDigest { get; }
|
||||
public string? LayerDigest { get; }
|
||||
public int Attempt => 1;
|
||||
public DateTimeOffset EnqueuedAtUtc => DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset LeasedAtUtc => DateTimeOffset.UtcNow;
|
||||
public TimeSpan LeaseDuration => TimeSpan.FromMinutes(5);
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; }
|
||||
|
||||
public ValueTask RenewAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
public ValueTask CompleteAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
public ValueTask AbandonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
public ValueTask PoisonAsync(string reason, CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Analyzers.Lang;
|
||||
using StellaOps.Scanner.Analyzers.Lang.Ruby;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.Core.Entropy;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Surface.FS;
|
||||
@@ -120,6 +121,62 @@ public sealed class SurfaceManifestStageExecutorTests
|
||||
Assert.Contains(payloadMetrics, m => Equals("layer.fragments", m["surface.kind"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IncludesEntropyPayloads_WhenPresent()
|
||||
{
|
||||
var metrics = new ScannerWorkerMetrics();
|
||||
var publisher = new TestSurfaceManifestPublisher("tenant-a");
|
||||
var cache = new RecordingSurfaceCache();
|
||||
var environment = new TestSurfaceEnvironment("tenant-a");
|
||||
var hash = CreateCryptoHash();
|
||||
|
||||
var executor = new SurfaceManifestStageExecutor(
|
||||
publisher,
|
||||
cache,
|
||||
environment,
|
||||
metrics,
|
||||
NullLogger<SurfaceManifestStageExecutor>.Instance,
|
||||
hash,
|
||||
new NullRubyPackageInventoryStore());
|
||||
|
||||
var context = CreateContext();
|
||||
|
||||
var entropyReport = new EntropyReport(
|
||||
ImageDigest: "sha256:image",
|
||||
LayerDigest: "sha256:layer",
|
||||
Files: new[]
|
||||
{
|
||||
new EntropyFileReport(
|
||||
Path: "/bin/app",
|
||||
Size: 1024 * 32,
|
||||
OpaqueBytes: 1024 * 8,
|
||||
OpaqueRatio: 0.25,
|
||||
Flags: Array.Empty<string>(),
|
||||
Windows: Array.Empty<EntropyFileWindow>())
|
||||
},
|
||||
ImageOpaqueRatio: 0.2);
|
||||
|
||||
var entropySummary = new EntropyLayerSummary(
|
||||
LayerDigest: "sha256:layer",
|
||||
OpaqueBytes: 1024 * 8,
|
||||
TotalBytes: 1024 * 32,
|
||||
OpaqueRatio: 0.25,
|
||||
Indicators: Array.Empty<string>());
|
||||
|
||||
context.Analysis.Set(ScanAnalysisKeys.EntropyReport, entropyReport);
|
||||
context.Analysis.Set(ScanAnalysisKeys.EntropyLayerSummary, entropySummary);
|
||||
|
||||
await executor.ExecuteAsync(context, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, publisher.PublishCalls);
|
||||
Assert.NotNull(publisher.LastRequest);
|
||||
Assert.Contains(publisher.LastRequest!.Payloads, p => p.Kind == "entropy.report");
|
||||
Assert.Contains(publisher.LastRequest!.Payloads, p => p.Kind == "entropy.layer-summary");
|
||||
|
||||
// Two payloads + manifest persisted to cache.
|
||||
Assert.Equal(3, cache.Entries.Count);
|
||||
}
|
||||
|
||||
private static ScanJobContext CreateContext()
|
||||
{
|
||||
var lease = new FakeJobLease();
|
||||
|
||||
@@ -12,8 +12,17 @@ if [ ! -f "$jar" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# If java is missing, try vendored JDK in tools/
|
||||
if ! command -v java >/dev/null 2>&1; then
|
||||
echo "SKIP: java not on PATH; set JAVA_HOME or install JDK to run this smoke." >&2
|
||||
vendor_jdk="$root_dir/tools/jdk-21.0.1+12"
|
||||
if [ -d "$vendor_jdk/bin" ]; then
|
||||
export JAVA_HOME="$vendor_jdk"
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v java >/dev/null 2>&1; then
|
||||
echo "SKIP: java not on PATH and vendored JDK not found; set JAVA_HOME or install JDK to run this smoke." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Observer.Backend;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
using StellaOps.Zastava.Observer.Runtime;
|
||||
|
||||
Reference in New Issue
Block a user