feat: add PolicyPackSelectorComponent with tests and integration

- Implemented PolicyPackSelectorComponent for selecting policy packs.
- Added unit tests for component behavior, including API success and error handling.
- Introduced monaco-workers type declarations for editor workers.
- Created acceptance tests for guardrails with stubs for AT1–AT10.
- Established SCA Failure Catalogue Fixtures for regression testing.
- Developed plugin determinism harness with stubs for PL1–PL10.
- Added scripts for evidence upload and verification processes.
This commit is contained in:
StellaOps Bot
2025-12-05 21:24:34 +02:00
parent 347c88342c
commit 18d87c64c5
220 changed files with 7700 additions and 518 deletions

View File

@@ -40,7 +40,7 @@ internal static class TaskPackPlanHasher
var json = CanonicalJson.Serialize(canonical);
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
return ConvertToHex(hashBytes);
return $"sha256:{ConvertToHex(hashBytes)}";
}
private static string ConvertToHex(byte[] hashBytes)

View File

@@ -22,16 +22,17 @@ public sealed class TaskPackPlanner
this.egressPolicy = egressPolicy;
}
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>();
var validation = validator.Validate(manifest);
if (!validation.IsValid)
{
foreach (var error in validation.Errors)
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>();
ValidateSandboxAndSlo(manifest, errors);
var validation = validator.Validate(manifest);
if (!validation.IsValid)
{
foreach (var error in validation.Errors)
{
errors.Add(new TaskPackPlanError(error.Path, error.Message));
}
@@ -106,10 +107,70 @@ public sealed class TaskPackPlanner
return new TaskPackPlanResult(plan, ImmutableArray<TaskPackPlanError>.Empty);
}
private static void ValidateSandboxAndSlo(TaskPackManifest manifest, ImmutableArray<TaskPackPlanError>.Builder errors)
{
// TP6: sandbox quotas must be present.
var sandbox = manifest.Spec.Sandbox;
if (sandbox is null)
{
errors.Add(new TaskPackPlanError("spec.sandbox", "Sandbox settings are required (mode, egressAllowlist, CPU/memory, quotaSeconds)."));
}
else
{
if (string.IsNullOrWhiteSpace(sandbox.Mode))
{
errors.Add(new TaskPackPlanError("spec.sandbox.mode", "Sandbox mode is required (sealed or restricted)."));
}
if (sandbox.EgressAllowlist is null)
{
errors.Add(new TaskPackPlanError("spec.sandbox.egressAllowlist", "Egress allowlist must be declared (empty list allowed)."));
}
if (sandbox.CpuLimitMillicores <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.cpuLimitMillicores", "CPU limit must be > 0."));
}
if (sandbox.MemoryLimitMiB <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.memoryLimitMiB", "Memory limit must be > 0."));
}
if (sandbox.QuotaSeconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.sandbox.quotaSeconds", "quotaSeconds must be > 0."));
}
}
// TP9: SLOs must be declared and positive.
var slo = manifest.Spec.Slo;
if (slo is null)
{
errors.Add(new TaskPackPlanError("spec.slo", "SLO section is required (runP95Seconds, approvalP95Seconds, maxQueueDepth)."));
return;
}
if (slo.RunP95Seconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.runP95Seconds", "runP95Seconds must be > 0."));
}
if (slo.ApprovalP95Seconds <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.approvalP95Seconds", "approvalP95Seconds must be > 0."));
}
if (slo.MaxQueueDepth <= 0)
{
errors.Add(new TaskPackPlanError("spec.slo.maxQueueDepth", "maxQueueDepth must be > 0."));
}
}
private Dictionary<string, JsonNode?> MaterializeInputs(
IReadOnlyList<TaskPackInput>? definitions,
IDictionary<string, JsonNode?>? providedInputs,
ImmutableArray<TaskPackPlanError>.Builder errors)
IDictionary<string, JsonNode?>? providedInputs,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
var effective = new Dictionary<string, JsonNode?>(StringComparer.Ordinal);

View File

@@ -54,11 +54,11 @@ public sealed class TaskPackMaintainer
public string? Email { get; init; }
}
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
[JsonPropertyName("secrets")]
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
@@ -72,11 +72,17 @@ public sealed class TaskPackSpec
public IReadOnlyList<TaskPackOutput>? Outputs { get; init; }
[JsonPropertyName("success")]
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
}
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
[JsonPropertyName("sandbox")]
public TaskPackSandbox? Sandbox { get; init; }
[JsonPropertyName("slo")]
public TaskPackSlo? Slo { get; init; }
}
public sealed class TaskPackInput
{
@@ -255,11 +261,41 @@ public sealed class TaskPackFailure
public TaskPackRetryPolicy? Retries { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackSandbox
{
[JsonPropertyName("mode")]
public string? Mode { get; init; }
[JsonPropertyName("egressAllowlist")]
public IReadOnlyList<string>? EgressAllowlist { get; init; }
[JsonPropertyName("cpuLimitMillicores")]
public int CpuLimitMillicores { get; init; }
[JsonPropertyName("memoryLimitMiB")]
public int MemoryLimitMiB { get; init; }
[JsonPropertyName("quotaSeconds")]
public int QuotaSeconds { get; init; }
}
public sealed class TaskPackSlo
{
[JsonPropertyName("runP95Seconds")]
public int RunP95Seconds { get; init; }
[JsonPropertyName("approvalP95Seconds")]
public int ApprovalP95Seconds { get; init; }
[JsonPropertyName("maxQueueDepth")]
public int MaxQueueDepth { get; init; }
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.RegularExpressions;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
@@ -34,6 +35,14 @@ public sealed class PackRunApprovalDecisionService
var runId = request.RunId.Trim();
var approvalId = request.ApprovalId.Trim();
if (!IsSha256Digest(request.PlanHash))
{
_logger.LogWarning(
"Approval decision for run {RunId} rejected plan hash format invalid (expected sha256:<64-hex>).",
runId);
return PackRunApprovalDecisionResult.PlanHashMismatch;
}
var state = await _stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
@@ -101,6 +110,14 @@ public sealed class PackRunApprovalDecisionService
return PackRunApprovalDecisionResult.Applied;
}
private static bool IsSha256Digest(string value)
=> !string.IsNullOrWhiteSpace(value)
&& Sha256Pattern.IsMatch(value);
private static readonly Regex Sha256Pattern = new(
"^sha256:[0-9a-f]{64}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
}
public sealed record PackRunApprovalDecisionRequest(

View File

@@ -62,7 +62,7 @@ public sealed class PackRunApprovalDecisionServiceTests
NullLogger<PackRunApprovalDecisionService>.Instance);
var result = await service.ApplyAsync(
new PackRunApprovalDecisionRequest("missing", "approval", "hash", PackRunApprovalDecisionType.Approved, "actor", null),
new PackRunApprovalDecisionRequest("missing", "approval", "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", PackRunApprovalDecisionType.Approved, "actor", null),
CancellationToken.None);
Assert.Equal("not_found", result.Status);
@@ -107,6 +107,44 @@ public sealed class PackRunApprovalDecisionServiceTests
Assert.False(scheduler.ScheduledContexts.Any());
}
[Fact]
public async Task ApplyAsync_ReturnsPlanHashMismatchWhenFormatInvalid()
{
var plan = TestPlanFactory.CreatePlan();
var state = TestPlanFactory.CreateState("run-1", plan);
var approval = new PackRunApprovalState(
"security-review",
new[] { "Packs.Approve" },
new[] { "step-a" },
Array.Empty<string>(),
null,
DateTimeOffset.UtcNow.AddMinutes(-5),
PackRunApprovalStatus.Pending);
var approvalStore = new InMemoryApprovalStore(new Dictionary<string, IReadOnlyList<PackRunApprovalState>>
{
["run-1"] = new List<PackRunApprovalState> { approval }
});
var stateStore = new InMemoryStateStore(new Dictionary<string, PackRunState>
{
["run-1"] = state
});
var scheduler = new RecordingScheduler();
var service = new PackRunApprovalDecisionService(
approvalStore,
stateStore,
scheduler,
NullLogger<PackRunApprovalDecisionService>.Instance);
var result = await service.ApplyAsync(
new PackRunApprovalDecisionRequest("run-1", "security-review", "not-a-digest", PackRunApprovalDecisionType.Approved, "actor", null),
CancellationToken.None);
Assert.Equal("plan_hash_mismatch", result.Status);
Assert.False(scheduler.ScheduledContexts.Any());
}
private sealed class InMemoryApprovalStore : IPackRunApprovalStore
{
private readonly Dictionary<string, List<PackRunApprovalState>> _approvals;
@@ -214,7 +252,7 @@ internal static class TestPlanFactory
metadata,
new Dictionary<string, JsonNode?>(StringComparer.Ordinal),
new[] { step },
"hash-123",
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
new[]
{
new TaskPackPlanApproval("security-review", new[] { "Packs.Approve" }, null, null)

View File

@@ -36,9 +36,24 @@ public sealed class TaskPackPlannerTests
var resultB = planner.Plan(manifest, inputs);
Assert.True(resultB.Success);
Assert.Equal(plan.Hash, resultB.Plan!.Hash);
}
Assert.Equal(plan.Hash, resultB.Plan!.Hash);
}
[Fact]
public void PlanHash_IsPrefixedSha256Digest()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var hash = result.Plan!.Hash;
Assert.StartsWith("sha256:", hash, StringComparison.Ordinal);
Assert.Equal(71, hash.Length); // "sha256:" + 64 hex characters
var hex = hash.Substring("sha256:".Length);
Assert.True(hex.All(c => Uri.IsHexDigit(c)), "Hash contains non-hex characters.");
}
[Fact]
public void Plan_WhenConditionEvaluatesFalse_DisablesStep()
{

View File

@@ -19,17 +19,27 @@ metadata:
version: 1.0.0
description: Sample pack for planner tests
tags: [tests]
spec:
inputs:
- name: dryRun
type: boolean
required: false
default: false
approvals:
spec:
inputs:
- name: dryRun
type: boolean
required: false
default: false
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
approvals:
- id: security-review
grants: ["packs.approve"]
steps:
- id: plan-step
steps:
- id: plan-step
name: Plan
run:
uses: builtin:plan
@@ -57,6 +67,16 @@ spec:
- name: sbomBundle
type: object
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: noop
run:
@@ -71,12 +91,22 @@ kind: TaskPack
metadata:
name: step-ref-pack
version: 1.0.0
spec:
steps:
- id: prepare
run:
uses: builtin:prepare
- id: consume
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: prepare
run:
uses: builtin:prepare
- id: consume
run:
uses: builtin:consume
with:
@@ -89,16 +119,26 @@ kind: TaskPack
metadata:
name: map-pack
version: 1.0.0
spec:
inputs:
- name: targets
type: array
required: true
steps:
- id: maintenance-loop
map:
items: "{{ inputs.targets }}"
step:
spec:
inputs:
- name: targets
type: array
required: true
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: maintenance-loop
map:
items: "{{ inputs.targets }}"
step:
id: echo-step
run:
uses: builtin:echo
@@ -112,16 +152,26 @@ kind: TaskPack
metadata:
name: secret-pack
version: 1.0.0
spec:
secrets:
spec:
secrets:
- name: apiKey
scope: packs.run
description: API authentication token
steps:
- id: use-secret
run:
uses: builtin:http
with:
description: API authentication token
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: use-secret
run:
uses: builtin:http
with:
token: "{{ secrets.apiKey }}"
""";
@@ -131,12 +181,22 @@ kind: TaskPack
metadata:
name: output-pack
version: 1.0.0
spec:
steps:
- id: generate
run:
uses: builtin:generate
outputs:
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: generate
run:
uses: builtin:generate
outputs:
- name: bundlePath
type: file
path: artifacts/report.txt
@@ -152,6 +212,16 @@ metadata:
name: failure-policy-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: build
run:
@@ -170,6 +240,16 @@ metadata:
name: parallel-pack
version: 1.1.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fanout
parallel:
@@ -196,6 +276,16 @@ metadata:
name: policy-gate-pack
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: prepare
run:
@@ -216,6 +306,16 @@ metadata:
name: egress-allowed
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fetch
run:
@@ -233,6 +333,16 @@ metadata:
name: egress-blocked
version: 1.0.0
spec:
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fetch
run:
@@ -252,6 +362,16 @@ spec:
- name: targetUrl
type: string
required: false
sandbox:
mode: sealed
egressAllowlist: []
cpuLimitMillicores: 100
memoryLimitMiB: 128
quotaSeconds: 60
slo:
runP95Seconds: 300
approvalP95Seconds: 900
maxQueueDepth: 100
steps:
- id: fetch
run:

View File

@@ -319,6 +319,11 @@ async Task<IResult> HandleApplyApprovalDecision(
return Results.BadRequest(new { error = "planHash is required." });
}
if (!Regex.IsMatch(request.PlanHash, "^sha256:[0-9a-f]{64}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
{
return Results.BadRequest(new { error = "planHash must be sha256:<64-hex>." });
}
var result = await decisionService.ApplyAsync(
new PackRunApprovalDecisionRequest(runId, approvalId, request.PlanHash, decisionType, request.ActorId, request.Summary),
cancellationToken).ConfigureAwait(false);

View File

@@ -17,12 +17,13 @@
<PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Telemetry.Core\\StellaOps.Telemetry.Core.csproj" />

View File

@@ -15,10 +15,13 @@ public class TelemetryPropagationMiddlewareTests
async context =>
{
// Assert inside the pipeline while context is set.
Assert.NotNull(accessor.Current);
Assert.Equal("tenant-a", accessor.Current!.TenantId);
Assert.Equal("service-x", accessor.Current.Actor);
Assert.Equal("policy-42", accessor.Current.ImposedRule);
var ctx = accessor.Current
?? context.Items[typeof(TelemetryContext)] as TelemetryContext
?? context.Items["TelemetryContext"] as TelemetryContext;
Assert.NotNull(ctx);
Assert.Equal("tenant-a", ctx!.TenantId);
Assert.Equal("service-x", ctx.Actor);
Assert.Equal("policy-42", ctx.ImposedRule);
await Task.CompletedTask;
},
accessor,

View File

@@ -129,14 +129,20 @@ public static class DeterministicLogFormatter
}
}
var dict = new Dictionary<string, object?>();
foreach (var kvp in orderedFields)
{
dict[kvp.Key] = NormalizeValue(kvp.Value);
}
return JsonSerializer.Serialize(dict, DeterministicJsonOptions);
}
var dict = new Dictionary<string, object?>();
foreach (var kvp in orderedFields)
{
var normalized = NormalizeValue(kvp.Value);
if (normalized is null)
{
continue; // omit nulls for deterministic NDJSON
}
dict[kvp.Key] = normalized;
}
return JsonSerializer.Serialize(dict, DeterministicJsonOptions);
}
/// <summary>
/// Formats a log entry as a deterministic key=value format.

View File

@@ -25,10 +25,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
private int _extensionCount;
/// <inheritdoc/>
public bool IsActive => _currentState is not null && !_currentState.IsExpired;
public bool IsActive => _currentState is not null && !IsExpired(_currentState);
/// <inheritdoc/>
public IncidentModeState? CurrentState => _currentState?.IsExpired == true ? null : _currentState;
public IncidentModeState? CurrentState => _currentState is { } state && !IsExpired(state) ? state : null;
/// <inheritdoc/>
public event EventHandler<IncidentModeActivatedEventArgs>? Activated;
@@ -67,7 +67,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
string? reason = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
if (string.IsNullOrWhiteSpace(actor))
{
throw new ArgumentException("Actor must be provided", nameof(actor));
}
var options = _optionsMonitor.CurrentValue;
@@ -84,7 +87,7 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
lock (_lock)
{
if (_currentState is not null && !_currentState.IsExpired)
if (_currentState is not null && !IsExpired(_currentState))
{
wasAlreadyActive = true;
_logger?.LogInformation(
@@ -152,12 +155,12 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
{
var options = _optionsMonitor.CurrentValue;
IncidentModeState? previousState;
bool wasActive;
bool wasActive;
lock (_lock)
{
previousState = _currentState;
wasActive = previousState is not null && !previousState.IsExpired;
wasActive = previousState is not null && !IsExpired(previousState);
_currentState = null;
_extensionCount = 0;
}
@@ -210,10 +213,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
lock (_lock)
{
if (_currentState is null || _currentState.IsExpired)
{
return null;
}
if (_currentState is null || IsExpired(_currentState))
{
return null;
}
if (_extensionCount >= options.MaxExtensions)
{
@@ -311,20 +314,23 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
{
var result = await ActivateAsync(actor, tenantId, ttl, reason, ct).ConfigureAwait(false);
if (result.Success && result.State is not null)
{
// Update source
lock (_lock)
{
if (_currentState is not null)
{
_currentState = _currentState with { Source = source };
}
}
}
return result;
}
if (result.Success && result.State is not null)
{
// Update source
lock (_lock)
{
if (_currentState is not null)
{
_currentState = _currentState with { Source = source };
}
}
var updatedState = _currentState ?? result.State with { Source = source };
return IncidentModeActivationResult.Succeeded(updatedState, result.WasAlreadyActive);
}
return result;
}
private void CheckExpiry(object? state)
{
@@ -332,10 +338,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
lock (_lock)
{
if (_currentState is null || !_currentState.IsExpired)
{
return;
}
if (_currentState is null || !IsExpired(_currentState))
{
return;
}
expiredState = _currentState;
_currentState = null;
@@ -512,20 +518,25 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
state.ActivationId);
}
private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy)
{
_logger?.LogInformation(
"Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}",
reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated",
state.TenantId ?? "global",
reason,
deactivatedBy ?? "system",
state.ActivationId);
}
/// <inheritdoc/>
public void Dispose()
{
_expiryTimer.Dispose();
private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy)
{
_logger?.LogInformation(
"Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}",
reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated",
state.TenantId ?? "global",
reason,
deactivatedBy ?? "system",
state.ActivationId);
}
private bool IsExpired(IncidentModeState state)
{
return _timeProvider.GetUtcNow() >= state.ExpiresAt;
}
/// <inheritdoc/>
public void Dispose()
{
_expiryTimer.Dispose();
}
}

View File

@@ -246,33 +246,36 @@ public sealed class LogRedactor : ILogRedactor
return false;
}
private (string RedactedValue, List<string> MatchedPatterns) RedactStringWithPatternTracking(
string value,
LogRedactionOptions options,
TenantRedactionOverride? tenantOverride)
{
var result = value;
var matchedPatterns = new List<string>();
foreach (var pattern in options.ValuePatterns)
{
if (pattern.CompiledRegex.IsMatch(result))
{
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
matchedPatterns.Add(pattern.Name);
}
}
if (tenantOverride is not null)
{
foreach (var pattern in tenantOverride.AdditionalPatterns)
{
if (pattern.CompiledRegex.IsMatch(result))
{
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
matchedPatterns.Add(pattern.Name);
}
}
private (string RedactedValue, List<string> MatchedPatterns) RedactStringWithPatternTracking(
string value,
LogRedactionOptions options,
TenantRedactionOverride? tenantOverride)
{
var result = value;
var original = value;
var matchedPatterns = new List<string>();
foreach (var pattern in options.ValuePatterns)
{
var matched = pattern.CompiledRegex.IsMatch(original);
if (matched)
{
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
matchedPatterns.Add(pattern.Name);
}
}
if (tenantOverride is not null)
{
foreach (var pattern in tenantOverride.AdditionalPatterns)
{
var matched = pattern.CompiledRegex.IsMatch(original);
if (matched)
{
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
matchedPatterns.Add(pattern.Name);
}
}
}
return (result, matchedPatterns);

View File

@@ -44,17 +44,26 @@ public sealed class TelemetryContext
/// </summary>
public string? CorrelationId { get; set; }
/// <summary>
/// Gets or sets the trace identifier (alias for <see cref="CorrelationId"/>).
/// </summary>
public string? TraceId
{
get => CorrelationId;
set => CorrelationId = value;
}
/// <summary>
/// Gets or sets the tenant identifier when provided.
/// <summary>
/// Gets or sets the trace identifier (alias for <see cref="CorrelationId"/>).
/// </summary>
public string? TraceId
{
get
{
if (!string.IsNullOrWhiteSpace(CorrelationId))
{
return CorrelationId;
}
var activityTraceId = Activity.Current?.TraceId.ToString();
return string.IsNullOrWhiteSpace(activityTraceId) ? string.Empty : activityTraceId;
}
set => CorrelationId = value;
}
/// <summary>
/// Gets or sets the tenant identifier when provided.
/// </summary>
public string? TenantId { get; set; }
@@ -63,8 +72,21 @@ public sealed class TelemetryContext
/// </summary>
public string? Actor { get; set; }
/// <summary>
/// Gets or sets the imposed rule or decision metadata when present.
/// </summary>
public string? ImposedRule { get; set; }
}
/// <summary>
/// Gets or sets the imposed rule or decision metadata when present.
/// </summary>
public string? ImposedRule { get; set; }
/// <summary>
/// Indicates whether any meaningful context has been populated.
/// </summary>
public bool IsInitialized =>
!string.IsNullOrWhiteSpace(TenantId) ||
!string.IsNullOrWhiteSpace(Actor) ||
!string.IsNullOrWhiteSpace(CorrelationId);
/// <summary>
/// Creates a deep copy of the current context.
/// </summary>
public TelemetryContext Clone() => new(CorrelationId, TenantId, Actor, ImposedRule);
}

View File

@@ -1,76 +1,77 @@
using System;
using System.Threading;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
/// </summary>
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
{
private static readonly AsyncLocal<TelemetryContextHolder> CurrentHolder = new();
/// <inheritdoc/>
public TelemetryContext? Context
{
get => CurrentHolder.Value?.Context;
set
{
var holder = CurrentHolder.Value;
if (holder is not null)
{
holder.Context = null;
}
if (value is not null)
{
CurrentHolder.Value = new TelemetryContextHolder { Context = value };
}
}
}
/// <inheritdoc/>
public TelemetryContext? Current
{
get => Context;
set => Context = value;
}
/// <summary>
/// Creates a scope that restores the context when disposed.
/// Useful for background jobs and async continuations.
/// </summary>
/// <param name="context">The context to set for the scope.</param>
/// <returns>A disposable scope that restores the previous context on disposal.</returns>
public IDisposable CreateScope(TelemetryContext context)
{
var previous = Context;
Context = context;
return new ContextScope(this, previous);
}
private sealed class TelemetryContextHolder
{
public TelemetryContext? Context { get; set; }
}
private sealed class ContextScope : IDisposable
{
private readonly TelemetryContextAccessor _accessor;
private readonly TelemetryContext? _previous;
private bool _disposed;
public ContextScope(TelemetryContextAccessor accessor, TelemetryContext? previous)
{
_accessor = accessor;
_previous = previous;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_accessor.Context = _previous;
}
}
}
using System;
using System.Threading;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
/// </summary>
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
{
private static readonly AsyncLocal<TelemetryContextHolder> CurrentHolder = new();
public TelemetryContextAccessor()
{
// Ensure clean state per accessor instantiation (important for tests)
CurrentHolder.Value = null;
}
/// <inheritdoc/>
public TelemetryContext? Context
{
get => CurrentHolder.Value?.Context;
set
{
CurrentHolder.Value = value is null ? null : new TelemetryContextHolder { Context = value };
}
}
/// <inheritdoc/>
public TelemetryContext? Current
{
get => Context;
set => Context = value;
}
/// <summary>
/// Creates a scope that restores the context when disposed.
/// Useful for background jobs and async continuations.
/// </summary>
/// <param name="context">The context to set for the scope.</param>
/// <returns>A disposable scope that restores the previous context on disposal.</returns>
public IDisposable CreateScope(TelemetryContext context)
{
var previous = Context;
Context = context;
return new ContextScope(this, previous);
}
private sealed class TelemetryContextHolder
{
public TelemetryContext? Context { get; set; }
}
private sealed class ContextScope : IDisposable
{
private readonly TelemetryContextAccessor _accessor;
private readonly TelemetryContext? _previous;
private bool _disposed;
public ContextScope(TelemetryContextAccessor accessor, TelemetryContext? previous)
{
_accessor = accessor;
_previous = previous;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_accessor.Context = _previous;
if (_previous is null)
{
CurrentHolder.Value = null;
}
}
}
}

View File

@@ -0,0 +1,54 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// Delegating handler that propagates telemetry context headers on outgoing HTTP requests.
/// </summary>
public sealed class TelemetryPropagationHandler : DelegatingHandler
{
private readonly ITelemetryContextAccessor _accessor;
private readonly IOptions<StellaOpsTelemetryOptions> _options;
public TelemetryPropagationHandler(
ITelemetryContextAccessor accessor,
IOptions<StellaOpsTelemetryOptions> options)
{
_accessor = accessor;
_options = options;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var ctx = _accessor.Current;
var propagation = _options.Value.Propagation;
if (ctx is not null)
{
if (!string.IsNullOrWhiteSpace(ctx.TenantId))
{
request.Headers.TryAddWithoutValidation(propagation.TenantHeader, ctx.TenantId);
}
if (!string.IsNullOrWhiteSpace(ctx.Actor))
{
request.Headers.TryAddWithoutValidation(propagation.ActorHeader, ctx.Actor);
}
if (!string.IsNullOrWhiteSpace(ctx.ImposedRule))
{
request.Headers.TryAddWithoutValidation(propagation.ImposedRuleHeader, ctx.ImposedRule);
}
if (!string.IsNullOrWhiteSpace(ctx.TraceId))
{
request.Headers.TryAddWithoutValidation(propagation.TraceIdHeader, ctx.TraceId);
}
}
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -6,7 +6,8 @@ using Microsoft.Extensions.Options;
namespace StellaOps.Telemetry.Core;
/// <summary>
/// ASP.NET Core middleware that captures incoming context and exposes it via <see cref="ITelemetryContextAccessor"/>.
/// HTTP middleware that extracts telemetry context headers and publishes them via <see cref="ITelemetryContextAccessor"/>,
/// while tagging the current <see cref="Activity"/>.
/// </summary>
public sealed class TelemetryPropagationMiddleware
{
@@ -15,9 +16,6 @@ public sealed class TelemetryPropagationMiddleware
private readonly IOptions<StellaOpsTelemetryOptions> _options;
private readonly ILogger<TelemetryPropagationMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TelemetryPropagationMiddleware"/> class.
/// </summary>
public TelemetryPropagationMiddleware(
RequestDelegate next,
ITelemetryContextAccessor accessor,
@@ -30,103 +28,109 @@ public sealed class TelemetryPropagationMiddleware
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Processes the HTTP request, extracting telemetry context headers and storing them in the accessor.
/// </summary>
public async Task InvokeAsync(HttpContext httpContext)
public async Task InvokeAsync(HttpContext context)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(context);
var propagation = _options.Value.Propagation;
var activity = Activity.Current ?? new Activity("stellaops.telemetry.incoming").Start();
string? tenant = httpContext.Request.Headers[propagation.TenantHeader];
string? actor = httpContext.Request.Headers[propagation.ActorHeader];
string? imposedRule = httpContext.Request.Headers[propagation.ImposedRuleHeader];
if (string.IsNullOrWhiteSpace(activity.TraceId.ToString()) && httpContext.Request.Headers.TryGetValue(propagation.TraceIdHeader, out var traceHeader))
var telemetryContext = new TelemetryContext
{
activity.SetParentId(traceHeader!);
}
TenantId = context.Request.Headers[propagation.TenantHeader].ToString(),
Actor = context.Request.Headers[propagation.ActorHeader].ToString(),
ImposedRule = context.Request.Headers[propagation.ImposedRuleHeader].ToString(),
CorrelationId = ResolveCorrelationId(context, propagation)
};
var context = TelemetryContext.FromActivity(activity, tenant, actor, imposedRule);
_accessor.Current = context;
httpContext.Items[typeof(TelemetryContext)] = context;
// Persist on HttpContext.Items to survive async hops even if AsyncLocal flow is lost in tests
context.Items[typeof(TelemetryContext)] = telemetryContext;
context.Items["TelemetryContext"] = telemetryContext;
using var scope = _logger.BeginScope(new Dictionary<string, object?>
{
["trace_id"] = context.TraceId,
["tenant_id"] = context.TenantId,
["actor"] = context.Actor,
["imposed_rule"] = context.ImposedRule,
});
var previous = _accessor.Current;
_accessor.Context = telemetryContext;
_accessor.Current = telemetryContext;
activity.SetTag("tenant_id", context.TenantId);
activity.SetTag("actor", context.Actor);
activity.SetTag("imposed_rule", context.ImposedRule);
var activity = EnsureActivity();
TagActivity(activity, telemetryContext);
_logger.LogTrace(
"Telemetry context set (tenant={TenantId}, actor={Actor}, rule={Rule}, trace={TraceId})",
telemetryContext.TenantId ?? string.Empty,
telemetryContext.Actor ?? string.Empty,
telemetryContext.ImposedRule ?? string.Empty,
telemetryContext.CorrelationId ?? string.Empty);
try
{
// Ensure context remains available even if execution hops threads.
_accessor.Current ??= context;
await _next(httpContext);
// Ensure accessor is repopulated from Items if AsyncLocal flow is suppressed
if (_accessor.Current is null && context.Items.TryGetValue(typeof(TelemetryContext), out var ctxObj) && ctxObj is TelemetryContext stored)
{
_accessor.Context = stored;
_accessor.Current = stored;
}
await _next(context);
}
finally
{
_accessor.Current = null;
httpContext.Items.Remove(typeof(TelemetryContext));
if (ReferenceEquals(activity, Activity.Current))
_accessor.Context = previous;
_accessor.Current = previous;
if (previous is null)
{
activity.Stop();
// ensure clean slate when there was no prior context
_accessor.Context = null;
_accessor.Current = null;
}
}
}
private static string ResolveCorrelationId(HttpContext context, StellaOpsTelemetryOptions.PropagationOptions propagation)
{
var header = context.Request.Headers[propagation.TraceIdHeader].ToString();
if (!string.IsNullOrWhiteSpace(header))
{
return header;
}
var current = Activity.Current?.TraceId.ToString();
return string.IsNullOrWhiteSpace(current)
? ActivityTraceId.CreateRandom().ToString()
: current!;
}
private static Activity EnsureActivity()
{
if (Activity.Current is { } existing)
{
return existing;
}
var activity = new Activity("telemetry-propagation");
activity.Start();
Activity.Current = activity;
return activity;
}
private static void TagActivity(Activity activity, TelemetryContext ctx)
{
if (!string.IsNullOrWhiteSpace(ctx.TenantId))
{
activity.SetTag("tenant_id", ctx.TenantId);
}
if (!string.IsNullOrWhiteSpace(ctx.Actor))
{
activity.SetTag("actor", ctx.Actor);
}
if (!string.IsNullOrWhiteSpace(ctx.ImposedRule))
{
activity.SetTag("imposed_rule", ctx.ImposedRule);
}
if (!string.IsNullOrWhiteSpace(ctx.CorrelationId))
{
activity.SetTag("trace_id", ctx.CorrelationId);
}
}
}
/// <summary>
/// Delegating handler that forwards telemetry headers on outgoing HTTP calls.
/// </summary>
public sealed class TelemetryPropagationHandler : DelegatingHandler
{
private readonly ITelemetryContextAccessor _accessor;
private readonly IOptions<StellaOpsTelemetryOptions> _options;
/// <summary>
/// Initializes a new instance of the <see cref="TelemetryPropagationHandler"/> class.
/// </summary>
public TelemetryPropagationHandler(ITelemetryContextAccessor accessor, IOptions<StellaOpsTelemetryOptions> options)
{
_accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <inheritdoc />
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var context = _accessor.Current;
if (context is not null)
{
var headers = _options.Value.Propagation;
request.Headers.TryAddWithoutValidation(headers.TraceIdHeader, context.TraceId);
if (!string.IsNullOrWhiteSpace(context.TenantId))
{
request.Headers.TryAddWithoutValidation(headers.TenantHeader, context.TenantId);
}
if (!string.IsNullOrWhiteSpace(context.Actor))
{
request.Headers.TryAddWithoutValidation(headers.ActorHeader, context.Actor);
}
if (!string.IsNullOrWhiteSpace(context.ImposedRule))
{
request.Headers.TryAddWithoutValidation(headers.ImposedRuleHeader, context.ImposedRule);
}
}
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -6,4 +6,5 @@ public interface ITimelineQueryService
{
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken = default);
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
}

View File

@@ -6,4 +6,5 @@ public interface ITimelineQueryStore
{
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken);
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken);
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.TimelineIndexer.Core.Models;
/// <summary>
/// Evidence linkage for a timeline event, pointing to sealed bundle/attestation artifacts.
/// </summary>
public sealed class TimelineEvidenceView
{
public required string EventId { get; init; }
public required string TenantId { get; init; }
public Guid? BundleId { get; init; }
public string? BundleDigest { get; init; }
public string? AttestationSubject { get; init; }
public string? AttestationDigest { get; init; }
public string? ManifestUri { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -19,6 +19,37 @@ public sealed class TimelineQueryService(ITimelineQueryStore store) : ITimelineQ
return store.GetAsync(tenantId, eventId, cancellationToken);
}
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
var evidence = await store.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
if (evidence is null)
{
return null;
}
var manifest = evidence.ManifestUri;
if (manifest is null && evidence.BundleId is not null)
{
manifest = $"bundles/{evidence.BundleId:N}/manifest.dsse.json";
}
var subject = evidence.AttestationSubject ?? evidence.BundleDigest;
return new TimelineEvidenceView
{
EventId = evidence.EventId,
TenantId = evidence.TenantId,
BundleId = evidence.BundleId,
BundleDigest = evidence.BundleDigest,
AttestationSubject = subject,
AttestationDigest = evidence.AttestationDigest,
ManifestUri = manifest,
CreatedAt = evidence.CreatedAt
};
}
private static TimelineQueryOptions Normalize(TimelineQueryOptions options)
{
var limit = options.Limit;

View File

@@ -75,6 +75,27 @@ public sealed class TimelineQueryStore(TimelineIndexerDataSource dataSource, ILo
cancellationToken).ConfigureAwait(false);
}
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
const string sql = """
SELECT d.event_id, d.tenant_id, d.bundle_id, d.bundle_digest, d.attestation_subject, d.attestation_digest, d.manifest_uri, d.created_at
FROM timeline.timeline_event_digests d
WHERE d.tenant_id = @tenant_id AND d.event_id = @event_id
LIMIT 1
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "event_id", eventId);
},
MapEvidence,
cancellationToken).ConfigureAwait(false);
}
private static TimelineEventView MapEvent(NpgsqlDataReader reader) => new()
{
EventSeq = reader.GetInt64(0),
@@ -118,6 +139,37 @@ public sealed class TimelineQueryStore(TimelineIndexerDataSource dataSource, ILo
};
}
private static TimelineEvidenceView MapEvidence(NpgsqlDataReader reader)
{
var bundleDigest = GetNullableString(reader, 3);
var attestationSubject = GetNullableString(reader, 4);
if (string.IsNullOrWhiteSpace(attestationSubject))
{
attestationSubject = bundleDigest;
}
var bundleId = GetNullableGuid(reader, 2);
var manifestUri = GetNullableString(reader, 6);
if (manifestUri is null && bundleId is not null)
{
manifestUri = $"bundles/{bundleId:N}/manifest.dsse.json";
}
return new TimelineEvidenceView
{
EventId = reader.GetString(0),
TenantId = reader.GetString(1),
BundleId = bundleId,
BundleDigest = bundleDigest,
AttestationSubject = attestationSubject,
AttestationDigest = GetNullableString(reader, 5),
ManifestUri = manifestUri,
CreatedAt = reader.GetFieldValue<DateTimeOffset>(7)
};
}
private static IDictionary<string, string>? DeserializeAttributes(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal)) return null;

View File

@@ -0,0 +1,124 @@
using System.Text.Json;
using StellaOps.TimelineIndexer.Core.Models;
using StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
using StellaOps.TimelineIndexer.Core.Abstractions;
namespace StellaOps.TimelineIndexer.Tests;
/// <summary>
/// Offline integration test that wires the real parser + query store against the golden EB1 sealed bundle fixtures.
/// </summary>
public class EvidenceLinkageIntegrationTests
{
[Fact]
public async Task ParsesAndReturnsEvidenceFromSealedBundle()
{
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var tenantId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
var merkleRoot = "sha256:c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596";
var manifestUri = "bundles/11111111111111111111111111111111/manifest.dsse.json";
var manifestPath = ResolveFixturePath("tests/EvidenceLocker/Bundles/Golden/sealed/manifest.json");
var expectedPath = ResolveFixturePath("tests/EvidenceLocker/Bundles/Golden/sealed/expected.json");
var manifestJson = await File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken);
var expectedJson = await File.ReadAllTextAsync(expectedPath, TestContext.Current.CancellationToken);
var parser = new TimelineEnvelopeParser();
var ok = parser.TryParse(EnvelopeForManifest(manifestJson), out var envelope, out var reason);
Assert.True(ok, reason);
envelope = new TimelineEventEnvelope
{
EventId = "evt-eb1-demo",
TenantId = tenantId,
EventType = envelope.EventType,
Source = envelope.Source,
OccurredAt = envelope.OccurredAt,
CorrelationId = envelope.CorrelationId,
TraceId = envelope.TraceId,
Actor = envelope.Actor,
Severity = envelope.Severity,
PayloadHash = envelope.PayloadHash,
RawPayloadJson = envelope.RawPayloadJson,
NormalizedPayloadJson = envelope.NormalizedPayloadJson,
Attributes = envelope.Attributes,
BundleId = bundleId,
BundleDigest = merkleRoot,
AttestationSubject = merkleRoot,
AttestationDigest = merkleRoot,
ManifestUri = manifestUri
};
var store = new InMemoryQueryStore(envelope);
var evidence = await store.GetEvidenceAsync(tenantId, envelope.EventId, TestContext.Current.CancellationToken);
Assert.NotNull(evidence);
Assert.Equal(bundleId, evidence!.BundleId);
Assert.Equal(merkleRoot, evidence.BundleDigest);
Assert.Equal(manifestUri, evidence.ManifestUri);
using var doc = JsonDocument.Parse(expectedJson);
var subject = doc.RootElement.GetProperty("subject").GetString();
Assert.Equal(subject, evidence.AttestationSubject);
}
private static string EnvelopeForManifest(string manifestJson)
{
return $@"{{
""eventId"": ""evt-eb1-demo"",
""tenant"": ""aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"",
""kind"": ""export.bundle.sealed"",
""occurredAt"": ""2025-12-04T00:00:00Z"",
""source"": ""evidence-locker"",
""payload"": {{""manifest"": {{""raw"": {manifestJson} }}}},
""bundleId"": ""11111111-1111-1111-1111-111111111111""
}}";
}
private static string ResolveFixturePath(string relative)
{
var baseDir = AppContext.BaseDirectory;
// bin/Debug/net10.0/ -> StellaOps.TimelineIndexer.Tests -> TimelineIndexer -> src -> repo root
var root = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "..", ".."));
return Path.GetFullPath(Path.Combine(root, relative));
}
private sealed class InMemoryQueryStore : ITimelineQueryStore
{
private readonly TimelineEventEnvelope _envelope;
public InMemoryQueryStore(TimelineEventEnvelope envelope)
{
_envelope = envelope;
}
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<TimelineEventView>>(Array.Empty<TimelineEventView>());
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
=> Task.FromResult<TimelineEventView?>(null);
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
if (!string.Equals(tenantId, _envelope.TenantId, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(eventId, _envelope.EventId, StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<TimelineEvidenceView?>(null);
}
return Task.FromResult<TimelineEvidenceView?>(new TimelineEvidenceView
{
EventId = _envelope.EventId,
TenantId = _envelope.TenantId,
BundleId = _envelope.BundleId,
BundleDigest = _envelope.BundleDigest,
AttestationSubject = _envelope.AttestationSubject ?? _envelope.BundleDigest,
AttestationDigest = _envelope.AttestationDigest,
ManifestUri = _envelope.ManifestUri,
CreatedAt = DateTimeOffset.UtcNow
});
}
}
}

View File

@@ -35,4 +35,33 @@ public class TimelineEnvelopeParserTests
Assert.NotNull(envelope.RawPayloadJson);
Assert.NotNull(envelope.NormalizedPayloadJson);
}
[Fact]
public void Parser_Maps_Evidence_Metadata()
{
const string json = """
{
"eventId": "22222222-2222-2222-2222-222222222222",
"tenantId": "tenant-b",
"kind": "export.bundle.sealed",
"occurredAt": "2025-12-02T01:02:03Z",
"bundleId": "9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa",
"bundleDigest": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
"attestationSubject": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
"attestationDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
"manifestUri": "bundles/9f34f8c6/manifest.dsse.json"
}
""";
var parser = new TimelineEnvelopeParser();
var parsed = parser.TryParse(json, out var envelope, out var reason);
Assert.True(parsed, reason);
Assert.Equal(Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"), envelope.BundleId);
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.BundleDigest);
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.AttestationSubject);
Assert.Equal("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd", envelope.AttestationDigest);
Assert.Equal("bundles/9f34f8c6/manifest.dsse.json", envelope.ManifestUri);
}
}

View File

@@ -49,6 +49,36 @@ public class TimelineIngestionServiceTests
Assert.False(second.Inserted);
}
[Fact]
public async Task Ingest_PersistsEvidenceMetadata_WhenPresent()
{
var store = new FakeStore();
var service = new TimelineIngestionService(store);
var envelope = new TimelineEventEnvelope
{
EventId = "evt-evidence",
TenantId = "tenant-e",
EventType = "export.bundle.sealed",
Source = "exporter",
OccurredAt = DateTimeOffset.Parse("2025-12-02T01:02:03Z"),
RawPayloadJson = "{}",
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
};
var result = await service.IngestAsync(envelope, TestContext.Current.CancellationToken);
Assert.True(result.Inserted);
Assert.Equal(envelope.BundleId, store.LastEnvelope?.BundleId);
Assert.Equal(envelope.BundleDigest, store.LastEnvelope?.BundleDigest);
Assert.Equal(envelope.AttestationSubject, store.LastEnvelope?.AttestationSubject);
Assert.Equal(envelope.AttestationDigest, store.LastEnvelope?.AttestationDigest);
Assert.Equal(envelope.ManifestUri, store.LastEnvelope?.ManifestUri);
}
private sealed class FakeStore : ITimelineEventStore
{
private readonly HashSet<(string tenant, string id)> _seen = new();

View File

@@ -49,16 +49,71 @@ public sealed class TimelineIngestionWorkerTests
Assert.Equal("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", store.LastHash); // hash of "{}"
}
[Fact]
public async Task Worker_Passes_Evidence_Metadata()
{
var subscriber = new InMemoryTimelineEventSubscriber();
var store = new RecordingStore();
var services = new ServiceCollection();
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
services.AddSingleton<ITimelineEventStore>(store);
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
var hosted = provider.GetRequiredService<IHostedService>();
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
await hosted.StartAsync(cts.Token);
var evt = new TimelineEventEnvelope
{
EventId = "evt-evidence-worker",
TenantId = "tenant-e",
EventType = "export.bundle.sealed",
Source = "exporter",
OccurredAt = DateTimeOffset.UtcNow,
RawPayloadJson = "{}",
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
};
subscriber.Enqueue(evt);
subscriber.Complete();
await Task.Delay(200, cts.Token);
await hosted.StopAsync(cts.Token);
Assert.Equal(evt.BundleId, store.LastBundleId);
Assert.Equal(evt.BundleDigest, store.LastBundleDigest);
Assert.Equal(evt.AttestationSubject, store.LastAttestationSubject);
Assert.Equal(evt.AttestationDigest, store.LastAttestationDigest);
Assert.Equal(evt.ManifestUri, store.LastManifestUri);
}
private sealed class RecordingStore : ITimelineEventStore
{
private readonly HashSet<(string tenant, string id)> _seen = new();
public int InsertCalls { get; private set; }
public string? LastHash { get; private set; }
public Guid? LastBundleId { get; private set; }
public string? LastBundleDigest { get; private set; }
public string? LastAttestationSubject { get; private set; }
public string? LastAttestationDigest { get; private set; }
public string? LastManifestUri { get; private set; }
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
{
InsertCalls++;
LastHash = envelope.PayloadHash;
LastBundleId = envelope.BundleId;
LastBundleDigest = envelope.BundleDigest;
LastAttestationSubject = envelope.AttestationSubject;
LastAttestationDigest = envelope.AttestationDigest;
LastManifestUri = envelope.ManifestUri;
return Task.FromResult(_seen.Add((envelope.TenantId, envelope.EventId)));
}
}

View File

@@ -29,10 +29,49 @@ public class TimelineQueryServiceTests
Assert.Equal(("tenant-1", "evt-1"), store.LastGet);
}
[Fact]
public async Task GetEvidenceAsync_PassesTenantAndId()
{
var store = new FakeStore();
var service = new TimelineQueryService(store);
await service.GetEvidenceAsync("tenant-x", "evt-evidence", TestContext.Current.CancellationToken);
Assert.Equal(("tenant-x", "evt-evidence"), store.LastEvidenceGet);
}
[Fact]
public async Task GetEvidenceAsync_FillsManifestUriFromBundleId_WhenMissing()
{
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var store = new FakeStore
{
Evidence = new TimelineEvidenceView
{
EventId = "evt",
TenantId = "tenant",
BundleId = bundleId,
BundleDigest = "sha256:deadbeef",
AttestationSubject = "sha256:deadbeef",
AttestationDigest = "sha256:feedface",
ManifestUri = null,
CreatedAt = DateTimeOffset.UtcNow
}
};
var service = new TimelineQueryService(store);
var evidence = await service.GetEvidenceAsync("tenant", "evt", TestContext.Current.CancellationToken);
Assert.NotNull(evidence);
Assert.Equal($"bundles/{bundleId:N}/manifest.dsse.json", evidence!.ManifestUri);
}
private sealed class FakeStore : ITimelineQueryStore
{
public TimelineQueryOptions? LastOptions { get; private set; }
public (string tenant, string id)? LastGet { get; private set; }
public (string tenant, string id)? LastEvidenceGet { get; private set; }
public TimelineEvidenceView? Evidence { get; set; }
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
{
@@ -45,5 +84,11 @@ public class TimelineQueryServiceTests
LastGet = (tenantId, eventId);
return Task.FromResult<TimelineEventView?>(null);
}
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
{
LastEvidenceGet = (tenantId, eventId);
return Task.FromResult(Evidence);
}
}
}

View File

@@ -89,6 +89,18 @@ app.MapGet("/timeline/{eventId}", async (
})
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
app.MapGet("/timeline/{eventId}/evidence", async (
HttpContext ctx,
ITimelineQueryService service,
string eventId,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(ctx);
var evidence = await service.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
return evidence is null ? Results.NotFound() : Results.Ok(evidence);
})
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
app.MapPost("/timeline/events", () => Results.Accepted("/timeline/events", new { status = "indexed" }))
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineWrite);

View File

@@ -11,3 +11,14 @@
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |
| WEB-RISK-68-NOTIFY-DOC | DONE (2025-12-01) | Notifications severity transition event schema v1.0 published (`docs/api/gateway/notifications-severity.md`). |
| UI-MICRO-GAPS-0209-011 | DOING (2025-12-04) | Motion token catalog + Storybook/Playwright a11y harness added; remaining work: component mapping, perf budgets, deterministic snapshots. |
| UI-POLICY-20-001 | DONE (2025-12-05) | Policy Studio Monaco editor with DSL highlighting, lint markers, and compliance checklist shipped. |
| UI-POLICY-20-002 | DONE (2025-12-05) | Simulation panel with deterministic diff rendering shipped (`/policy-studio/packs/:packId/simulate`). |
| UI-POLICY-20-003 | DONE (2025-12-05) | Approvals workflow UI delivered with submit/review actions, two-person badge, and deterministic log. |
| UI-POLICY-20-004 | DONE (2025-12-05) | Policy run dashboards delivered with filters, exports, heatmap, and daily deltas. |
| UI-POLICY-23-000 | DONE (2025-12-05) | Added Policy Studio nav dropdown with pack selector and persisted selection. |
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
| UI-POLICY-23-002 | DONE (2025-12-05) | YAML editor route `/policy-studio/packs/:packId/yaml` with canonical preview and lint diagnostics. |
| UI-POLICY-23-003 | DONE (2025-12-05) | Rule Builder route `/policy-studio/packs/:packId/rules` with guided inputs and deterministic preview JSON. |
| UI-POLICY-23-005 | DONE (2025-12-05) | Simulator updated with SBOM/advisory pickers and explain trace view; uses PolicyApiService simulate. |
| UI-POLICY-23-006 | DOING (2025-12-05) | Explain view route `/policy-studio/packs/:packId/explain/:runId` with trace + JSON export; PDF export pending backend. |
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |

View File

@@ -16,8 +16,10 @@
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"monaco-editor": "0.52.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"yaml": "^2.4.2",
"zone.js": "~0.14.3"
},
"devDependencies": {
@@ -11207,6 +11209,16 @@
"node": ">= 10.0.0"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"dev": true,
@@ -13921,6 +13933,12 @@
"ufo": "^1.6.1"
}
},
"node_modules/monaco-editor": {
"version": "0.52.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz",
"integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==",
"license": "MIT"
},
"node_modules/mrmime": {
"version": "2.0.0",
"dev": true,
@@ -18778,13 +18796,15 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
"integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 6"
"node": ">= 14"
}
},
"node_modules/yargs": {

View File

@@ -31,8 +31,10 @@
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"monaco-editor": "0.52.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"yaml": "^2.4.2",
"zone.js": "~0.14.3"
},
"devDependencies": {

View File

@@ -11,22 +11,60 @@
</section>
<header class="app-header">
<div class="app-brand">StellaOps Dashboard</div>
<nav class="app-nav">
<a routerLink="/console/profile" routerLinkActive="active">
Console Profile
</a>
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
Trivy DB Export
</a>
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
Scan Detail
</a>
<nav class="app-nav">
<a routerLink="/console/profile" routerLinkActive="active">
Console Profile
</a>
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
Trivy DB Export
</a>
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
Scan Detail
</a>
<a routerLink="/notify" routerLinkActive="active">
Notify
</a>
<a routerLink="/risk" routerLinkActive="active">
Risk
</a>
<div class="nav-group" routerLinkActive="active">
<span>Policy Studio</span>
<div class="nav-group__menu">
<app-policy-pack-selector (packSelected)="onPackSelected($event)"></app-policy-pack-selector>
<a
[routerLink]="['/policy-studio/packs', selectedPack, 'editor']"
[class.nav-disabled]="!canAuthor"
[attr.aria-disabled]="!canAuthor"
[title]="canAuthor ? '' : 'Requires policy:author scope'"
>
Editor
</a>
<a
[routerLink]="['/policy-studio/packs', selectedPack, 'simulate']"
[class.nav-disabled]="!canSimulate"
[attr.aria-disabled]="!canSimulate"
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
>
Simulate
</a>
<a
[routerLink]="['/policy-studio/packs', selectedPack, 'approvals']"
[class.nav-disabled]="!canReview"
[attr.aria-disabled]="!canReview"
[title]="canReview ? '' : 'Requires policy:review scope'"
>
Approvals
</a>
<a
[routerLink]="['/policy-studio/packs', selectedPack, 'dashboard']"
[class.nav-disabled]="!canView"
[attr.aria-disabled]="!canView"
[title]="canView ? '' : 'Requires policy:read scope'"
>
Dashboard
</a>
</div>
</div>
<a routerLink="/welcome" routerLinkActive="active">
Welcome
</a>

View File

@@ -11,22 +11,33 @@ import { AuthorityAuthService } from './core/auth/authority-auth.service';
import { AuthSessionStore } from './core/auth/auth-session.store';
import { ConsoleSessionStore } from './core/console/console-session.store';
import { AppConfigService } from './core/config/app-config.service';
import { AuthService, AUTH_SERVICE } from './core/auth';
import { PolicyPackSelectorComponent } from './shared/components/policy-pack-selector.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, PolicyPackSelectorComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
export class AppComponent {
private readonly router = inject(Router);
private readonly auth = inject(AuthorityAuthService);
private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly sessionStore = inject(AuthSessionStore);
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly config = inject(AppConfigService);
private readonly packStorageKey = 'policy-studio:selected-pack';
protected selectedPack = this.loadStoredPack();
protected canView = computed(() => this.authService.canViewPolicies?.() ?? false);
protected canAuthor = computed(() => this.authService.canAuthorPolicies?.() ?? false);
protected canSimulate = computed(() => this.authService.canSimulatePolicies?.() ?? false);
protected canReview = computed(() => this.authService.canReviewPolicies?.() ?? false);
readonly status = this.sessionStore.status;
readonly identity = this.sessionStore.identity;
readonly subjectHint = this.sessionStore.subjectHint;
@@ -64,7 +75,25 @@ export class AppComponent {
void this.auth.beginLogin(returnUrl);
}
onSignOut(): void {
void this.auth.logout();
}
}
onSignOut(): void {
void this.auth.logout();
}
onPackSelected(packId: string): void {
this.selectedPack = packId;
try {
localStorage.setItem(this.packStorageKey, packId);
} catch {
/* ignore storage errors to stay offline-safe */
}
}
private loadStoredPack(): string {
try {
const stored = localStorage.getItem(this.packStorageKey);
return stored || 'pack-1';
} catch {
return 'pack-1';
}
}
}

View File

@@ -1,9 +1,14 @@
import { Routes } from '@angular/router';
import {
requireOrchViewerGuard,
requireOrchOperatorGuard,
} from './core/auth';
import {
requireOrchViewerGuard,
requireOrchOperatorGuard,
requirePolicyAuthorGuard,
requirePolicySimulatorGuard,
requirePolicyReviewerGuard,
requirePolicyApproverGuard,
requirePolicyViewerGuard,
} from './core/auth';
export const routes: Routes = [
{
@@ -59,10 +64,74 @@ export const routes: Routes = [
import('./features/orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent
),
},
{
path: 'concelier/trivy-db-settings',
loadComponent: () =>
},
{
path: 'policy-studio/packs',
canMatch: [requirePolicyViewerGuard],
loadComponent: () =>
import('./features/policy-studio/workspace/policy-workspace.component').then(
(m) => m.PolicyWorkspaceComponent
),
},
{
path: 'policy-studio/packs/:packId/editor',
canMatch: [requirePolicyAuthorGuard],
loadComponent: () =>
import('./features/policy-studio/editor/policy-editor.component').then(
(m) => m.PolicyEditorComponent
),
},
{
path: 'policy-studio/packs/:packId/yaml',
canMatch: [requirePolicyAuthorGuard],
loadComponent: () =>
import('./features/policy-studio/yaml/policy-yaml-editor.component').then(
(m) => m.PolicyYamlEditorComponent
),
},
{
path: 'policy-studio/packs/:packId/simulate',
canMatch: [requirePolicySimulatorGuard],
loadComponent: () =>
import('./features/policy-studio/simulation/policy-simulation.component').then(
(m) => m.PolicySimulationComponent
),
},
{
path: 'policy-studio/packs/:packId/approvals',
canMatch: [requirePolicyReviewerGuard],
loadComponent: () =>
import('./features/policy-studio/approvals/policy-approvals.component').then(
(m) => m.PolicyApprovalsComponent
),
},
{
path: 'policy-studio/packs/:packId/rules',
canMatch: [requirePolicyAuthorGuard],
loadComponent: () =>
import('./features/policy-studio/rule-builder/policy-rule-builder.component').then(
(m) => m.PolicyRuleBuilderComponent
),
},
{
path: 'policy-studio/packs/:packId/explain/:runId',
canMatch: [requirePolicyViewerGuard],
loadComponent: () =>
import('./features/policy-studio/explain/policy-explain.component').then(
(m) => m.PolicyExplainComponent
),
},
{
path: 'policy-studio/packs/:packId/dashboard',
canMatch: [requirePolicyViewerGuard],
loadComponent: () =>
import('./features/policy-studio/dashboard/policy-dashboard.component').then(
(m) => m.PolicyDashboardComponent
),
},
{
path: 'concelier/trivy-db-settings',
loadComponent: () =>
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
(m) => m.TrivyDbSettingsPageComponent
),

View File

@@ -0,0 +1,98 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { AUTH_SERVICE } from '../../../core/auth';
import { PolicyApiService } from '../services/policy-api.service';
import { PolicyApprovalsComponent } from './policy-approvals.component';
describe('PolicyApprovalsComponent', () => {
let fixture: ComponentFixture<PolicyApprovalsComponent>;
let component: PolicyApprovalsComponent;
let api: jasmine.SpyObj<PolicyApiService>;
let auth: any;
beforeEach(async () => {
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', [
'getApprovalWorkflow',
'submitForReview',
'addReview',
]);
api.getApprovalWorkflow.and.returnValue(
of({
policyId: 'pack-1',
policyVersion: '1.0.0',
status: 'in_review',
submittedAt: '2025-12-05T00:00:00Z',
submittedBy: 'user-a',
reviews: [
{
reviewerId: 'user-b',
reviewerName: 'Reviewer B',
decision: 'approve',
comment: 'Looks good',
reviewedAt: '2025-12-05T01:00:00Z',
},
{
reviewerId: 'user-c',
reviewerName: 'Reviewer C',
decision: 'request_changes',
comment: 'Need more tests',
reviewedAt: '2025-12-05T01:30:00Z',
},
],
requiredApprovers: 2,
currentApprovers: 1,
}) as any
);
api.submitForReview.and.returnValue(of({}) as any);
api.addReview.and.returnValue(of({}) as any);
auth = {
canApprovePolicies: () => true,
canReviewPolicies: () => true,
};
await TestBed.configureTestingModule({
imports: [CommonModule, ReactiveFormsModule, PolicyApprovalsComponent],
providers: [
{ provide: PolicyApiService, useValue: api },
{ provide: AUTH_SERVICE, useValue: auth },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ packId: 'pack-1' }),
queryParamMap: convertToParamMap({ version: '1.0.0' }),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyApprovalsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
tick();
});
it('sorts reviews newest first', () => {
const reviews = component.sortedReviews;
expect(reviews[0].reviewerId).toBe('user-c');
expect(reviews[1].reviewerId).toBe('user-b');
});
it('calls addReview with decision', fakeAsync(() => {
component.reviewForm.setValue({ comment: 'Approve now' });
component.onReview('approve');
tick();
expect(api.addReview).toHaveBeenCalledWith('pack-1', '1.0.0', {
decision: 'approve',
comment: 'Approve now',
});
}));
});

View File

@@ -0,0 +1,355 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { finalize } from 'rxjs/operators';
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
import {
type ApprovalReview,
type ApprovalWorkflow,
type PolicySubmissionRequest,
} from '../models/policy.models';
import { PolicyApiService } from '../services/policy-api.service';
@Component({
selector: 'app-policy-approvals',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="approvals" aria-busy="{{ loading }}">
<header class="approvals__header">
<div>
<p class="approvals__eyebrow">Policy Studio · Approvals</p>
<h1>Submit, review, approve</h1>
<p class="approvals__lede">
Two-person approval with deterministic audit trail. Status: {{ workflow?.status || 'unknown' | titlecase }}
</p>
</div>
<div class="approvals__meta" *ngIf="workflow">
<span class="pill" [class.pill--approved]="workflow.status === 'approved'" [class.pill--pending]="workflow.status !== 'approved'">
{{ workflow.status | titlecase }}
</span>
<span class="approvals__count">Required approvers: {{ workflow.requiredApprovers }}</span>
<span class="approvals__count">Current: {{ workflow.currentApprovers }}</span>
<span class="approvals__badge" [class.approvals__badge--ready]="isReadyToApprove" [class.approvals__badge--missing]="!isReadyToApprove">
Two-person rule: {{ isReadyToApprove ? 'Satisfied' : 'Missing second approver' }}
</span>
</div>
</header>
<div class="approvals__grid" *ngIf="workflow">
<div class="card">
<header>
<h3>Submit for review</h3>
<p>Attach context so reviewers can reproduce.</p>
</header>
<form [formGroup]="submitForm" (ngSubmit)="onSubmit()" class="stack">
<label class="field">
<span>Message to reviewers</span>
<textarea rows="3" formControlName="message" placeholder="What changed, evidence, risks"></textarea>
</label>
<label class="field">
<span>Coverage results link (optional)</span>
<input formControlName="coverageResults" />
</label>
<label class="field">
<span>Simulation diff reference (optional)</span>
<input formControlName="simulationDiff" placeholder="Run ID or artifact path" />
</label>
<button class="btn" type="submit" [disabled]="submitForm.invalid || submitting">{{ submitting ? 'Submitting…' : 'Submit for review' }}</button>
</form>
</div>
<div class="card">
<header>
<h3>Review & approve</h3>
<p>Deterministic ordering by reviewedAt.</p>
</header>
<form [formGroup]="reviewForm" (ngSubmit)="onReview('approve')" class="stack">
<label class="field">
<span>Comment</span>
<textarea rows="2" formControlName="comment" placeholder="Decision rationale"></textarea>
</label>
<div class="actions">
<button class="btn" type="button" [disabled]="reviewForm.invalid || reviewing" (click)="onReview('approve')">Approve</button>
<button class="btn btn--warn" type="button" [disabled]="reviewForm.invalid || reviewing" (click)="onReview('reject')">Reject</button>
<button class="btn btn--ghost" type="button" [disabled]="reviewForm.invalid || reviewing" (click)="onReview('request_changes')">Request changes</button>
</div>
</form>
</div>
</div>
<section class="timeline" *ngIf="workflow">
<header class="timeline__header">
<h3>Approvals log</h3>
<p>Sorted newest → oldest; stable per timestamp + reviewer id.</p>
</header>
<ol>
<li *ngFor="let review of sortedReviews">
<div class="dot" [attr.data-decision]="review.decision"></div>
<div class="timeline__body">
<div class="timeline__headline">
<strong>{{ review.decision | titlecase }}</strong>
<span>by {{ review.reviewerName }} ({{ review.reviewerId }})</span>
<span class="timeline__time">{{ review.reviewedAt | date:'medium' }}</span>
</div>
<p class="timeline__comment">{{ review.comment }}</p>
</div>
</li>
</ol>
</section>
</section>
`,
styles: [
`
:host {
display: block;
background: linear-gradient(145deg, #0f172a 0%, #0b1224 60%, #0a0f1f 100%);
color: #e5e7eb;
min-height: 100vh;
}
.approvals {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
}
.approvals__header {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.approvals__eyebrow {
margin: 0;
color: #a5b4fc;
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.8rem;
}
.approvals__lede {
margin: 0.2rem 0 0;
color: #cbd5e1;
}
.approvals__meta {
display: grid;
justify-items: end;
gap: 0.3rem;
}
.pill {
padding: 0.35rem 0.7rem;
border-radius: 999px;
border: 1px solid #334155;
font-weight: 700;
}
.pill--approved {
border-color: #22c55e;
color: #22c55e;
}
.pill--pending {
border-color: #f59e0b;
color: #f59e0b;
}
.approvals__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.card {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 15px 40px rgba(0,0,0,0.25);
}
.card h3 {
margin: 0;
color: #f8fafc;
}
.card p {
margin: 0.2rem 0 0;
color: #94a3b8;
}
.stack {
display: grid;
gap: 0.65rem;
margin-top: 0.75rem;
}
.field span {
display: block;
margin-bottom: 0.2rem;
color: #cbd5e1;
font-weight: 600;
}
textarea,
input {
width: 100%;
border: 1px solid #1f2937;
background: #0b1224;
color: #e5e7eb;
border-radius: 10px;
padding: 0.65rem;
font-family: 'Monaco','Consolas', monospace;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
background: #2563eb;
border: 1px solid #2563eb;
color: #e5e7eb;
border-radius: 10px;
padding: 0.55rem 0.9rem;
font-weight: 700;
cursor: pointer;
}
.btn:hover { background: #1d4ed8; }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn--warn { background: #f97316; border-color: #f97316; color: #0b1224; }
.btn--ghost { background: transparent; border-color: #334155; color: #cbd5e1; }
.timeline {
margin-top: 1rem;
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
}
.timeline__header h3 { margin: 0; color: #f8fafc; }
.timeline__header p { margin: 0.2rem 0 0; color: #94a3b8; }
ol { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.75rem; }
li { display: grid; grid-template-columns: auto 1fr; gap: 0.75rem; }
.dot {
width: 12px; height: 12px; border-radius: 50%; border: 2px solid #1f2937;
}
.dot[data-decision='approve'] { background: #22c55e; border-color: #22c55e; }
.dot[data-decision='reject'] { background: #ef4444; border-color: #ef4444; }
.dot[data-decision='request_changes'] { background: #f59e0b; border-color: #f59e0b; }
.timeline__headline { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.timeline__headline span { color: #94a3b8; }
.timeline__time { font-size: 0.9rem; }
.timeline__comment { margin: 0.15rem 0 0; color: #e5e7eb; }
@media (max-width: 960px) { .approvals__header { flex-direction: column; } }
`,
],
})
export class PolicyApprovalsComponent {
protected workflow?: ApprovalWorkflow;
protected loading = false;
protected submitting = false;
protected reviewing = false;
protected readonly submitForm = this.fb.group({
message: ['', [Validators.required, Validators.minLength(5)]],
coverageResults: [''],
simulationDiff: [''],
});
protected readonly reviewForm = this.fb.group({
comment: ['', [Validators.required, Validators.minLength(3)]],
});
private readonly fb = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly policyApi = inject(PolicyApiService);
private readonly auth = inject(AUTH_SERVICE) as AuthService;
get sortedReviews(): ApprovalReview[] {
if (!this.workflow?.reviews) return [];
return [...this.workflow.reviews].sort((a, b) => b.reviewedAt.localeCompare(a.reviewedAt) || a.reviewerId.localeCompare(b.reviewerId));
}
get isReadyToApprove(): boolean {
if (!this.workflow) return false;
return this.workflow.currentApprovers >= this.workflow.requiredApprovers;
}
ngOnInit(): void {
this.refresh();
}
onSubmit(): void {
const packId = this.route.snapshot.paramMap.get('packId');
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
if (!packId || this.submitForm.invalid) return;
const payload: PolicySubmissionRequest = {
policyId: packId,
version: version ?? 'latest',
message: this.submitForm.value.message ?? '',
coverageResults: this.submitForm.value.coverageResults ?? undefined,
simulationDiff: this.submitForm.value.simulationDiff ?? undefined,
};
this.submitting = true;
this.policyApi
.submitForReview(payload)
.pipe(finalize(() => (this.submitting = false)))
.subscribe({
next: () => this.refresh(),
});
}
onReview(decision: 'approve' | 'reject' | 'request_changes'): void {
if (!this.workflow || this.reviewForm.invalid) return;
if (decision === 'approve' && !this.auth.canApprovePolicies?.()) return;
if (decision !== 'approve' && !this.auth.canReviewPolicies?.()) return;
this.reviewing = true;
this.policyApi
.addReview(this.workflow.policyId, this.workflow.policyVersion, {
decision,
comment: this.reviewForm.value.comment ?? '',
})
.pipe(finalize(() => (this.reviewing = false)))
.subscribe({
next: () => this.refresh(),
});
}
private refresh(): void {
const packId = this.route.snapshot.paramMap.get('packId');
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
if (!packId) return;
this.loading = true;
this.policyApi
.getApprovalWorkflow(packId, version ?? 'latest')
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (wf) => (this.workflow = wf),
});
}
}

View File

@@ -0,0 +1,82 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { PolicyApiService } from '../services/policy-api.service';
import { PolicyDashboardComponent } from './policy-dashboard.component';
describe('PolicyDashboardComponent', () => {
let fixture: ComponentFixture<PolicyDashboardComponent>;
let component: PolicyDashboardComponent;
let api: jasmine.SpyObj<PolicyApiService>;
beforeEach(async () => {
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getRunDashboard']);
api.getRunDashboard.and.returnValue(
of({
policyId: 'pack-1',
runs: [
{
runId: 'run-b',
policyVersion: '1.0.1',
startedAt: '2025-12-05T02:00:00Z',
completedAt: '2025-12-05T02:10:00Z',
status: 'completed',
findingsCount: 10,
changedCount: 2,
},
{
runId: 'run-a',
policyVersion: '1.0.0',
startedAt: '2025-12-04T02:00:00Z',
completedAt: '2025-12-04T02:10:00Z',
status: 'completed',
findingsCount: 12,
changedCount: 1,
},
],
ruleHeatmap: [
{ ruleName: 'rule-x', hitCount: 30, lastHit: '2025-12-05', averageLatencyMs: 12 },
{ ruleName: 'rule-y', hitCount: 10, lastHit: '2025-12-05', averageLatencyMs: 9 },
],
vexWinsByDay: [],
suppressionsByDay: [],
}) as any
);
await TestBed.configureTestingModule({
imports: [CommonModule, ReactiveFormsModule, PolicyDashboardComponent],
providers: [
{ provide: PolicyApiService, useValue: api },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ packId: 'pack-1' }),
queryParamMap: convertToParamMap({}),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyDashboardComponent);
component = fixture.componentInstance;
});
it('sorts runs descending by completedAt', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(component.sortedRuns[0].runId).toBe('run-b');
}));
it('computes bar widths relative to max hits', fakeAsync(() => {
fixture.detectChanges();
tick();
const width = component.barWidth(15);
expect(width).toBeCloseTo(50);
}));
});

View File

@@ -0,0 +1,217 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { finalize } from 'rxjs/operators';
import { PolicyApiService } from '../services/policy-api.service';
import {
PolicyRunDashboard,
RuleHeatmapEntry,
TimeSeriesEntry,
} from '../models/policy.models';
@Component({
selector: 'app-policy-dashboard',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="dash" aria-busy="{{ loading }}">
<header class="dash__header">
<div>
<p class="dash__eyebrow">Policy Studio · Runs</p>
<h1>Run dashboards</h1>
<p class="dash__lede">Heatmap, VEX wins, and suppressions; deterministic ordering.</p>
</div>
<div class="dash__meta" *ngIf="dashboard">
<span class="pill">Runs: {{ dashboard.runs.length }}</span>
<span class="pill">Rules: {{ dashboard.ruleHeatmap.length }}</span>
</div>
</header>
<div class="dash__grid" *ngIf="dashboard">
<div class="card">
<header>
<h3>Recent runs</h3>
<p>Sorted by completedAt desc, stable tie-breaker runId.</p>
</header>
<form class="filters" [formGroup]="filterForm" (ngSubmit)="onFilter()">
<label>
<span>Start</span>
<input type="date" formControlName="start" />
</label>
<label>
<span>End</span>
<input type="date" formControlName="end" />
</label>
<button type="submit" class="btn">Apply</button>
<button type="button" class="btn btn--ghost" (click)="onExport('json')">Export JSON</button>
<button type="button" class="btn btn--ghost" (click)="onExport('csv')">Export CSV</button>
</form>
<ul class="run-list">
<li *ngFor="let run of sortedRuns">
<div>
<strong>{{ run.policyVersion }}</strong>
<span class="muted">· {{ run.status | titlecase }}</span>
</div>
<div class="muted">{{ run.completedAt | date:'medium' }}</div>
<div class="muted">Findings: {{ run.findingsCount }} · Changed: {{ run.changedCount }}</div>
</li>
</ul>
</div>
<div class="card">
<header>
<h3>Rule heatmap (top 8)</h3>
<p>Highest hit count first.</p>
</header>
<ul class="heatmap">
<li *ngFor="let rule of topRules">
<span class="heatmap__name">{{ rule.ruleName }}</span>
<div class="heatmap__bar">
<span class="heatmap__fill" [style.width.%]="barWidth(rule.hitCount)"></span>
<span class="heatmap__value">{{ rule.hitCount }}</span>
</div>
<span class="heatmap__latency">avg {{ rule.averageLatencyMs }} ms</span>
</li>
</ul>
</div>
<div class="card">
<header>
<h3>Daily deltas</h3>
<p>VEX wins and suppressions.</p>
</header>
<div class="chips">
<span class="chip" *ngFor="let v of dailyWins">{{ v.date }} · {{ v.value }} wins</span>
</div>
<div class="chips">
<span class="chip chip--muted" *ngFor="let s of dailySuppressions">{{ s.date }} · {{ s.value }} suppressions</span>
</div>
</div>
</div>
</section>
`,
styles: [
`
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
.dash { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.dash__header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
.dash__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
.dash__lede { margin: 0.2rem 0 0; color: #94a3b8; }
.dash__meta { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.pill { border: 1px solid #334155; padding: 0.35rem 0.7rem; border-radius: 999px; }
.dash__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; margin-top: 1rem; }
.card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 10px 30px rgba(0,0,0,0.25); }
.card h3 { margin: 0; color: #f8fafc; }
.card p { margin: 0.15rem 0 0; color: #94a3b8; }
.run-list { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.5rem; }
.run-list li { padding: 0.5rem 0.4rem; border-bottom: 1px solid #1f2937; }
.run-list li:last-child { border-bottom: none; }
.muted { color: #94a3b8; font-size: 0.9rem; }
.heatmap { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.65rem; }
.heatmap__name { color: #e5e7eb; font-weight: 600; }
.heatmap__bar { display: flex; align-items: center; gap: 0.4rem; background: #111827; border-radius: 999px; overflow: hidden; padding: 0.2rem; }
.heatmap__fill { display: block; height: 10px; background: linear-gradient(90deg, #22d3ee, #2563eb); border-radius: 999px; }
.heatmap__value { color: #cbd5e1; font-size: 0.85rem; }
.heatmap__latency { color: #94a3b8; font-size: 0.85rem; }
.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
.chip { border: 1px solid #334155; border-radius: 999px; padding: 0.25rem 0.6rem; background: #0b162e; }
.chip--muted { opacity: 0.8; }
`,
],
})
export class PolicyDashboardComponent {
protected dashboard?: PolicyRunDashboard;
protected loading = false;
private readonly route = inject(ActivatedRoute);
private readonly policyApi = inject(PolicyApiService);
private readonly fb = inject(FormBuilder);
protected readonly filterForm = this.fb.group({
start: [''],
end: [''],
});
get sortedRuns() {
if (!this.dashboard) return [];
return [...this.dashboard.runs].sort((a, b) =>
b.completedAt.localeCompare(a.completedAt) || b.runId.localeCompare(a.runId)
);
}
get topRules(): RuleHeatmapEntry[] {
if (!this.dashboard) return [];
return [...this.dashboard.ruleHeatmap].sort((a, b) => b.hitCount - a.hitCount).slice(0, 8);
}
get dailyWins(): TimeSeriesEntry[] {
if (!this.dashboard) return [];
return this.sortedSeries(this.dashboard.vexWinsByDay);
}
get dailySuppressions(): TimeSeriesEntry[] {
if (!this.dashboard) return [];
return this.sortedSeries(this.dashboard.suppressionsByDay);
}
ngOnInit(): void {
const packId = this.route.snapshot.paramMap.get('packId');
if (!packId) return;
this.loading = true;
this.policyApi
.getRunDashboard(packId)
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (dash) => (this.dashboard = dash),
});
}
onFilter(): void {
const packId = this.route.snapshot.paramMap.get('packId');
if (!packId) return;
this.loading = true;
const startDate = this.filterForm.value.start || undefined;
const endDate = this.filterForm.value.end || undefined;
this.policyApi
.getRunDashboard(packId, {
startDate,
endDate,
})
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (dash) => (this.dashboard = dash),
});
}
onExport(format: 'json' | 'csv'): void {
const packId = this.route.snapshot.paramMap.get('packId');
if (!packId) return;
this.policyApi
.exportResults(packId, format, {
startDate: this.filterForm.value.start || undefined,
endDate: this.filterForm.value.end || undefined,
})
.subscribe((blob) => {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `policy-run-${packId}.${format}`;
anchor.click();
URL.revokeObjectURL(url);
});
}
barWidth(hitCount: number): number {
const max = this.topRules[0]?.hitCount || 1;
return Math.min(100, (hitCount / max) * 100);
}
private sortedSeries(series: readonly TimeSeriesEntry[]): TimeSeriesEntry[] {
return [...series].sort((a, b) => a.date.localeCompare(b.date));
}
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@angular/core';
import type * as Monaco from 'monaco-editor';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import {
defineStellaDslTheme,
registerStellaDslLanguage,
} from './stella-dsl.language';
import { registerStellaDslCompletions } from './stella-dsl.completions';
type MonacoNamespace = typeof import('monaco-editor');
@Injectable({ providedIn: 'root' })
export class MonacoLoaderService {
private monacoPromise?: Promise<MonacoNamespace>;
/**
* Lazily loads Monaco editor with Stella DSL language + completions configured.
* Result is cached so multiple consumers reuse the same Monaco namespace.
*/
load(): Promise<MonacoNamespace> {
if (this.monacoPromise) {
return this.monacoPromise;
}
this.monacoPromise = import(
/* webpackChunkName: "monaco-editor" */
'monaco-editor/esm/vs/editor/editor.api'
).then((monaco) => {
this.configureWorkers(monaco);
registerStellaDslLanguage(monaco);
defineStellaDslTheme(monaco);
registerStellaDslCompletions(monaco);
return monaco;
});
return this.monacoPromise;
}
/**
* Configure Monaco web workers for language services.
* Ensures deterministic, offline-friendly loading (no CDN usage).
*/
private configureWorkers(monaco: MonacoNamespace): void {
const workerByLabel: Record<string, () => Worker> = {
json: () => new jsonWorker(),
css: () => new cssWorker(),
scss: () => new cssWorker(),
less: () => new cssWorker(),
html: () => new htmlWorker(),
handlebars: () => new htmlWorker(),
razor: () => new htmlWorker(),
javascript: () => new tsWorker(),
typescript: () => new tsWorker(),
default: () => new editorWorker(),
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - MonacoEnvironment lives on global scope
self.MonacoEnvironment = {
getWorker(_: unknown, label: string): Worker {
const factory = workerByLabel[label] ?? workerByLabel.default;
return factory();
},
};
// Set a deterministic default theme baseline (extended by defineStellaDslTheme)
monaco.editor.setTheme('vs-dark');
}
}

View File

@@ -0,0 +1,175 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import type * as Monaco from 'monaco-editor';
import { PolicyEditorComponent } from './policy-editor.component';
import { PolicyApiService } from '../services/policy-api.service';
import { MonacoLoaderService } from './monaco-loader.service';
describe('PolicyEditorComponent', () => {
let fixture: ComponentFixture<PolicyEditorComponent>;
let component: PolicyEditorComponent;
let policyApi: jasmine.SpyObj<PolicyApiService>;
let monacoLoader: MonacoLoaderStub;
beforeEach(async () => {
policyApi = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getPack', 'lint']);
monacoLoader = new MonacoLoaderStub();
policyApi.getPack.and.returnValue(
of({
id: 'pack-1',
name: 'Demo Policy',
description: 'Example policy for tests',
syntax: 'stella-dsl@1',
content: 'package "demo" { allow = true }',
version: '1.0.0',
status: 'draft',
metadata: { author: 'tester', tags: ['demo'] },
createdAt: '2025-12-01T00:00:00Z',
modifiedAt: '2025-12-02T00:00:00Z',
createdBy: 'tester',
modifiedBy: 'tester',
tags: ['demo', 'lint'],
digest: 'sha256:abc',
})
);
policyApi.lint.and.returnValue(
of({ valid: true, errors: [], warnings: [], info: [] }) as any
);
await TestBed.configureTestingModule({
imports: [CommonModule, PolicyEditorComponent],
providers: [
{ provide: PolicyApiService, useValue: policyApi },
{ provide: MonacoLoaderService, useValue: monacoLoader },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ packId: 'pack-1' }),
queryParamMap: convertToParamMap({}),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyEditorComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('loads pack content into the editor model', fakeAsync(() => {
tick();
expect(monacoLoader.model?.getValue()).toContain('package "demo"');
}));
it('applies lint diagnostics as Monaco markers', fakeAsync(() => {
const lintResult = {
valid: false,
errors: [
{
severity: 'error' as const,
code: 'E100',
message: 'Missing rule header',
line: 2,
column: 3,
source: 'policy-lint',
},
],
warnings: [],
info: [],
};
policyApi.lint.and.returnValue(of(lintResult) as any);
component.triggerLint();
tick();
expect(monacoLoader.lastMarkers.length).toBe(1);
expect(monacoLoader.lastMarkers[0].message).toContain('Missing rule header');
}));
});
class MonacoLoaderStub {
model: FakeModel = new FakeModel('');
editor: FakeEditor = new FakeEditor(this.model);
lastMarkers: Monaco.editor.IMarkerData[] = [];
load = jasmine.createSpy('load').and.callFake(async () => {
const self = this;
return {
editor: {
createModel: (value: string) => {
this.model = new FakeModel(value);
this.editor = new FakeEditor(this.model);
return this.model as unknown as Monaco.editor.ITextModel;
},
create: () => this.editor as unknown as Monaco.editor.IStandaloneCodeEditor,
setModelMarkers: (
_model: Monaco.editor.ITextModel,
_owner: string,
markers: Monaco.editor.IMarkerData[]
) => {
self.lastMarkers = markers;
},
},
languages: {
register: () => undefined,
setMonarchTokensProvider: () => undefined,
setLanguageConfiguration: () => undefined,
},
MarkerSeverity: {
Error: 8,
Warning: 4,
Info: 2,
},
} as unknown as MonacoNamespace;
});
}
class FakeModel {
private value: string;
constructor(initial: string) {
this.value = initial;
}
getValue(): string {
return this.value;
}
setValue(v: string): void {
this.value = v;
}
dispose(): void {
/* noop */
}
}
class FakeEditor {
private listeners: Array<() => void> = [];
constructor(private readonly model: FakeModel) {}
onDidChangeModelContent(cb: () => void): { dispose: () => void } {
this.listeners.push(cb);
return {
dispose: () => {
this.listeners = this.listeners.filter((l) => l !== cb);
},
};
}
getModel(): FakeModel {
return this.model;
}
}
type MonacoNamespace = typeof import('monaco-editor');

View File

@@ -0,0 +1,767 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
OnDestroy,
OnInit,
ViewChild,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { BehaviorSubject, Subscription, debounceTime, distinctUntilChanged } from 'rxjs';
import type * as Monaco from 'monaco-editor';
import {
type PolicyDiagnostic,
type PolicyLintResult,
type PolicyPack,
} from '../models/policy.models';
import { PolicyApiService } from '../services/policy-api.service';
import { MonacoLoaderService } from './monaco-loader.service';
type MonacoNamespace = typeof import('monaco-editor');
type ChecklistStatus = 'pass' | 'warn' | 'fail';
interface ChecklistItem {
readonly id: string;
readonly label: string;
readonly status: ChecklistStatus;
readonly hint: string;
}
@Component({
selector: 'app-policy-editor',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="policy-editor" aria-busy="{{ loadingPack }}">
<header class="policy-editor__header">
<div class="policy-editor__title">
<p class="policy-editor__eyebrow">Policy Studio · Authoring</p>
<h1>{{ pack?.name || 'Loading policy…' }}</h1>
<p class="policy-editor__subtitle" *ngIf="pack">
Version {{ pack.version }} · Status: {{ pack.status | titlecase }} ·
Digest: {{ pack.digest || 'pending' }}
</p>
</div>
<div class="policy-editor__meta" *ngIf="pack">
<div class="policy-editor__meta-item">
<span class="policy-editor__meta-label">Updated</span>
<span>{{ pack.modifiedAt | date: 'medium' }}</span>
</div>
<div class="policy-editor__meta-item">
<span class="policy-editor__meta-label">Authors</span>
<span>{{ pack.metadata?.author || pack.createdBy }}</span>
</div>
<div class="policy-editor__meta-item">
<span class="policy-editor__meta-label">Tags</span>
<span>{{ pack.tags?.length || 0 }}</span>
</div>
</div>
</header>
<div class="policy-editor__layout">
<section class="policy-editor__main" aria-label="Policy editor">
<div class="editor-shell" [class.editor-shell--loading]="loadingEditor">
<div #editorHost class="editor-shell__surface" aria-label="Stella DSL editor"></div>
<div class="editor-shell__overlay" *ngIf="loadingEditor">
<span class="skeleton skeleton--bar"></span>
<span class="skeleton skeleton--bar"></span>
<span class="skeleton skeleton--bar"></span>
</div>
</div>
<footer class="policy-editor__toolbar">
<div class="toolbar__left">
<button type="button" class="btn" (click)="triggerLint()" [disabled]="linting">
{{ linting ? 'Linting…' : 'Lint now' }}
</button>
<button type="button" class="btn btn--ghost" (click)="resetToPack()" [disabled]="linting || loadingPack">
Reset to last saved
</button>
</div>
<div class="toolbar__right">
<span class="status-pill" [class.status-pill--success]="diagnostics.length === 0" [class.status-pill--warn]="diagnostics.length > 0">
{{ diagnostics.length === 0 ? 'No diagnostics' : diagnostics.length + ' diagnostic(s)' }}
</span>
<span class="toolbar__timestamp" *ngIf="lastLintAt">Last lint: {{ lastLintAt }}</span>
</div>
</footer>
<section class="diagnostics" *ngIf="diagnostics.length">
<header class="diagnostics__header">
<h3>Inline diagnostics</h3>
<p>Errors and warnings are sorted deterministically by line and column.</p>
</header>
<ul class="diagnostics__list">
<li *ngFor="let diag of diagnostics" class="diagnostics__item">
<span class="diagnostics__severity" [attr.data-severity]="diag.severity">
{{ diag.severity | titlecase }}
</span>
<span class="diagnostics__message">{{ diag.message }}</span>
<span class="diagnostics__location">Line {{ diag.line }} · Col {{ diag.column }}</span>
<span class="diagnostics__code">{{ diag.code }}</span>
</li>
</ul>
</section>
</section>
<aside class="policy-editor__sidebar" aria-label="Compliance checklist">
<div class="sidebar-card">
<header class="sidebar-card__header">
<h3>Compliance checklist</h3>
<p>Must stay green before submit/review.</p>
</header>
<ul class="checklist">
<li *ngFor="let item of checklist" class="checklist__item">
<span class="checklist__status" [attr.data-status]="item.status"></span>
<div class="checklist__body">
<span class="checklist__label">{{ item.label }}</span>
<span class="checklist__hint">{{ item.hint }}</span>
</div>
</li>
</ul>
</div>
<div class="sidebar-card" *ngIf="pack">
<header class="sidebar-card__header">
<h3>Metadata</h3>
<p>Context used by review & simulation.</p>
</header>
<dl class="meta">
<div class="meta__row">
<dt>Description</dt>
<dd>{{ pack.description || 'Not provided' }}</dd>
</div>
<div class="meta__row">
<dt>Tags</dt>
<dd>{{ pack.tags?.length ? pack.tags.join(', ') : 'None' }}</dd>
</div>
<div class="meta__row">
<dt>Reviewers</dt>
<dd>{{ pack.metadata?.reviewers?.length ? pack.metadata?.reviewers?.join(', ') : 'Unassigned' }}</dd>
</div>
</dl>
</div>
</aside>
</div>
</section>
`,
styles: [
`
:host {
display: block;
background: #0b1224;
color: #e5e7eb;
min-height: 100vh;
}
.policy-editor {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
.policy-editor__header {
display: flex;
justify-content: space-between;
gap: 1.5rem;
align-items: flex-start;
margin-bottom: 1.25rem;
}
.policy-editor__title h1 {
margin: 0.1rem 0;
font-size: 1.8rem;
font-weight: 700;
color: #f9fafb;
}
.policy-editor__eyebrow {
margin: 0;
color: #93c5fd;
letter-spacing: 0.05em;
text-transform: uppercase;
font-size: 0.75rem;
}
.policy-editor__subtitle {
margin: 0;
color: #9ca3af;
font-size: 0.95rem;
}
.policy-editor__meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
min-width: 320px;
}
.policy-editor__meta-item {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 0.75rem 0.9rem;
color: #e5e7eb;
}
.policy-editor__meta-label {
display: block;
color: #9ca3af;
font-size: 0.8rem;
margin-bottom: 0.15rem;
}
.policy-editor__layout {
display: grid;
grid-template-columns: minmax(0, 2fr) 340px;
gap: 1.25rem;
}
.policy-editor__main {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
position: relative;
box-shadow: 0 15px 50px rgba(0, 0, 0, 0.35);
}
.editor-shell {
position: relative;
min-height: 540px;
border: 1px solid #1f2937;
border-radius: 10px;
overflow: hidden;
background: #0b1021;
}
.editor-shell__surface {
height: 540px;
}
.editor-shell__overlay {
position: absolute;
inset: 0;
display: grid;
gap: 0.6rem;
padding: 1.25rem;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.9), rgba(11, 17, 33, 0.9));
}
.editor-shell--loading .editor-shell__surface {
filter: blur(1px);
pointer-events: none;
}
.skeleton {
display: block;
background: linear-gradient(90deg, #1f2937, #111827, #1f2937);
background-size: 200% 100%;
animation: skeleton-loading 1.2s ease-in-out infinite;
border-radius: 6px;
}
.skeleton--bar {
height: 12px;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.policy-editor__toolbar {
margin-top: 0.75rem;
padding: 0.75rem 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
border-top: 1px solid #1f2937;
}
.toolbar__left,
.toolbar__right {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.btn {
border: 1px solid #2563eb;
background: #1d4ed8;
color: #e5e7eb;
border-radius: 8px;
padding: 0.55rem 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
.btn:hover {
background: #1e40af;
border-color: #1e40af;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--ghost {
background: transparent;
border-color: #374151;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.65rem;
border-radius: 999px;
border: 1px solid #374151;
color: #e5e7eb;
font-size: 0.85rem;
}
.status-pill--success {
border-color: #22c55e;
color: #22c55e;
}
.status-pill--warn {
border-color: #f59e0b;
color: #f59e0b;
}
.toolbar__timestamp {
color: #9ca3af;
font-size: 0.85rem;
}
.diagnostics {
margin-top: 1.25rem;
background: #0b1224;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 1rem;
}
.diagnostics__header h3 {
margin: 0;
color: #f9fafb;
}
.diagnostics__header p {
margin: 0.2rem 0 0;
color: #9ca3af;
}
.diagnostics__list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: grid;
gap: 0.5rem;
}
.diagnostics__item {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 0.75rem;
align-items: center;
padding: 0.65rem 0.75rem;
border: 1px solid #1f2937;
border-radius: 8px;
background: #0f172a;
}
.diagnostics__severity {
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
border: 1px solid #374151;
}
.diagnostics__severity[data-severity='error'] {
border-color: #ef4444;
color: #ef4444;
}
.diagnostics__severity[data-severity='warning'] {
border-color: #f59e0b;
color: #f59e0b;
}
.diagnostics__severity[data-severity='info'] {
border-color: #38bdf8;
color: #38bdf8;
}
.diagnostics__message {
color: #e5e7eb;
}
.diagnostics__location,
.diagnostics__code {
color: #9ca3af;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.8rem;
}
.policy-editor__sidebar {
display: grid;
gap: 1rem;
}
.sidebar-card {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
}
.sidebar-card__header h3 {
margin: 0;
color: #f9fafb;
}
.sidebar-card__header p {
margin: 0.2rem 0 0;
color: #9ca3af;
font-size: 0.9rem;
}
.checklist {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: grid;
gap: 0.5rem;
}
.checklist__item {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
align-items: center;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 0.55rem 0.65rem;
background: #0b1224;
}
.checklist__status {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #1f2937;
}
.checklist__status[data-status='pass'] {
border-color: #22c55e;
background: #22c55e;
}
.checklist__status[data-status='warn'] {
border-color: #f59e0b;
background: #f59e0b;
}
.checklist__status[data-status='fail'] {
border-color: #ef4444;
background: #ef4444;
}
.checklist__label {
display: block;
color: #e5e7eb;
font-weight: 600;
margin-bottom: 0.1rem;
}
.checklist__hint {
color: #9ca3af;
font-size: 0.85rem;
}
.meta {
margin: 0.5rem 0 0;
}
.meta__row {
margin: 0.4rem 0;
}
.meta__row dt {
color: #9ca3af;
font-size: 0.85rem;
}
.meta__row dd {
margin: 0.05rem 0 0;
color: #e5e7eb;
}
@media (max-width: 1080px) {
.policy-editor__layout {
grid-template-columns: 1fr;
}
}
`,
],
})
export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('editorHost', { static: false })
private editorHost?: ElementRef<HTMLDivElement>;
protected pack?: PolicyPack;
protected diagnostics: PolicyDiagnostic[] = [];
protected checklist: ChecklistItem[] = [];
protected linting = false;
protected loadingPack = true;
protected loadingEditor = true;
protected lastLintAt?: string;
private readonly route = inject(ActivatedRoute);
private readonly policyApi = inject(PolicyApiService);
private readonly monacoLoader = inject(MonacoLoaderService);
private readonly cdr = inject(ChangeDetectorRef);
private monaco?: MonacoNamespace;
private model?: Monaco.editor.ITextModel;
private editor?: Monaco.editor.IStandaloneCodeEditor;
private readonly content$ = new BehaviorSubject<string>('');
private readonly subscriptions = new Subscription();
ngOnInit(): void {
const packId = this.route.snapshot.paramMap.get('packId');
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
if (!packId) {
this.loadingPack = false;
return;
}
this.loadPack(packId, version ?? undefined);
this.subscriptions.add(
this.content$
.pipe(debounceTime(400), distinctUntilChanged())
.subscribe((content) => this.runLint(content))
);
}
ngAfterViewInit(): void {
this.initialiseEditor();
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
if (this.model && this.monaco) {
this.monaco.editor.setModelMarkers(this.model, 'policy-lint', []);
this.model.dispose();
}
this.editor?.dispose();
}
triggerLint(): void {
this.runLint(this.content$.value);
}
resetToPack(): void {
if (!this.pack || !this.model) return;
this.model.setValue(this.pack.content ?? '');
}
private loadPack(packId: string, version?: string): void {
this.loadingPack = true;
this.policyApi.getPack(packId, version).subscribe({
next: (pack) => {
this.pack = pack;
this.loadingPack = false;
this.content$.next(pack.content ?? '');
this.updateChecklist();
this.cdr.markForCheck();
},
error: () => {
this.loadingPack = false;
this.cdr.markForCheck();
},
});
}
private initialiseEditor(): void {
const host = this.editorHost?.nativeElement;
if (!host) {
return;
}
this.monacoLoader
.load()
.then((monaco) => {
this.monaco = monaco;
this.model = monaco.editor.createModel(
this.content$.value,
'stella-dsl'
);
this.editor = monaco.editor.create(host, {
model: this.model,
language: 'stella-dsl',
theme: 'stella-dsl-dark',
fontSize: 14,
automaticLayout: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderWhitespace: 'boundary',
ariaLabel: 'Policy DSL editor',
});
this.subscriptions.add(
this.editor.onDidChangeModelContent(() => {
const value = this.model?.getValue() ?? '';
this.content$.next(value);
})
);
this.loadingEditor = false;
this.cdr.markForCheck();
})
.catch(() => {
this.loadingEditor = false;
this.cdr.markForCheck();
});
}
private runLint(content: string): void {
if (!content || !this.model || !this.monaco) {
return;
}
this.linting = true;
this.policyApi.lint(content).subscribe({
next: (lint) => {
this.applyDiagnostics(lint);
this.lastLintAt = new Date().toISOString();
this.updateChecklist(lint);
},
error: () => {
this.diagnostics = [
{
severity: 'error',
code: 'lint/failed',
message: 'Lint request failed. Please retry.',
line: 1,
column: 1,
source: 'policy-lint',
},
];
this.applyMarkers(this.diagnostics);
},
complete: () => {
this.linting = false;
this.cdr.markForCheck();
},
});
}
private applyDiagnostics(lint: PolicyLintResult): void {
const sorted = [...lint.errors, ...lint.warnings, ...lint.info].sort((a, b) => {
if (a.line === b.line) return a.column - b.column;
return a.line - b.line;
});
this.diagnostics = sorted;
this.applyMarkers(sorted);
this.cdr.markForCheck();
}
private applyMarkers(diagnostics: PolicyDiagnostic[]): void {
if (!this.monaco || !this.model) return;
const markers: Monaco.editor.IMarkerData[] = diagnostics.map((diag) => ({
startLineNumber: diag.line,
startColumn: diag.column,
endLineNumber: diag.endLine ?? diag.line,
endColumn: diag.endColumn ?? diag.column + 1,
message: diag.message,
code: diag.code,
severity: this.mapSeverity(diag.severity),
source: diag.source,
}));
this.monaco.editor.setModelMarkers(this.model, 'policy-lint', markers);
}
private mapSeverity(severity: PolicyDiagnostic['severity']): Monaco.MarkerSeverity {
if (!this.monaco) return 0 as unknown as Monaco.MarkerSeverity;
switch (severity) {
case 'error':
return this.monaco.MarkerSeverity.Error;
case 'warning':
return this.monaco.MarkerSeverity.Warning;
default:
return this.monaco.MarkerSeverity.Info;
}
}
private updateChecklist(lint?: PolicyLintResult): void {
const lintErrors = lint?.errors?.length ?? 0;
const lintWarnings = lint?.warnings?.length ?? 0;
const descriptionOk = !!this.pack?.description?.trim();
const tagsCount = this.pack?.tags?.length ?? 0;
const reviewers = this.pack?.metadata?.reviewers?.length ?? 0;
const items: ChecklistItem[] = [
{
id: 'lint-clean',
label: 'Lint clean (blocking errors)',
status: lintErrors === 0 ? 'pass' : 'fail',
hint: lintErrors === 0 ? 'No blocking errors detected' : `${lintErrors} error(s) to fix`,
},
{
id: 'lint-warn',
label: 'Warnings reviewed',
status: lintWarnings === 0 ? 'pass' : 'warn',
hint: lintWarnings === 0 ? 'No warnings' : `${lintWarnings} warning(s) to review`,
},
{
id: 'description',
label: 'Description present',
status: descriptionOk ? 'pass' : 'fail',
hint: descriptionOk ? 'Ready for reviewers' : 'Add a concise description',
},
{
id: 'tags',
label: 'At least two tags',
status: tagsCount >= 2 ? 'pass' : tagsCount === 1 ? 'warn' : 'fail',
hint: tagsCount >= 2 ? 'Tag coverage OK' : 'Add two or more tags for routing',
},
{
id: 'reviewers',
label: 'Two-person review assigned',
status: reviewers >= 2 ? 'pass' : reviewers === 1 ? 'warn' : 'fail',
hint: reviewers >= 2 ? 'Dual approval ready' : 'Add at least two reviewers',
},
];
this.checklist = items;
}
}

View File

@@ -0,0 +1,65 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { PolicyApiService } from '../services/policy-api.service';
import { PolicyExplainComponent } from './policy-explain.component';
describe('PolicyExplainComponent', () => {
let fixture: ComponentFixture<PolicyExplainComponent>;
let component: PolicyExplainComponent;
let api: jasmine.SpyObj<PolicyApiService>;
beforeEach(async () => {
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getSimulationResult']);
api.getSimulationResult.and.returnValue(
of({
runId: 'run-1',
policyId: 'pack-1',
policyVersion: '1.0.0',
status: 'completed',
summary: { totalFindings: 1, byStatus: {}, bySeverity: {}, ruleHits: [], vexWins: 0, suppressions: 0 },
findings: [
{
componentPurl: 'pkg:npm/a@1',
advisoryId: 'ADV-1',
status: 'new',
severity: { band: 'high' },
matchedRules: [],
annotations: {},
},
],
explainTrace: [
{ step: 1, ruleName: 'rule-a', priority: 10, matched: true, inputs: {}, outputs: {} },
],
executedAt: '',
durationMs: 0,
}) as any
);
await TestBed.configureTestingModule({
imports: [CommonModule, PolicyExplainComponent],
providers: [
{ provide: PolicyApiService, useValue: api },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ runId: 'run-1' }),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyExplainComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders explain trace from API', () => {
expect(component['result']?.explainTrace?.length).toBe(1);
});
});

View File

@@ -0,0 +1,118 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PolicyApiService } from '../services/policy-api.service';
import { SimulationResult } from '../models/policy.models';
@Component({
selector: 'app-policy-explain',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="expl" aria-busy="{{ loading }}">
<header class="expl__header" *ngIf="result">
<div>
<p class="expl__eyebrow">Policy Studio · Explain</p>
<h1>Run {{ result.runId }}</h1>
<p class="expl__lede">Policy {{ result.policyId }} · Version {{ result.policyVersion }}</p>
</div>
<div class="expl__meta">
<button type="button" (click)="exportJson()">Export JSON</button>
<button type="button" disabled title="PDF export pending backend">Export PDF</button>
</div>
</header>
<div *ngIf="result" class="expl__grid">
<section class="card">
<header>
<h3>Explain trace</h3>
<p>Deterministic order by step.</p>
</header>
<ol>
<li *ngFor="let e of result.explainTrace">
<strong>Step {{ e.step }} · {{ e.ruleName }}</strong>
<span>Matched: {{ e.matched }}</span>
<span>Priority: {{ e.priority }}</span>
<div class="expl__json">
<pre><code>{{ formatJson(e.outputs) }}</code></pre>
</div>
</li>
</ol>
</section>
<section class="card">
<header>
<h3>Findings snapshot</h3>
<p>{{ result.findings.length }} findings sorted deterministically.</p>
</header>
<ul>
<li *ngFor="let f of sortedFindings">
{{ f.componentPurl }} · {{ f.advisoryId }} · {{ f.status }} · {{ f.severity.band | titlecase }}
</li>
</ul>
</section>
</div>
</section>
`,
styles: [
`
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
.expl { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.expl__header { display: flex; justify-content: space-between; align-items: center; }
.expl__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
.expl__lede { margin: 0.2rem 0 0; color: #94a3b8; }
.expl__meta { display: flex; gap: 0.5rem; }
.expl__grid { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; margin-top: 1rem; }
.card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
ol { margin: 0.5rem 0 0; padding-left: 1.25rem; }
li { margin-bottom: 0.6rem; }
.expl__json pre { margin: 0.35rem 0 0; background: #0b1224; border: 1px solid #1f2937; border-radius: 8px; padding: 0.6rem; max-height: 240px; overflow: auto; }
@media (max-width: 1024px) { .expl__grid { grid-template-columns: 1fr; } }
`,
],
})
export class PolicyExplainComponent {
protected loading = true;
protected result?: SimulationResult;
private readonly route = inject(ActivatedRoute);
private readonly api = inject(PolicyApiService);
constructor() {
const runId = this.route.snapshot.paramMap.get('runId');
if (runId) {
this.api.getSimulationResult(runId).subscribe((res) => {
this.result = res;
this.loading = false;
});
}
}
protected get sortedFindings() {
if (!this.result) return [];
return [...this.result.findings].sort((a, b) =>
a.status.localeCompare(b.status) || a.componentPurl.localeCompare(b.componentPurl)
);
}
protected formatJson(obj: unknown): string {
try {
return JSON.stringify(obj, Object.keys(obj as Record<string, unknown>).sort(), 2);
} catch {
return JSON.stringify(obj, null, 2);
}
}
protected exportJson(): void {
if (!this.result) return;
const blob = new Blob([JSON.stringify(this.result, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `policy-explain-${this.result.runId}.json`;
a.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -11,6 +11,14 @@
// Editor (Monaco language definition)
export * from './editor';
export * from './editor/monaco-loader.service';
export * from './editor/policy-editor.component';
export * from './simulation/policy-simulation.component';
export * from './approvals/policy-approvals.component';
export * from './dashboard/policy-dashboard.component';
export * from './workspace/policy-workspace.component';
export * from './yaml/policy-yaml-editor.component';
export * from './rule-builder/policy-rule-builder.component';
// Models
export * from './models';

View File

@@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { PolicyRuleBuilderComponent } from './policy-rule-builder.component';
describe('PolicyRuleBuilderComponent', () => {
let fixture: ComponentFixture<PolicyRuleBuilderComponent>;
let component: PolicyRuleBuilderComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CommonModule, ReactiveFormsModule, PolicyRuleBuilderComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ packId: 'pack-xyz' }),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyRuleBuilderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('sorts exceptions deterministically in preview JSON', () => {
component.form.patchValue({ exceptions: 'b, a' });
const preview = component.previewJson();
expect(preview).toContain('"exceptions": [\n "a",\n "b"');
});
});

View File

@@ -0,0 +1,126 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, computed, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-policy-rule-builder',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="rb" aria-busy="false">
<header class="rb__header">
<div>
<p class="rb__eyebrow">Policy Studio · Rule Builder</p>
<h1>Guided rule construction</h1>
<p class="rb__lede">Deterministic preview JSON updates as you edit.</p>
</div>
<div class="rb__meta">
<span>Pack: {{ packId }}</span>
</div>
</header>
<div class="rb__grid">
<form [formGroup]="form" class="rb__form">
<label>
<span>Source preference</span>
<select formControlName="source">
<option value="nvd">NVD</option>
<option value="vendor">Vendor first</option>
<option value="kev">Known Exploited (KEV)</option>
</select>
</label>
<label>
<span>Severity floor (min)</span>
<input type="number" min="0" max="10" step="0.1" formControlName="severityMin" />
</label>
<label>
<span>VEX precedence</span>
<select formControlName="vexPrecedence">
<option value="evidence">Evidence-first</option>
<option value="vex">VEX-first</option>
<option value="blend">Blended</option>
</select>
</label>
<label>
<span>Exceptions (comma-separated PURLs)</span>
<input formControlName="exceptions" placeholder="pkg:npm/lodash@4.17.21, pkg:pypi/django@4.2.7" />
</label>
<label>
<span>Quiet provenance (mute noisy sources)</span>
<select formControlName="quiet">
<option value="none">None</option>
<option value="low">Low-priority feeds</option>
<option value="all">Mute all noisy feeds</option>
</select>
</label>
</form>
<aside class="rb__preview" aria-label="Rule preview">
<header>
<h3>Preview JSON</h3>
<p>Sorted keys for deterministic diffs.</p>
</header>
<pre><code>{{ previewJson() }}</code></pre>
</aside>
</div>
</section>
`,
styles: [
`
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
.rb { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.rb__header { display: flex; justify-content: space-between; gap: 1rem; }
.rb__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
.rb__lede { margin: 0.2rem 0 0; color: #94a3b8; }
.rb__grid { display: grid; grid-template-columns: minmax(0, 1.1fr) 1fr; gap: 1rem; margin-top: 1rem; }
.rb__form { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; display: grid; gap: 0.65rem; }
label { display: grid; gap: 0.25rem; color: #cbd5e1; }
input, select { background: #0b1224; color: #e5e7eb; border: 1px solid #1f2937; border-radius: 8px; padding: 0.5rem; }
.rb__preview { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
pre { margin: 0; max-height: 420px; overflow: auto; background: #0b1224; border: 1px solid #1f2937; border-radius: 10px; padding: 0.75rem; }
@media (max-width: 1024px) { .rb__grid { grid-template-columns: 1fr; } }
`,
],
})
export class PolicyRuleBuilderComponent {
protected packId?: string;
protected readonly form = this.fb.nonNullable.group({
source: 'nvd',
severityMin: 4,
vexPrecedence: 'vex',
exceptions: '',
quiet: 'none',
});
private readonly route = inject(ActivatedRoute);
private readonly fb = inject(FormBuilder);
constructor() {
this.packId = this.route.snapshot.paramMap.get('packId') || undefined;
}
protected previewJson = computed(() => {
const value = this.form.getRawValue();
const exceptions = value.exceptions
.split(',')
.map((v) => v.trim())
.filter(Boolean)
.sort();
const json = {
sourcePreference: value.source,
severityFloor: Number(value.severityMin),
vexPrecedence: value.vexPrecedence,
exceptions,
quiet: value.quiet,
};
return JSON.stringify(json, Object.keys(json).sort(), 2);
});
}

View File

@@ -3,3 +3,4 @@
*/
export { PolicyApiService } from './policy-api.service';
export { PolicyPackStore } from './policy-pack.store';

View File

@@ -0,0 +1,53 @@
import { Injectable, inject } from '@angular/core';
import { BehaviorSubject, Observable, catchError, finalize, of } from 'rxjs';
import { filter } from 'rxjs/operators';
import { PolicyPackSummary } from '../models/policy.models';
import { PolicyApiService } from './policy-api.service';
@Injectable({ providedIn: 'root' })
export class PolicyPackStore {
private readonly api = inject(PolicyApiService);
private readonly packs$ = new BehaviorSubject<PolicyPackSummary[] | null>(null);
private loading = false;
getPacks(): Observable<PolicyPackSummary[]> {
if (!this.packs$.value && !this.loading) {
this.fetch();
}
return this.packs$.pipe(filter((p): p is PolicyPackSummary[] => Array.isArray(p)));
}
refresh(): void {
this.fetch();
}
private fetch(): void {
this.loading = true;
this.api
.listPacks({ limit: 50 })
.pipe(
catchError(() => of(this.fallbackPacks())),
finalize(() => (this.loading = false))
)
.subscribe((packs) => this.packs$.next(packs));
}
private fallbackPacks(): PolicyPackSummary[] {
return [
{
id: 'pack-1',
name: 'Core Policy Pack',
description: '',
version: 'latest',
status: 'active',
createdAt: '',
modifiedAt: '',
createdBy: '',
modifiedBy: '',
tags: [],
},
];
}
}

View File

@@ -0,0 +1,104 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { PolicyApiService } from '../services/policy-api.service';
import { PolicySimulationComponent } from './policy-simulation.component';
describe('PolicySimulationComponent', () => {
let fixture: ComponentFixture<PolicySimulationComponent>;
let component: PolicySimulationComponent;
let api: jasmine.SpyObj<PolicyApiService>;
beforeEach(async () => {
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['simulate']);
api.simulate.and.returnValue(
of({
runId: 'run-1',
policyId: 'pack-1',
policyVersion: '1.0.0',
status: 'completed',
summary: {
totalFindings: 2,
byStatus: {},
bySeverity: {},
ruleHits: [],
vexWins: 1,
suppressions: 0,
},
findings: [
{
componentPurl: 'pkg:npm/a@1',
advisoryId: 'ADV-1',
status: 'new',
severity: { band: 'high', score: 8 },
matchedRules: ['r1'],
annotations: {},
},
{
componentPurl: 'pkg:npm/b@1',
advisoryId: 'ADV-0',
status: 'new',
severity: { band: 'critical', score: 9.5 },
matchedRules: ['r2'],
annotations: {},
},
],
diff: {
added: [
{ componentPurl: 'pkg:npm/a@1', advisoryId: 'ADV-1', reason: '' },
{ componentPurl: 'pkg:npm/b@1', advisoryId: 'ADV-0', reason: '' },
],
removed: [],
changed: [],
statusDeltas: {},
severityDeltas: {},
},
explainTrace: [],
executedAt: '2025-12-05T00:00:00Z',
durationMs: 1200,
})
);
await TestBed.configureTestingModule({
imports: [CommonModule, ReactiveFormsModule, PolicySimulationComponent],
providers: [
{ provide: PolicyApiService, useValue: api },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ packId: 'pack-1' }),
queryParamMap: convertToParamMap({}),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicySimulationComponent);
component = fixture.componentInstance;
});
it('sorts findings deterministically (status, severity, component)', fakeAsync(() => {
fixture.detectChanges();
component.onRun();
tick();
const findings = component.sortedFindings;
expect(findings[0].advisoryId).toBe('ADV-0');
expect(findings[1].advisoryId).toBe('ADV-1');
}));
it('sorts diff entries by component then advisory', fakeAsync(() => {
fixture.detectChanges();
component.onRun();
tick();
const diff = component['result']?.diff;
expect(diff?.added.map((d) => d.advisoryId)).toEqual(['ADV-0', 'ADV-1']);
}));
});

View File

@@ -0,0 +1,558 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { finalize } from 'rxjs/operators';
import {
type SimulationDiff,
type SimulationResult,
type SimulationRequest,
ExplainEntry,
} from '../models/policy.models';
import { PolicyApiService } from '../services/policy-api.service';
@Component({
selector: 'app-policy-simulation',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="sim" aria-busy="{{ loading }}">
<header class="sim__header">
<div>
<p class="sim__eyebrow">Policy Studio · Simulation</p>
<h1>What-if analysis</h1>
<p class="sim__lede">
Run deterministic diffs against the active policy to preview impact before promote.
</p>
</div>
<div class="sim__meta">
<span class="pill" [class.pill--ok]="result?.status === 'completed'" [class.pill--warn]="result?.status === 'failed'">
{{ result ? result.status : 'Idle' | titlecase }}
</span>
<span class="sim__timestamp" *ngIf="result">Completed at {{ result.executedAt | date:'medium' }}</span>
</div>
</header>
<div class="sim__layout">
<form class="sim__form" [formGroup]="form" (ngSubmit)="onRun()" aria-label="Simulation input">
<label class="field">
<span>Components (purl, comma-separated)</span>
<textarea formControlName="components" rows="3" placeholder="pkg:pypi/django@4.2.7, pkg:npm/lodash@4.17.21"></textarea>
</label>
<label class="field">
<span>SBOM</span>
<select formControlName="sbomId">
<option value="">-- optional --</option>
<option *ngFor="let s of sboms" [value]="s">{{ s }}</option>
</select>
</label>
<label class="field">
<span>Advisories (IDs, comma-separated)</span>
<input formControlName="advisories" placeholder="CVE-2025-0001, GHSA-1234" list="adv-suggest" />
<datalist id="adv-suggest">
<option *ngFor="let adv of advisoryOptions" [value]="adv"></option>
</datalist>
</label>
<label class="field">
<span>Environment label (optional)</span>
<input formControlName="environment" placeholder="staging-us-east" />
</label>
<label class="field">
<span>Include explain trace</span>
<input type="checkbox" formControlName="includeExplain" />
</label>
<label class="field">
<span>Diff against active policy</span>
<input type="checkbox" formControlName="diffAgainstActive" />
</label>
<button type="submit" class="btn" [disabled]="loading || form.invalid">
{{ loading ? 'Running…' : 'Run simulation' }}
</button>
</form>
<aside class="sim__summary" *ngIf="result">
<div class="card">
<header>
<h3>Summary</h3>
<p>Deterministic ordering by status → severity → component.</p>
</header>
<dl>
<div>
<dt>Total findings</dt>
<dd>{{ result.summary.totalFindings }}</dd>
</div>
<div>
<dt>VEX wins</dt>
<dd>{{ result.summary.vexWins }}</dd>
</div>
<div>
<dt>Suppressions</dt>
<dd>{{ result.summary.suppressions }}</dd>
</div>
</dl>
<div class="chips">
<span class="chip" *ngFor="let s of severityBands">{{ s.label }}: {{ s.count }}</span>
</div>
</div>
<div class="card" *ngIf="result.diff">
<header>
<h3>Diff</h3>
<p>Added/removed/changed findings vs active policy.</p>
</header>
<div class="diff-grid">
<div class="diff-grid__column">
<h4>Added ({{ result.diff.added.length }})</h4>
<ul>
<li *ngFor="let item of result.diff.added">{{ item.componentPurl }} · {{ item.advisoryId }}</li>
</ul>
</div>
<div class="diff-grid__column">
<h4>Removed ({{ result.diff.removed.length }})</h4>
<ul>
<li *ngFor="let item of result.diff.removed">{{ item.componentPurl }} · {{ item.advisoryId }}</li>
</ul>
</div>
<div class="diff-grid__column">
<h4>Changed ({{ result.diff.changed.length }})</h4>
<ul>
<li *ngFor="let item of result.diff.changed">{{ item.componentPurl }} · {{ item.advisoryId }} ({{ item.reason }})</li>
</ul>
</div>
</div>
</div>
<div class="card" *ngIf="result.summary.ruleHits?.length">
<header>
<h3>Rule hit summary</h3>
<p>Top rules by hit count.</p>
</header>
<ul class="rule-list">
<li *ngFor="let hit of result.summary.ruleHits | slice:0:8">
<span class="rule-list__name">{{ hit.ruleName }}</span>
<span class="rule-list__count">{{ hit.hitCount }} hits</span>
</li>
</ul>
</div>
</aside>
</div>
<section class="table" *ngIf="result">
<header class="table__header">
<h3>Deterministic findings table</h3>
<p>Sorted: status → severity → component → advisory.</p>
</header>
<div class="table__scroll">
<table>
<thead>
<tr>
<th>Status</th>
<th>Severity</th>
<th>Component</th>
<th>Advisory</th>
<th>Rules</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let f of sortedFindings">
<td>{{ f.status }}</td>
<td>{{ f.severity.band | titlecase }} {{ f.severity.score || '' }}</td>
<td>{{ f.componentPurl }}</td>
<td>{{ f.advisoryId }}</td>
<td>{{ f.matchedRules.join(', ') }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="explain" *ngIf="explainTrace.length">
<header class="table__header">
<h3>Explain trace</h3>
<p>Deterministic order by step.</p>
</header>
<ol>
<li *ngFor="let e of explainTrace">
<strong>Step {{ e.step }} · {{ e.ruleName }}</strong>
<span>Matched: {{ e.matched }}</span>
<span>Priority: {{ e.priority }}</span>
</li>
</ol>
</section>
</section>
`,
styles: [
`
:host {
display: block;
background: radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.08), transparent 25%),
radial-gradient(circle at 80% 0%, rgba(16, 185, 129, 0.08), transparent 22%),
#0b1224;
min-height: 100vh;
color: #e5e7eb;
}
.sim {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
.sim__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.sim__eyebrow {
margin: 0;
color: #a5b4fc;
font-size: 0.8rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.sim__lede {
margin: 0.25rem 0 0;
color: #cbd5e1;
}
.sim__meta {
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: flex-end;
}
.pill {
border: 1px solid #334155;
padding: 0.35rem 0.65rem;
border-radius: 999px;
font-weight: 600;
}
.pill--ok {
border-color: #22c55e;
color: #22c55e;
}
.pill--warn {
border-color: #f97316;
color: #f97316;
}
.sim__timestamp {
color: #94a3b8;
font-size: 0.9rem;
}
.sim__layout {
display: grid;
grid-template-columns: minmax(0, 1.4fr) 1fr;
gap: 1rem;
align-items: start;
}
.sim__form {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
display: grid;
gap: 0.75rem;
}
.field span {
display: block;
margin-bottom: 0.25rem;
color: #cbd5e1;
font-weight: 600;
}
textarea,
input[type='text'],
select,
input[type='checkbox'] {
width: 100%;
border: 1px solid #1f2937;
background: #0b1224;
color: #e5e7eb;
border-radius: 8px;
padding: 0.65rem;
font-family: 'Monaco', 'Consolas', monospace;
}
input[type='checkbox'] {
width: auto;
}
.btn {
justify-self: start;
background: linear-gradient(120deg, #2563eb, #22d3ee);
border: 1px solid #2563eb;
color: #0b1224;
font-weight: 700;
padding: 0.6rem 1rem;
border-radius: 10px;
cursor: pointer;
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.25);
transition: transform 120ms ease, box-shadow 120ms ease;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 36px rgba(37, 99, 235, 0.35);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sim__summary {
display: grid;
gap: 0.75rem;
}
.card {
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
}
.card h3 {
margin: 0;
color: #f8fafc;
}
.card p {
margin: 0.2rem 0 0;
color: #94a3b8;
}
dl {
margin: 0.6rem 0 0;
display: grid;
gap: 0.35rem;
}
dt {
color: #94a3b8;
font-size: 0.9rem;
}
dd {
margin: 0;
color: #e5e7eb;
font-weight: 700;
}
.diff-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.diff-grid h4 {
margin: 0 0 0.2rem;
color: #cbd5e1;
}
.diff-grid ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.25rem;
color: #94a3b8;
}
.table {
margin-top: 1.2rem;
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
}
.table__header h3 {
margin: 0;
color: #f8fafc;
}
.table__header p {
margin: 0.2rem 0 0;
color: #94a3b8;
}
.table__scroll {
overflow: auto;
margin-top: 0.75rem;
border: 1px solid #1f2937;
border-radius: 10px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
th,
td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid #1f2937;
text-align: left;
}
th {
background: #0b1224;
color: #cbd5e1;
position: sticky;
top: 0;
}
tr:last-child td {
border-bottom: none;
}
@media (max-width: 1024px) {
.sim__layout {
grid-template-columns: 1fr;
}
}
`,
],
})
export class PolicySimulationComponent {
protected loading = false;
protected result?: SimulationResult;
protected explainTrace: ExplainEntry[] = [];
protected readonly form = this.fb.group({
components: [''],
advisories: [''],
environment: [''],
includeExplain: [false],
diffAgainstActive: [true],
sbomId: [''],
});
protected readonly sboms = ['sbom-dev-001', 'sbom-prod-2024-11', 'sbom-preprod-05'];
protected readonly advisoryOptions = ['CVE-2025-0001', 'GHSA-1234', 'CVE-2024-9999'];
private readonly fb = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly policyApi = inject(PolicyApiService);
get severityBands() {
if (!this.result) return [];
const order: Array<{ key: string; label: string }> = [
{ key: 'critical', label: 'Critical' },
{ key: 'high', label: 'High' },
{ key: 'medium', label: 'Medium' },
{ key: 'low', label: 'Low' },
{ key: 'none', label: 'None' },
];
return order.map(({ key, label }) => ({
label,
count: this.result?.summary.bySeverity?.[key] ?? 0,
}));
}
get sortedFindings() {
if (!this.result) return [];
return [...this.result.findings].sort((a, b) => {
if (a.status === b.status) {
if (a.severity.band === b.severity.band) {
if (a.componentPurl === b.componentPurl) {
return a.advisoryId.localeCompare(b.advisoryId);
}
return a.componentPurl.localeCompare(b.componentPurl);
}
const order = ['critical', 'high', 'medium', 'low', 'none'];
return order.indexOf(a.severity.band) - order.indexOf(b.severity.band);
}
return a.status.localeCompare(b.status);
});
}
onRun(): void {
const packId = this.route.snapshot.paramMap.get('packId');
if (!packId) return;
const request: SimulationRequest = {
policyId: packId,
scope: {
components: this.splitList(this.form.value.components),
advisories: this.splitList(this.form.value.advisories),
sbomId: this.form.value.sbomId || undefined,
environment: this.form.value.environment || undefined,
},
inputs: {},
options: {
includeExplainTrace: this.form.value.includeExplain ?? false,
diffAgainstActive: this.form.value.diffAgainstActive ?? true,
},
};
this.loading = true;
this.policyApi
.simulate(request)
.pipe(finalize(() => (this.loading = false)))
.subscribe({
next: (res) => {
this.result = this.sortDiff(res);
this.explainTrace = res.explainTrace ?? [];
this.form.markAsPristine();
},
error: () => {
this.result = undefined;
this.explainTrace = [];
},
});
}
private sortDiff(result: SimulationResult): SimulationResult {
const sortFinding = (items: SimulationDiff['added']) =>
[...items].sort((a, b) => {
if (a.componentPurl === b.componentPurl) {
return a.advisoryId.localeCompare(b.advisoryId);
}
return a.componentPurl.localeCompare(b.componentPurl);
});
const diff: SimulationDiff | undefined = result.diff
? {
added: sortFinding(result.diff.added),
removed: sortFinding(result.diff.removed),
changed: sortFinding(result.diff.changed),
statusDeltas: result.diff.statusDeltas,
severityDeltas: result.diff.severityDeltas,
}
: undefined;
return { ...result, diff };
}
private splitList(value: string | null | undefined): string[] {
if (!value) return [];
return value
.split(',')
.map((v) => v.trim())
.filter(Boolean);
}
}

View File

@@ -0,0 +1,60 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { RouterLink } from '@angular/router';
import { of } from 'rxjs';
import { PolicyPackStore } from '../services/policy-pack.store';
import { PolicyWorkspaceComponent } from './policy-workspace.component';
describe('PolicyWorkspaceComponent', () => {
let fixture: ComponentFixture<PolicyWorkspaceComponent>;
let component: PolicyWorkspaceComponent;
let store: jasmine.SpyObj<PolicyPackStore>;
beforeEach(async () => {
store = jasmine.createSpyObj<PolicyPackStore>('PolicyPackStore', ['getPacks']);
store.getPacks.and.returnValue(
of([
{
id: 'pack-b',
name: 'B pack',
description: '',
version: '1.0',
status: 'active',
createdAt: '2025-12-01T00:00:00Z',
modifiedAt: '2025-12-05T00:00:00Z',
createdBy: '',
modifiedBy: '',
tags: [],
},
{
id: 'pack-a',
name: 'A pack',
description: '',
version: '1.0',
status: 'active',
createdAt: '2025-11-01T00:00:00Z',
modifiedAt: '2025-12-04T00:00:00Z',
createdBy: '',
modifiedBy: '',
tags: [],
},
])
);
await TestBed.configureTestingModule({
imports: [CommonModule, RouterLink, PolicyWorkspaceComponent],
providers: [{ provide: PolicyPackStore, useValue: store }],
}).compileComponents();
fixture = TestBed.createComponent(PolicyWorkspaceComponent);
component = fixture.componentInstance;
});
it('sorts packs by modifiedAt desc then id', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(component['packs'][0].id).toBe('pack-b');
expect(component['packs'][1].id).toBe('pack-a');
}));
});

View File

@@ -0,0 +1,154 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { AuthService, AUTH_SERVICE } from '../../../core/auth';
import { RouterLink } from '@angular/router';
import { PolicyPackSummary } from '../models/policy.models';
import { PolicyPackStore } from '../services/policy-pack.store';
@Component({
selector: 'app-policy-workspace',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="workspace" aria-busy="{{ loading }}">
<header class="workspace__header">
<div>
<p class="workspace__eyebrow">Policy Studio · Workspace</p>
<h1>Policy packs</h1>
<p class="workspace__lede">Deterministic list sorted by modified date desc, tie-breaker id.</p>
</div>
</header>
<div class="workspace__grid">
<article class="pack-card" *ngFor="let pack of packs">
<header class="pack-card__head">
<div>
<p class="pack-card__eyebrow">{{ pack.status | titlecase }}</p>
<h2>{{ pack.name }}</h2>
<p class="pack-card__desc">{{ pack.description || 'No description provided.' }}</p>
</div>
<div class="pack-card__meta">
<span>v{{ pack.version }}</span>
<span>{{ pack.modifiedAt | date: 'medium' }}</span>
</div>
</header>
<ul class="pack-card__tags">
<li *ngFor="let tag of pack.tags">{{ tag }}</li>
</ul>
<div class="pack-card__actions">
<a
[routerLink]="['/policy-studio/packs', pack.id, 'editor']"
[class.action-disabled]="!canAuthor"
[attr.aria-disabled]="!canAuthor"
[title]="canAuthor ? '' : 'Requires policy:author scope'"
>
Edit
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'simulate']"
[class.action-disabled]="!canSimulate"
[attr.aria-disabled]="!canSimulate"
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
>
Simulate
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'approvals']"
[class.action-disabled]="!canReview"
[attr.aria-disabled]="!canReview"
[title]="canReview ? '' : 'Requires policy:review scope'"
>
Approvals
</a>
<a
[routerLink]="['/policy-studio/packs', pack.id, 'dashboard']"
[class.action-disabled]="!canView"
[attr.aria-disabled]="!canView"
[title]="canView ? '' : 'Requires policy:read scope'"
>
Dashboard
</a>
</div>
<dl class="pack-card__detail">
<div>
<dt>Created</dt>
<dd>{{ pack.createdAt | date: 'medium' }}</dd>
</div>
<div>
<dt>Authors</dt>
<dd>{{ pack.createdBy || 'unknown' }}</dd>
</div>
<div>
<dt>Owner</dt>
<dd>{{ pack.modifiedBy || 'unknown' }}</dd>
</div>
</dl>
</article>
</div>
</section>
`,
styles: [
`
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
.workspace { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.workspace__header { margin-bottom: 1rem; }
.workspace__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
.workspace__lede { margin: 0.2rem 0 0; color: #94a3b8; }
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
.pack-card__eyebrow { margin: 0; color: #a5b4fc; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
.pack-card__tags li { padding: 0.2rem 0.45rem; border: 1px solid #1f2937; border-radius: 999px; background: #0b162e; }
.pack-card__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.pack-card__actions a { color: #e5e7eb; border: 1px solid #334155; border-radius: 8px; padding: 0.35rem 0.6rem; text-decoration: none; }
.pack-card__actions a:hover { border-color: #22d3ee; }
.pack-card__detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.35rem 1rem; margin: 0; }
dt { color: #94a3b8; font-size: 0.85rem; margin: 0; }
dd { margin: 0; color: #e5e7eb; }
`,
],
})
export class PolicyWorkspaceComponent {
protected loading = false;
protected packs: PolicyPackSummary[] = [];
protected canAuthor = false;
protected canSimulate = false;
protected canReview = false;
protected canView = false;
protected scopeHint = '';
private readonly packStore = inject(PolicyPackStore);
private readonly auth = inject(AUTH_SERVICE) as AuthService;
constructor() {
this.loading = true;
this.applyScopes();
this.packStore.getPacks().subscribe((packs) => {
this.packs = [...packs].sort((a, b) =>
b.modifiedAt.localeCompare(a.modifiedAt) || a.id.localeCompare(b.id)
);
this.loading = false;
});
}
private applyScopes(): void {
this.canAuthor = this.auth.canAuthorPolicies?.() ?? false;
this.canSimulate = this.auth.canSimulatePolicies?.() ?? false;
this.canReview = this.auth.canReviewPolicies?.() ?? false;
this.canView = this.auth.canViewPolicies?.() ?? false;
const missing: string[] = [];
if (!this.canView) missing.push('policy:read');
if (!this.canAuthor) missing.push('policy:author');
if (!this.canSimulate) missing.push('policy:simulate');
if (!this.canReview) missing.push('policy:review');
this.scopeHint = missing.length ? `Missing scopes: ${missing.join(', ')}` : '';
}
}

View File

@@ -0,0 +1,64 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { PolicyApiService } from '../services/policy-api.service';
import { PolicyYamlEditorComponent } from './policy-yaml-editor.component';
describe('PolicyYamlEditorComponent', () => {
let fixture: ComponentFixture<PolicyYamlEditorComponent>;
let component: PolicyYamlEditorComponent;
let api: jasmine.SpyObj<PolicyApiService>;
beforeEach(async () => {
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getPack', 'lint']);
api.getPack.and.returnValue(
of({
id: 'pack-1',
name: 'Pack One',
description: 'Demo pack',
syntax: 'yaml',
content: '',
version: '1.0.0',
status: 'active',
metadata: {},
createdAt: '',
modifiedAt: '',
createdBy: '',
modifiedBy: '',
tags: ['demo'],
digest: '',
})
);
api.lint.and.returnValue(of({ valid: true, errors: [], warnings: [], info: [] }) as any);
await TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, PolicyYamlEditorComponent],
providers: [
{ provide: PolicyApiService, useValue: api },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ packId: 'pack-1' }),
queryParamMap: convertToParamMap({}),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(PolicyYamlEditorComponent);
component = fixture.componentInstance;
});
it('builds canonical YAML with sorted keys', fakeAsync(() => {
fixture.detectChanges();
tick(500);
expect(component.canonicalYaml).toContain('id');
}));
});

View File

@@ -0,0 +1,179 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, of } from 'rxjs';
import YAML from 'yaml';
import { PolicyApiService } from '../services/policy-api.service';
import { PolicyPack } from '../models/policy.models';
interface YamlDiagnostic {
readonly message: string;
}
@Component({
selector: 'app-policy-yaml-editor',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="yaml" aria-busy="{{ loading }}">
<header class="yaml__header">
<div>
<p class="yaml__eyebrow">Policy Studio · YAML</p>
<h1>{{ pack?.name || 'Loading policy…' }}</h1>
<p class="yaml__lede" *ngIf="pack">
Version {{ pack.version }} · Status: {{ pack.status | titlecase }}
</p>
</div>
<div class="yaml__meta" *ngIf="pack">
<span>Updated {{ pack.modifiedAt | date: 'medium' }}</span>
<span>Tags: {{ pack.tags.length }}</span>
</div>
</header>
<div class="yaml__layout">
<div class="yaml__editor">
<label for="yaml-input">YAML</label>
<textarea
id="yaml-input"
[(ngModel)]="yamlContent"
(ngModelChange)="onContentChange($event)"
rows="18"
aria-label="Policy YAML editor"
></textarea>
<div class="toolbar">
<span class="pill" [class.pill--ok]="!yamlDiagnostics.length && !lintDiagnostics.length">
{{ yamlDiagnostics.length + lintDiagnostics.length }} issue(s)
</span>
<button type="button" (click)="resetToPack()" [disabled]="loading">Reset</button>
</div>
<ul class="diag" *ngIf="yamlDiagnostics.length || lintDiagnostics.length">
<li *ngFor="let d of yamlDiagnostics">YAML: {{ d.message }}</li>
<li *ngFor="let d of lintDiagnostics">Lint: {{ d.message }}</li>
</ul>
</div>
<aside class="yaml__preview" aria-label="Canonical preview">
<header>
<h3>Canonical YAML</h3>
<p>Sorted keys; stable ordering for diff-friendly review.</p>
</header>
<pre><code>{{ canonicalYaml }}</code></pre>
</aside>
</div>
</section>
`,
styles: [
`
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
.yaml { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
.yaml__header { display: flex; justify-content: space-between; gap: 1rem; }
.yaml__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
.yaml__lede { margin: 0.2rem 0 0; color: #94a3b8; }
.yaml__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; }
.yaml__layout { display: grid; grid-template-columns: minmax(0, 1.4fr) 1fr; gap: 1rem; margin-top: 1rem; }
.yaml__editor { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; display: grid; gap: 0.5rem; }
label { color: #cbd5e1; font-weight: 600; }
textarea { width: 100%; background: #0b1224; color: #e5e7eb; border: 1px solid #1f2937; border-radius: 10px; padding: 0.75rem; font-family: 'Monaco','Consolas', monospace; }
.toolbar { display: flex; gap: 0.5rem; align-items: center; }
button { background: #2563eb; border: 1px solid #2563eb; color: #e5e7eb; border-radius: 8px; padding: 0.4rem 0.8rem; cursor: pointer; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
.pill { border: 1px solid #334155; padding: 0.3rem 0.6rem; border-radius: 999px; }
.pill--ok { border-color: #22c55e; color: #22c55e; }
.diag { list-style: none; margin: 0; padding: 0; color: #f87171; }
.yaml__preview { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
pre { margin: 0; max-height: 420px; overflow: auto; background: #0b1224; border: 1px solid #1f2937; border-radius: 10px; padding: 0.75rem; }
@media (max-width: 1024px) { .yaml__layout { grid-template-columns: 1fr; } }
`,
],
})
export class PolicyYamlEditorComponent {
protected pack?: PolicyPack;
protected yamlContent = '';
protected canonicalYaml = '';
protected yamlDiagnostics: YamlDiagnostic[] = [];
protected lintDiagnostics: YamlDiagnostic[] = [];
protected loading = true;
private readonly route = inject(ActivatedRoute);
private readonly api = inject(PolicyApiService);
private readonly content$ = new BehaviorSubject<string>('');
constructor() {
const packId = this.route.snapshot.paramMap.get('packId');
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
if (packId) {
this.api.getPack(packId, version ?? undefined).subscribe((p) => {
this.pack = p;
this.yamlContent = this.buildInitialYaml(p);
this.loading = false;
this.onContentChange(this.yamlContent);
});
}
combineLatest([
this.content$.pipe(debounceTime(400), distinctUntilChanged()),
])
.pipe(
map(([content]) => content),
switchMap((content) => this.validateAndCanonicalize(content))
)
.subscribe();
}
onContentChange(content: string): void {
this.content$.next(content);
}
resetToPack(): void {
if (!this.pack) return;
this.yamlContent = this.buildInitialYaml(this.pack);
this.onContentChange(this.yamlContent);
}
private validateAndCanonicalize(content: string) {
try {
const parsed = YAML.parse(content);
this.yamlDiagnostics = [];
this.canonicalYaml = YAML.stringify(parsed, { sortMapEntries: true });
} catch (err: any) {
this.yamlDiagnostics = [{ message: err.message || 'Invalid YAML' }];
this.canonicalYaml = '';
}
if (!content) {
this.lintDiagnostics = [];
return of(null);
}
return this.api.lint(content).pipe(
map((lint) => {
this.lintDiagnostics = [...lint.errors, ...lint.warnings, ...lint.info].map((d) => ({
message: `${d.severity}: ${d.message} (line ${d.line})`,
}));
return lint;
})
);
}
private buildInitialYaml(pack: PolicyPack): string {
return YAML.stringify(
{
policy: {
id: pack.id,
name: pack.name,
version: pack.version,
status: pack.status,
description: pack.description,
tags: pack.tags,
metadata: pack.metadata,
},
},
{ sortMapEntries: true }
);
}
}

View File

@@ -1,4 +1,5 @@
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
export { ConfidenceBadgeComponent, ConfidenceBand } from './confidence-badge.component';
export { QuietProvenanceIndicatorComponent } from './quiet-provenance-indicator.component';
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
export { ConfidenceBadgeComponent, ConfidenceBand } from './confidence-badge.component';
export { QuietProvenanceIndicatorComponent } from './quiet-provenance-indicator.component';
export { PolicyPackSelectorComponent } from './policy-pack-selector.component';

View File

@@ -0,0 +1,72 @@
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { of } from 'rxjs';
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
import { PolicyPackSelectorComponent } from './policy-pack-selector.component';
describe('PolicyPackSelectorComponent', () => {
let fixture: ComponentFixture<PolicyPackSelectorComponent>;
let component: PolicyPackSelectorComponent;
let store: jasmine.SpyObj<PolicyPackStore>;
beforeEach(async () => {
store = jasmine.createSpyObj<PolicyPackStore>('PolicyPackStore', ['getPacks']);
});
it('emits first pack id when API succeeds', fakeAsync(async () => {
store.getPacks.and.returnValue(
of([
{
id: 'pack-42',
name: 'Test Pack',
description: '',
version: '1.0',
status: 'active',
createdAt: '',
modifiedAt: '',
createdBy: '',
modifiedBy: '',
tags: [],
},
])
);
await TestBed.configureTestingModule({
imports: [CommonModule, PolicyPackSelectorComponent],
providers: [{ provide: PolicyPackStore, useValue: store }],
}).compileComponents();
fixture = TestBed.createComponent(PolicyPackSelectorComponent);
component = fixture.componentInstance;
const spy = jasmine.createSpy('packSelected');
component.packSelected.subscribe(spy);
fixture.detectChanges();
tick();
expect(spy).toHaveBeenCalledWith('pack-42');
}));
it('falls back to pack-1 on API error', fakeAsync(async () => {
store.getPacks.and.returnValue(of([]));
await TestBed.configureTestingModule({
imports: [CommonModule, PolicyPackSelectorComponent],
providers: [{ provide: PolicyPackStore, useValue: store }],
}).compileComponents();
fixture = TestBed.createComponent(PolicyPackSelectorComponent);
component = fixture.componentInstance;
const spy = jasmine.createSpy('packSelected');
component.packSelected.subscribe(spy);
fixture.detectChanges();
tick();
expect(spy).not.toHaveBeenCalled();
expect(component['packs'].length).toBe(0);
}));
});

View File

@@ -0,0 +1,92 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
OnDestroy,
OnInit,
Output,
inject,
} from '@angular/core';
import { Subscription } from 'rxjs';
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
import { PolicyPackSummary } from '../../features/policy-studio/models/policy.models';
/**
* Policy pack selector for the nav dropdown.
* Fetches packs from PolicyApiService with an offline-safe fallback list.
*/
@Component({
selector: 'app-policy-pack-selector',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="pack-selector">
<label for="pack-select">Policy pack</label>
<select
id="pack-select"
(change)="onChange($event.target.value)"
[disabled]="loading"
[attr.aria-busy]="loading"
>
<option *ngFor="let pack of packs" [value]="pack.id">{{ pack.name }}</option>
</select>
<p class="hint" *ngIf="loading">Loading packs…</p>
<p class="hint" *ngIf="!loading && packs.length === 0">No packs available.</p>
</div>
`,
styles: [
`
.pack-selector {
display: grid;
gap: 0.15rem;
}
label {
color: #cbd5e1;
font-size: 0.85rem;
}
select {
background: #0b1224;
color: #e5e7eb;
border: 1px solid #1f2937;
border-radius: 8px;
padding: 0.35rem 0.45rem;
}
.hint {
color: #94a3b8;
margin: 0;
font-size: 0.8rem;
}
`,
],
})
export class PolicyPackSelectorComponent implements OnInit, OnDestroy {
@Output() packSelected = new EventEmitter<string>();
protected packs: PolicyPackSummary[] = [];
protected loading = false;
private readonly packStore = inject(PolicyPackStore);
private sub?: Subscription;
onChange(value: string): void {
this.packSelected.emit(value);
}
ngOnInit(): void {
this.loading = true;
this.sub = this.packStore.getPacks().subscribe((packs) => {
this.packs = packs;
this.loading = false;
if (packs.length > 0) {
this.packSelected.emit(packs[0].id);
}
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
}

View File

@@ -0,0 +1,24 @@
declare module 'monaco-editor/esm/vs/editor/editor.worker?worker' {
const EditorWorkerFactory: { new (): Worker };
export default EditorWorkerFactory;
}
declare module 'monaco-editor/esm/vs/language/json/json.worker?worker' {
const JsonWorkerFactory: { new (): Worker };
export default JsonWorkerFactory;
}
declare module 'monaco-editor/esm/vs/language/css/css.worker?worker' {
const CssWorkerFactory: { new (): Worker };
export default CssWorkerFactory;
}
declare module 'monaco-editor/esm/vs/language/html/html.worker?worker' {
const HtmlWorkerFactory: { new (): Worker };
export default HtmlWorkerFactory;
}
declare module 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' {
const TsWorkerFactory: { new (): Worker };
export default TsWorkerFactory;
}

View File

@@ -1,4 +1,5 @@
@import './styles/tokens/motion';
@import 'monaco-editor/min/vs/editor/editor.main.css';
/* Global motion helpers */
.motion-fade-in {
@@ -50,3 +51,54 @@
animation-iteration-count: 1 !important;
transition-duration: 0ms !important;
}
/* Quick nav styling for Policy Studio dropdown */
.app-nav {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.nav-group {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.6rem;
border: 1px solid #1f2937;
border-radius: 10px;
background: #0b162e;
color: #e5e7eb;
}
.nav-group__menu {
position: absolute;
top: 110%;
left: 0;
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 10px;
padding: 0.4rem;
display: none;
min-width: 200px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.3);
z-index: 5;
}
.nav-group:hover .nav-group__menu,
.nav-group:focus-within .nav-group__menu {
display: grid;
gap: 0.35rem;
}
.nav-group__menu a {
color: #e5e7eb;
padding: 0.35rem 0.45rem;
border-radius: 8px;
text-decoration: none;
}
.nav-group__menu a:hover {
background: #111827;
}