consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -25,7 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
<ProjectReference Include="..\..\..\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
</ItemGroup>
</Project>

View File

@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| ATTESTOR-225-002 | DOING | Sprint 225 endpoint tests for trusted/revoked/unknown key scenarios. |
| ATTESTOR-225-003 | DOING | Sprint 225 tenant isolation and claim-derived tenant tests. |
| ATTESTOR-225-004 | DOING | Sprint 225 verdict-by-hash retrieval tests with authorization checks. |
| AUDIT-0066-M | DONE | Revalidated 2026-01-06 (maintainability audit). |
| AUDIT-0066-T | DONE | Revalidated 2026-01-06 (test coverage audit). |
| AUDIT-0066-A | DONE | Waived (test project; revalidated 2026-01-06). |

View File

@@ -11,6 +11,7 @@ using OpenTelemetry.Trace;
using Serilog;
using Serilog.Context;
using Serilog.Events;
using StellaOps.Attestation;
using StellaOps.Attestor.Core.Bulk;
using StellaOps.Attestor.Core.Observability;
using StellaOps.Attestor.Core.Options;
@@ -130,6 +131,8 @@ internal static class AttestorWebServiceComposition
builder.Services.AddOptions<AttestorWebServiceFeatures>()
.Bind(builder.Configuration.GetSection($"{configurationSection}:features"))
.ValidateOnStart();
builder.Services.AddOptions<VerdictAuthorityRosterOptions>()
.Bind(builder.Configuration.GetSection($"{configurationSection}:verdictTrust"));
var featureOptions = builder.Configuration.GetSection($"{configurationSection}:features")
.Get<AttestorWebServiceFeatures>() ?? new AttestorWebServiceFeatures();
@@ -141,6 +144,7 @@ internal static class AttestorWebServiceComposition
manager.FeatureProviders.Add(new AttestorWebServiceControllerFeatureProvider(featureOptions));
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSingleton<IDsseVerifier, DsseVerifier>();
builder.Services.AddAttestorInfrastructure();
builder.Services.AddProofChainServices();

View File

@@ -98,3 +98,27 @@ public sealed class VerdictAttestationResponseDto
[JsonPropertyName("createdAt")]
public string CreatedAt { get; init; } = string.Empty;
}
/// <summary>
/// Response for verdict lookup by deterministic hash.
/// </summary>
public sealed class VerdictLookupResponseDto
{
[JsonPropertyName("verdictId")]
public string VerdictId { get; init; } = string.Empty;
[JsonPropertyName("attestationUri")]
public string AttestationUri { get; init; } = string.Empty;
[JsonPropertyName("envelope")]
public string Envelope { get; init; } = string.Empty;
[JsonPropertyName("keyId")]
public string KeyId { get; init; } = string.Empty;
[JsonPropertyName("createdAt")]
public string CreatedAt { get; init; } = string.Empty;
[JsonPropertyName("tenantId")]
public string TenantId { get; init; } = string.Empty;
}

View File

@@ -5,13 +5,17 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Attestation;
using StellaOps.Attestor.Core.Signing;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.WebService.Contracts;
using StellaOps.Attestor.WebService.Options;
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading;
@@ -31,21 +35,28 @@ public class VerdictController : ControllerBase
{
private readonly IAttestationSigningService _signingService;
private readonly ILogger<VerdictController> _logger;
private readonly IDsseVerifier _dsseVerifier;
private readonly IHttpClientFactory? _httpClientFactory;
private readonly AttestorWebServiceFeatures _features;
private readonly VerdictAuthorityRosterOptions _verdictRosterOptions;
private readonly TimeProvider _timeProvider;
private static readonly ConcurrentDictionary<string, CachedVerdictRecord> VerdictCache = new(StringComparer.Ordinal);
public VerdictController(
IAttestationSigningService signingService,
ILogger<VerdictController> logger,
IDsseVerifier dsseVerifier,
IHttpClientFactory? httpClientFactory = null,
IOptions<AttestorWebServiceFeatures>? features = null,
IOptions<VerdictAuthorityRosterOptions>? verdictRosterOptions = null,
TimeProvider? timeProvider = null)
{
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier));
_httpClientFactory = httpClientFactory;
_features = features?.Value ?? new AttestorWebServiceFeatures();
_verdictRosterOptions = verdictRosterOptions?.Value ?? new VerdictAuthorityRosterOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
@@ -75,6 +86,14 @@ public class VerdictController : ControllerBase
"Creating verdict attestation for subject {SubjectName}",
request.Subject.Name);
var tenantResolutionResult = ResolveTenantContext(User, Request.Headers);
if (tenantResolutionResult.Error is not null)
{
return tenantResolutionResult.Error;
}
var tenantId = tenantResolutionResult.TenantId!;
// Validate request
if (string.IsNullOrWhiteSpace(request.PredicateType))
{
@@ -114,9 +133,17 @@ public class VerdictController : ControllerBase
var predicateBase64 = Convert.ToBase64String(predicateBytes);
// Create signing request
var requestedKeyId = string.IsNullOrWhiteSpace(request.KeyId) ? "default" : request.KeyId.Trim();
var rosterResolution = ResolveRosterEntry(requestedKeyId);
if (rosterResolution.Error is not null)
{
return rosterResolution.Error;
}
var rosterEntry = rosterResolution.Entry!;
var signingRequest = new AttestationSignRequest
{
KeyId = request.KeyId ?? "default",
KeyId = requestedKeyId,
PayloadType = request.PredicateType,
PayloadBase64 = predicateBase64
};
@@ -127,7 +154,7 @@ public class VerdictController : ControllerBase
CallerSubject = "system",
CallerAudience = "policy-engine",
CallerClientId = "policy-engine-verdict-attestor",
CallerTenant = "default" // TODO: Extract from auth context
CallerTenant = tenantId
};
// Sign the predicate
@@ -137,12 +164,37 @@ public class VerdictController : ControllerBase
var envelope = signResult.Bundle.Dsse;
var envelopeJson = SerializeEnvelope(envelope, signResult.KeyId);
if (!string.Equals(signResult.KeyId, rosterEntry.KeyId, StringComparison.Ordinal))
{
return StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Signing key is not trusted by roster.",
detail: $"Signed key '{signResult.KeyId}' does not match roster key '{rosterEntry.KeyId}'.",
status: StatusCodes.Status403Forbidden,
code: "authority_key_mismatch"));
}
var signatureVerification = await _dsseVerifier.VerifyAsync(envelopeJson, rosterEntry.PublicKeyPem, ct).ConfigureAwait(false);
if (!signatureVerification.IsValid)
{
return StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Verdict signature is untrusted.",
detail: "Signed verdict DSSE envelope failed authority roster verification.",
status: StatusCodes.Status403Forbidden,
code: "authority_signature_untrusted",
issues: signatureVerification.Issues.ToArray()));
}
// Rekor log index (not implemented in minimal handler)
long? rekorLogIndex = null;
// Store in Evidence Locker (via HTTP call)
await StoreVerdictInEvidenceLockerAsync(
verdictId,
tenantId,
request.Subject.Name,
envelopeJson,
signResult,
@@ -158,15 +210,13 @@ public class VerdictController : ControllerBase
KeyId = signResult.KeyId ?? request.KeyId ?? "default",
CreatedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
};
VerdictCache[verdictId] = CachedVerdictRecord.From(response, tenantId);
_logger.LogInformation(
"Verdict attestation created successfully: {VerdictId}",
verdictId);
return CreatedAtRoute(
routeName: null, // No route name needed for external link
routeValues: null,
value: response);
return Created(attestationUri, response);
}
catch (Exception ex)
{
@@ -186,6 +236,60 @@ public class VerdictController : ControllerBase
}
}
/// <summary>
/// Retrieves a verdict attestation by deterministic verdict hash.
/// </summary>
[HttpGet("~/api/v1/verdicts/{verdictId}")]
[Authorize("attestor:read")]
[EnableRateLimiting("attestor-reads")]
[ProducesResponseType(typeof(VerdictLookupResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VerdictLookupResponseDto>> GetVerdictByHashAsync(
[FromRoute] string verdictId,
CancellationToken ct = default)
{
if (!_features.VerdictsEnabled)
{
return NotImplementedResult();
}
if (string.IsNullOrWhiteSpace(verdictId))
{
return BadRequest(CreateProblem(
title: "Invalid verdict identifier.",
detail: "verdictId is required.",
status: StatusCodes.Status400BadRequest,
code: "invalid_verdict_id"));
}
var tenantResolutionResult = ResolveTenantContext(User, Request.Headers);
if (tenantResolutionResult.Error is not null)
{
return tenantResolutionResult.Error;
}
var tenantId = tenantResolutionResult.TenantId!;
if (VerdictCache.TryGetValue(verdictId, out var cached) &&
string.Equals(cached.TenantId, tenantId, StringComparison.Ordinal))
{
return Ok(cached.ToLookupResponse(verdictId));
}
var lockerResult = await FetchVerdictFromEvidenceLockerAsync(verdictId, tenantId, ct).ConfigureAwait(false);
if (lockerResult is not null)
{
VerdictCache[verdictId] = CachedVerdictRecord.From(lockerResult);
return Ok(lockerResult);
}
return NotFound(CreateProblem(
title: "Verdict not found.",
detail: $"No verdict exists for hash '{verdictId}' in tenant '{tenantId}'.",
status: StatusCodes.Status404NotFound,
code: "verdict_not_found"));
}
/// <summary>
/// Computes a deterministic verdict ID from predicate content.
/// </summary>
@@ -227,6 +331,7 @@ public class VerdictController : ControllerBase
/// </summary>
private async Task StoreVerdictInEvidenceLockerAsync(
string verdictId,
string tenantId,
string findingId,
string envelopeJson,
AttestationSignResult signResult,
@@ -268,7 +373,7 @@ public class VerdictController : ControllerBase
var storeRequest = new
{
verdict_id = verdictId,
tenant_id = "default", // TODO: Extract from auth context (requires CallerTenant from SubmissionContext)
tenant_id = tenantId,
policy_run_id = policyRunId,
policy_id = policyId,
policy_version = policyVersion,
@@ -310,6 +415,220 @@ public class VerdictController : ControllerBase
}
}
private (string? TenantId, ActionResult? Error) ResolveTenantContext(ClaimsPrincipal principal, IHeaderDictionary headers)
{
var tenantId = principal.FindFirst("tenant_id")?.Value
?? principal.FindFirst("tenant")?.Value;
if (string.IsNullOrWhiteSpace(tenantId))
{
return (null, StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Tenant claim is required.",
detail: "Authenticated principal does not contain tenant_id or tenant claim.",
status: StatusCodes.Status403Forbidden,
code: "tenant_claim_missing")));
}
if (headers.TryGetValue("X-Tenant-Id", out var headerTenant) &&
headerTenant.Count > 0 &&
!string.Equals(headerTenant[0], tenantId, StringComparison.Ordinal))
{
return (null, StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Tenant mismatch detected.",
detail: "Tenant header does not match authenticated tenant claim.",
status: StatusCodes.Status403Forbidden,
code: "tenant_mismatch")));
}
return (tenantId, null);
}
private (VerdictAuthorityKeyOptions? Entry, ActionResult? Error) ResolveRosterEntry(string keyId)
{
if (_verdictRosterOptions.Keys.Count == 0)
{
return (null, StatusCode(
StatusCodes.Status503ServiceUnavailable,
CreateProblem(
title: "Authority roster is unavailable.",
detail: "attestor:verdictTrust:keys must include at least one trusted key.",
status: StatusCodes.Status503ServiceUnavailable,
code: "authority_roster_unavailable")));
}
var entry = _verdictRosterOptions.Keys
.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
if (entry is null)
{
return (null, StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Signing key is not in authority roster.",
detail: $"Key '{keyId}' is not trusted for verdict creation.",
status: StatusCodes.Status403Forbidden,
code: "authority_key_unknown")));
}
if (string.Equals(entry.Status, "revoked", StringComparison.OrdinalIgnoreCase))
{
return (null, StatusCode(
StatusCodes.Status403Forbidden,
CreateProblem(
title: "Signing key is revoked.",
detail: $"Key '{entry.KeyId}' is revoked in authority roster.",
status: StatusCodes.Status403Forbidden,
code: "authority_key_revoked")));
}
if (string.IsNullOrWhiteSpace(entry.PublicKeyPem))
{
return (null, StatusCode(
StatusCodes.Status500InternalServerError,
CreateProblem(
title: "Authority roster key is incomplete.",
detail: $"Key '{entry.KeyId}' is missing public key material.",
status: StatusCodes.Status500InternalServerError,
code: "authority_key_missing_public_key")));
}
return (entry, null);
}
private async Task<VerdictLookupResponseDto?> FetchVerdictFromEvidenceLockerAsync(
string verdictId,
string tenantId,
CancellationToken ct)
{
if (_httpClientFactory is null)
{
return null;
}
try
{
var client = _httpClientFactory.CreateClient("EvidenceLocker");
var response = await client.GetAsync($"/api/v1/verdicts/{Uri.EscapeDataString(verdictId)}", ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Evidence Locker verdict lookup failed for {VerdictId}: {StatusCode}",
verdictId,
response.StatusCode);
return null;
}
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct).ConfigureAwait(false);
if (payload.ValueKind != JsonValueKind.Object)
{
return null;
}
var lockerTenant = GetOptionalString(payload, "tenant_id", "tenantId");
if (!string.IsNullOrWhiteSpace(lockerTenant) &&
!string.Equals(lockerTenant, tenantId, StringComparison.Ordinal))
{
return null;
}
var envelope = ExtractEnvelope(payload);
if (string.IsNullOrWhiteSpace(envelope))
{
return null;
}
var keyId = GetOptionalString(payload, "key_id", "keyId") ?? "unknown";
var createdAt = GetOptionalString(payload, "evaluated_at", "created_at", "createdAt")
?? _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
return new VerdictLookupResponseDto
{
VerdictId = verdictId,
AttestationUri = $"/api/v1/verdicts/{verdictId}",
Envelope = envelope,
KeyId = keyId,
CreatedAt = createdAt,
TenantId = tenantId
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Evidence Locker verdict lookup failed for {VerdictId}", verdictId);
return null;
}
}
private static string? ExtractEnvelope(JsonElement payload)
{
if (!payload.TryGetProperty("envelope", out var envelopeElement))
{
return null;
}
if (envelopeElement.ValueKind == JsonValueKind.String)
{
return envelopeElement.GetString();
}
if (envelopeElement.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
var envelopeJson = envelopeElement.GetRawText();
return Convert.ToBase64String(Encoding.UTF8.GetBytes(envelopeJson));
}
return null;
}
private static string? GetOptionalString(JsonElement payload, params string[] candidates)
{
foreach (var candidate in candidates)
{
if (payload.TryGetProperty(candidate, out var value) &&
value.ValueKind == JsonValueKind.String)
{
var text = value.GetString();
if (!string.IsNullOrWhiteSpace(text))
{
return text;
}
}
}
return null;
}
private static ProblemDetails CreateProblem(
string title,
string detail,
int status,
string code,
string[]? issues = null)
{
var problem = new ProblemDetails
{
Title = title,
Detail = detail,
Status = status
};
problem.Extensions["code"] = code;
if (issues is not null && issues.Length > 0)
{
problem.Extensions["issues"] = issues;
}
return problem;
}
/// <summary>
/// Extracts verdict metadata from predicate JSON.
/// </summary>
@@ -418,4 +737,28 @@ public class VerdictController : ControllerBase
StatusCode = StatusCodes.Status501NotImplemented
};
}
private sealed record CachedVerdictRecord(
string TenantId,
string Envelope,
string KeyId,
string CreatedAt)
{
public static CachedVerdictRecord From(VerdictAttestationResponseDto response, string tenantId)
=> new(tenantId, response.Envelope, response.KeyId, response.CreatedAt);
public static CachedVerdictRecord From(VerdictLookupResponseDto response)
=> new(response.TenantId, response.Envelope, response.KeyId, response.CreatedAt);
public VerdictLookupResponseDto ToLookupResponse(string verdictId)
=> new()
{
VerdictId = verdictId,
AttestationUri = $"/api/v1/verdicts/{verdictId}",
Envelope = Envelope,
KeyId = KeyId,
CreatedAt = CreatedAt,
TenantId = TenantId
};
}
}

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Attestor.WebService.Options;
public sealed class VerdictAuthorityRosterOptions
{
public List<VerdictAuthorityKeyOptions> Keys { get; set; } = [];
}
public sealed class VerdictAuthorityKeyOptions
{
public string KeyId { get; set; } = string.Empty;
public string Status { get; set; } = "trusted";
public string PublicKeyPem { get; set; } = string.Empty;
}

View File

@@ -18,6 +18,7 @@
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Attestation/StellaOps.Attestation.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />

View File

@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| ATTESTOR-225-002 | DOING | Sprint 225: enforce roster-based trust verification before verdict append. |
| ATTESTOR-225-003 | DOING | Sprint 225: resolve tenant from authenticated claims and block spoofing. |
| ATTESTOR-225-004 | DOING | Sprint 225: implement verdict-by-hash retrieval and tenant-scoped access checks. |
| AUDIT-0072-M | DONE | Revalidated 2026-01-06 (maintainability audit). |
| AUDIT-0072-T | DONE | Revalidated 2026-01-06 (test coverage audit). |
| AUDIT-0072-A | DONE | Applied 2026-01-13 (feature gating, correlation ID provider, proof chain/verification summary updates, tests). |