sprints work
This commit is contained in:
@@ -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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user