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

This commit is contained in:
StellaOps Bot
2025-11-26 20:23:28 +02:00
parent 4831c7fcb0
commit d63af51f84
139 changed files with 8010 additions and 2795 deletions

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View 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;
}

View File

@@ -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);
});
});
}
}

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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";
}

View 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 { }

View File

@@ -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);

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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
};
}

View File

@@ -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 |

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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. |

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
{

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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}";
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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()}";
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View 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

View File

@@ -24,6 +24,10 @@
"epss": 0.42,
"kev": false
},
"weighting": {
"reachability": 1.0,
"exploitability": 0.9
},
"conditions": [
{
"field": "request.tenant",

View File

@@ -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": {

View File

@@ -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. |

View File

@@ -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;
}
}

View File

@@ -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() { }
}
}
}

View File

@@ -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));
}
}

View File

@@ -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");
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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))

View File

@@ -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; }
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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; }
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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 =>

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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;