sprints work

This commit is contained in:
master
2026-01-10 11:15:28 +02:00
parent a21d3dbc1f
commit 701eb6b21c
71 changed files with 10854 additions and 136 deletions

View File

@@ -0,0 +1,331 @@
// <copyright file="AttestationEndpoints.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AdvisoryAI.Attestation;
using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
/// <summary>
/// API endpoints for AI attestations.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-009
/// </summary>
public static class AttestationEndpoints
{
/// <summary>
/// Maps all attestation endpoints.
/// </summary>
public static void MapAttestationEndpoints(this WebApplication app)
{
// GET /v1/advisory-ai/runs/{runId}/attestation
app.MapGet("/v1/advisory-ai/runs/{runId}/attestation", HandleGetRunAttestation)
.WithName("advisory-ai.runs.attestation.get")
.WithTags("Attestations")
.Produces<RunAttestationResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/advisory-ai/runs/{runId}/claims
app.MapGet("/v1/advisory-ai/runs/{runId}/claims", HandleGetRunClaims)
.WithName("advisory-ai.runs.claims.list")
.WithTags("Attestations")
.Produces<ClaimsListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/advisory-ai/attestations/recent
app.MapGet("/v1/advisory-ai/attestations/recent", HandleListRecentAttestations)
.WithName("advisory-ai.attestations.recent")
.WithTags("Attestations")
.Produces<RecentAttestationsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// POST /v1/advisory-ai/attestations/verify
app.MapPost("/v1/advisory-ai/attestations/verify", HandleVerifyAttestation)
.WithName("advisory-ai.attestations.verify")
.WithTags("Attestations")
.Produces<AttestationVerificationResponse>(StatusCodes.Status200OK)
.Produces<AttestationVerificationResponse>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
}
private static async Task<IResult> HandleGetRunAttestation(
string runId,
IAiAttestationService attestationService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var attestation = await attestationService.GetRunAttestationAsync(runId, cancellationToken)
.ConfigureAwait(false);
if (attestation is null)
{
return Results.NotFound(new { error = "Run attestation not found", runId });
}
// Enforce tenant isolation
if (attestation.TenantId != tenantId)
{
return Results.NotFound(new { error = "Run attestation not found", runId });
}
// Get the signed envelope if available (from store)
// Note: The service stores but we access via the store for envelope
var store = httpContext.RequestServices.GetService<IAiAttestationStore>();
var envelope = store is not null
? await store.GetSignedEnvelopeAsync(runId, cancellationToken).ConfigureAwait(false)
: null;
return Results.Ok(new RunAttestationResponse
{
RunId = attestation.RunId,
Attestation = attestation,
Envelope = envelope,
Links = new AttestationLinks
{
Claims = $"/v1/advisory-ai/runs/{runId}/claims",
Verify = "/v1/advisory-ai/attestations/verify"
}
});
}
private static async Task<IResult> HandleGetRunClaims(
string runId,
IAiAttestationService attestationService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
// First verify the run exists and belongs to tenant
var attestation = await attestationService.GetRunAttestationAsync(runId, cancellationToken)
.ConfigureAwait(false);
if (attestation is null || attestation.TenantId != tenantId)
{
return Results.NotFound(new { error = "Run not found", runId });
}
var claims = await attestationService.GetClaimAttestationsAsync(runId, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new ClaimsListResponse
{
RunId = runId,
Count = claims.Count,
Claims = claims
});
}
private static async Task<IResult> HandleListRecentAttestations(
IAiAttestationService attestationService,
HttpContext httpContext,
int? limit,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var effectiveLimit = Math.Min(limit ?? 20, 100);
var attestations = await attestationService.ListRecentAttestationsAsync(
tenantId,
effectiveLimit,
cancellationToken).ConfigureAwait(false);
return Results.Ok(new RecentAttestationsResponse
{
Count = attestations.Count,
Attestations = attestations
});
}
private static async Task<IResult> HandleVerifyAttestation(
VerifyAttestationRequest request,
IAiAttestationService attestationService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
if (string.IsNullOrEmpty(request.RunId))
{
return Results.BadRequest(new AttestationVerificationResponse
{
IsValid = false,
Error = "RunId is required"
});
}
// First verify the run belongs to this tenant
var attestation = await attestationService.GetRunAttestationAsync(request.RunId, cancellationToken)
.ConfigureAwait(false);
if (attestation is null || attestation.TenantId != tenantId)
{
return Results.BadRequest(new AttestationVerificationResponse
{
IsValid = false,
RunId = request.RunId,
Error = "Attestation not found or access denied"
});
}
var result = await attestationService.VerifyRunAttestationAsync(
request.RunId,
cancellationToken).ConfigureAwait(false);
var response = new AttestationVerificationResponse
{
IsValid = result.Valid,
RunId = request.RunId,
ContentDigest = attestation.ComputeDigest(),
Error = result.FailureReason,
VerifiedAt = result.VerifiedAt,
SigningKeyId = result.SigningKeyId,
DigestValid = result.DigestValid,
SignatureValid = result.SignatureValid
};
return result.Valid ? Results.Ok(response) : Results.BadRequest(response);
}
private static string? GetTenantId(HttpContext context)
{
// Try standard header first
if (context.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var tenant))
{
return tenant.ToString();
}
// Fallback to claims if authenticated
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
return tenantClaim;
}
}
#region Response Models
/// <summary>
/// Response for run attestation retrieval.
/// </summary>
public sealed record RunAttestationResponse
{
/// <summary>Run identifier.</summary>
public required string RunId { get; init; }
/// <summary>The attestation data.</summary>
public required AiRunAttestation Attestation { get; init; }
/// <summary>DSSE envelope if signed.</summary>
public object? Envelope { get; init; }
/// <summary>Related links.</summary>
public AttestationLinks? Links { get; init; }
}
/// <summary>
/// Response for claims list.
/// </summary>
public sealed record ClaimsListResponse
{
/// <summary>Run identifier.</summary>
public required string RunId { get; init; }
/// <summary>Number of claims.</summary>
public int Count { get; init; }
/// <summary>Claim attestations.</summary>
public required IReadOnlyList<AiClaimAttestation> Claims { get; init; }
}
/// <summary>
/// Response for recent attestations list.
/// </summary>
public sealed record RecentAttestationsResponse
{
/// <summary>Number of attestations returned.</summary>
public int Count { get; init; }
/// <summary>Recent attestations.</summary>
public required IReadOnlyList<AiRunAttestation> Attestations { get; init; }
}
/// <summary>
/// Request for attestation verification.
/// </summary>
public sealed record VerifyAttestationRequest
{
/// <summary>Run ID to verify.</summary>
public string? RunId { get; init; }
}
/// <summary>
/// Response for attestation verification.
/// </summary>
public sealed record AttestationVerificationResponse
{
/// <summary>Whether verification succeeded.</summary>
public bool IsValid { get; init; }
/// <summary>Run ID if extracted from envelope.</summary>
public string? RunId { get; init; }
/// <summary>Content digest if verified.</summary>
public string? ContentDigest { get; init; }
/// <summary>Error message if verification failed.</summary>
public string? Error { get; init; }
/// <summary>Timestamp when verification was performed.</summary>
public DateTimeOffset? VerifiedAt { get; init; }
/// <summary>Signing key ID if signed.</summary>
public string? SigningKeyId { get; init; }
/// <summary>Whether the digest was valid.</summary>
public bool? DigestValid { get; init; }
/// <summary>Whether the signature was valid.</summary>
public bool? SignatureValid { get; init; }
}
/// <summary>
/// Related links for attestation responses.
/// </summary>
public sealed record AttestationLinks
{
/// <summary>Link to claims endpoint.</summary>
public string? Claims { get; init; }
/// <summary>Link to verification endpoint.</summary>
public string? Verify { get; init; }
}
#endregion

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.AdvisoryAI.Diagnostics;
@@ -22,6 +23,7 @@ using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.PolicyStudio;
using StellaOps.AdvisoryAI.Remediation;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.AdvisoryAI.WebService.Endpoints;
using StellaOps.AdvisoryAI.WebService.Services;
using StellaOps.Router.AspNet;
@@ -50,6 +52,10 @@ builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IAiConsentStore, InMemoryAiConsentStore>();
builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationGenerator>();
// AI Attestations (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
builder.Services.AddAiAttestationServices();
builder.Services.AddInMemoryAiAttestationStore();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
@@ -179,6 +185,9 @@ app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConv
app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
.RequireRateLimiting("advisory-ai");
// AI Attestations endpoints (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
app.MapAttestationEndpoints();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);

View File

@@ -13,5 +13,7 @@
<ProjectReference Include="..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
<ProjectReference Include="..\StellaOps.AdvisoryAI.Hosting\StellaOps.AdvisoryAI.Hosting.csproj" />
<ProjectReference Include="..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<!-- AI Attestations (Sprint: SPRINT_20260109_011_001) -->
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
</ItemGroup>
</Project>