wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
using StellaOps.AdvisoryAI.Attestation;
using StellaOps.AdvisoryAI.Attestation.Models;
using StellaOps.AdvisoryAI.Attestation.Storage;
using StellaOps.AdvisoryAI.WebService.Security;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -26,36 +27,48 @@ public static class AttestationEndpoints
// GET /v1/advisory-ai/runs/{runId}/attestation
app.MapGet("/v1/advisory-ai/runs/{runId}/attestation", HandleGetRunAttestation)
.WithName("advisory-ai.runs.attestation.get")
.WithSummary("Get the attestation record for a completed AI run")
.WithDescription("Returns the AI attestation for a completed investigation run, including the DSSE envelope if the run was cryptographically signed. Tenant isolation is enforced; requests for runs belonging to a different tenant return 404. Returns 404 if the run has not been attested.")
.WithTags("Attestations")
.Produces<RunAttestationResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.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")
.WithSummary("List AI-generated claims for a run")
.WithDescription("Returns all claim-level attestations recorded during an AI investigation run, each describing an individual assertion made by the AI (e.g. reachability verdict, remediation recommendation, risk rating). Claims are linked to the parent run attestation and can be independently verified.")
.WithTags("Attestations")
.Produces<ClaimsListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireRateLimiting("advisory-ai");
// GET /v1/advisory-ai/attestations/recent
app.MapGet("/v1/advisory-ai/attestations/recent", HandleListRecentAttestations)
.WithName("advisory-ai.attestations.recent")
.WithSummary("List recent AI attestations for the current tenant")
.WithDescription("Returns the most recent AI run attestations for the authenticated tenant, ordered by creation time descending. Limit defaults to 20 and is capped at 100. Use this endpoint to monitor recent AI activity and surface attestations for downstream signing or audit workflows.")
.WithTags("Attestations")
.Produces<RecentAttestationsResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireRateLimiting("advisory-ai");
// POST /v1/advisory-ai/attestations/verify
app.MapPost("/v1/advisory-ai/attestations/verify", HandleVerifyAttestation)
.WithName("advisory-ai.attestations.verify")
.WithSummary("Verify the cryptographic integrity of an AI run attestation")
.WithDescription("Verifies the content digest and DSSE envelope signature of a previously recorded AI run attestation. Returns a structured result including per-component validity flags (digest, signature) and the signing key ID. Returns 400 if the attestation is not found, is tampered, or belongs to a different tenant.")
.WithTags("Attestations")
.Produces<AttestationVerificationResponse>(StatusCodes.Status200OK)
.Produces<AttestationVerificationResponse>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireRateLimiting("advisory-ai");
}

View File

@@ -17,6 +17,7 @@ using StellaOps.AdvisoryAI.Chat.Routing;
using StellaOps.AdvisoryAI.Chat.Services;
using StellaOps.AdvisoryAI.Chat.Settings;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.AdvisoryAI.WebService.Security;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Text.Json;
@@ -43,7 +44,8 @@ public static class ChatEndpoints
public static RouteGroupBuilder MapChatEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/v1/chat")
.WithTags("Advisory Chat");
.WithTags("Advisory Chat")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
// Single query endpoint (non-streaming)
group.MapPost("/query", ProcessQueryAsync)
@@ -68,6 +70,7 @@ public static class ChatEndpoints
group.MapPost("/intent", DetectIntentAsync)
.WithName("DetectChatIntent")
.WithSummary("Detects intent from a user query without generating a full response")
.WithDescription("Classifies the user query into one of the advisory chat intents (explain, remediate, assess-risk, compare, etc.) and extracts structured parameters such as finding ID, package PURL, image reference, and environment. Useful for pre-routing or UI intent indicators without consuming LLM quota.")
.Produces<IntentDetectionResponse>(StatusCodes.Status200OK)
.ProducesValidationProblem();
@@ -75,6 +78,7 @@ public static class ChatEndpoints
group.MapPost("/evidence-preview", PreviewEvidenceBundleAsync)
.WithName("PreviewEvidenceBundle")
.WithSummary("Previews the evidence bundle that would be assembled for a query")
.WithDescription("Assembles and returns a preview of the evidence bundle that would be passed to the LLM for the specified finding, without generating an AI response. Indicates which evidence types are available (VEX, reachability, binary patch, provenance, policy, ops memory, fix options) and their status.")
.Produces<EvidenceBundlePreviewResponse>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
@@ -82,29 +86,34 @@ public static class ChatEndpoints
group.MapGet("/settings", GetChatSettingsAsync)
.WithName("GetChatSettings")
.WithSummary("Gets effective chat settings for the caller")
.WithDescription("Returns the effective advisory chat settings for the current tenant and user, merging global defaults, tenant overrides, and user overrides. Includes quota limits and tool access configuration.")
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK);
group.MapPut("/settings", UpdateChatSettingsAsync)
.WithName("UpdateChatSettings")
.WithSummary("Updates chat settings overrides (tenant or user)")
.WithDescription("Applies quota and tool access overrides for the current tenant (default) or a specific user (scope=user). Overrides are layered on top of global defaults; only fields present in the request body are changed.")
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
group.MapDelete("/settings", ClearChatSettingsAsync)
.WithName("ClearChatSettings")
.WithSummary("Clears chat settings overrides (tenant or user)")
.WithDescription("Removes all tenant-level or user-level chat settings overrides, reverting the affected scope to global defaults. Use scope=user to clear only the user-level override for the current user.")
.Produces(StatusCodes.Status204NoContent);
// Doctor endpoint
group.MapGet("/doctor", GetChatDoctorAsync)
.WithName("GetChatDoctor")
.WithSummary("Returns chat limit status and tool access diagnostics")
.WithDescription("Returns a diagnostics report for the current tenant and user, including remaining quota across all dimensions (requests/min, requests/day, tokens/day, tool calls/day), tool provider availability, and the last quota denial if any. Referenced by error responses via the doctor action hint.")
.Produces<ChatDoctorResponse>(StatusCodes.Status200OK);
// Health/status endpoint for chat service
group.MapGet("/status", GetChatStatusAsync)
.WithName("GetChatStatus")
.WithSummary("Gets the status of the advisory chat service")
.WithDescription("Returns the current operational status of the advisory chat service, including whether chat is enabled, the configured inference provider and model, maximum token limit, and whether guardrails and audit logging are active.")
.Produces<ChatServiceStatusResponse>(StatusCodes.Status200OK);
return group;

View File

@@ -6,6 +6,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Determinism;
using StellaOps.Evidence.Pack;
using StellaOps.Evidence.Pack.Models;
@@ -27,62 +28,83 @@ public static class EvidencePackEndpoints
// POST /v1/evidence-packs - Create Evidence Pack
app.MapPost("/v1/evidence-packs", HandleCreateEvidencePack)
.WithName("evidence-packs.create")
.WithSummary("Create an evidence pack")
.WithDescription("Creates a new evidence pack containing AI-generated claims and supporting evidence items for a vulnerability subject. Claims are linked to evidence items by ID. The pack is assigned a content digest for tamper detection and can subsequently be signed via the sign endpoint.")
.WithTags("EvidencePacks")
.Produces<EvidencePackResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.RequireRateLimiting("advisory-ai");
// GET /v1/evidence-packs/{packId} - Get Evidence Pack
app.MapGet("/v1/evidence-packs/{packId}", HandleGetEvidencePack)
.WithName("evidence-packs.get")
.WithSummary("Get an evidence pack by ID")
.WithDescription("Returns the full evidence pack record including all claims, evidence items, subject, context, and related links (sign, verify, export). Access is tenant-scoped; packs from other tenants return 404.")
.WithTags("EvidencePacks")
.Produces<EvidencePackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireRateLimiting("advisory-ai");
// POST /v1/evidence-packs/{packId}/sign - Sign Evidence Pack
app.MapPost("/v1/evidence-packs/{packId}/sign", HandleSignEvidencePack)
.WithName("evidence-packs.sign")
.WithSummary("Sign an evidence pack")
.WithDescription("Signs the specified evidence pack using DSSE (Dead Simple Signing Envelope), producing a cryptographic attestation over the pack's content digest. The resulting signed pack and DSSE envelope are returned and stored for subsequent verification.")
.WithTags("EvidencePacks")
.Produces<SignedEvidencePackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.RequireRateLimiting("advisory-ai");
// POST /v1/evidence-packs/{packId}/verify - Verify Evidence Pack
app.MapPost("/v1/evidence-packs/{packId}/verify", HandleVerifyEvidencePack)
.WithName("evidence-packs.verify")
.WithSummary("Verify an evidence pack's signature and integrity")
.WithDescription("Verifies the cryptographic signature and content digest of a signed evidence pack. Returns per-evidence URI resolution results, digest match status, and signing key ID. Returns 400 if the pack has not been signed or verification fails.")
.WithTags("EvidencePacks")
.Produces<EvidencePackVerificationResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireRateLimiting("advisory-ai");
// GET /v1/evidence-packs/{packId}/export - Export Evidence Pack
app.MapGet("/v1/evidence-packs/{packId}/export", HandleExportEvidencePack)
.WithName("evidence-packs.export")
.WithSummary("Export an evidence pack in a specified format")
.WithDescription("Exports an evidence pack in the requested format. Supported formats: json (default), markdown, html, pdf, signedjson, evidencecard, and evidencecardcompact. The format query parameter controls the output; the appropriate Content-Type and filename are set in the response.")
.WithTags("EvidencePacks")
.Produces<byte[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireRateLimiting("advisory-ai");
// GET /v1/runs/{runId}/evidence-packs - List Evidence Packs for Run
app.MapGet("/v1/runs/{runId}/evidence-packs", HandleListRunEvidencePacks)
.WithName("evidence-packs.list-by-run")
.WithSummary("List evidence packs for a run")
.WithDescription("Returns all evidence packs associated with a specific AI investigation run, filtered to the current tenant. Includes pack summaries with claim count, evidence count, subject type, and CVE ID.")
.WithTags("EvidencePacks")
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireRateLimiting("advisory-ai");
// GET /v1/evidence-packs - List Evidence Packs
app.MapGet("/v1/evidence-packs", HandleListEvidencePacks)
.WithName("evidence-packs.list")
.WithSummary("List evidence packs")
.WithDescription("Returns a paginated list of evidence packs for the current tenant, optionally filtered by CVE ID or run ID. Supports limit up to 100. Results include pack summaries with subject type, claim count, and evidence count.")
.WithTags("EvidencePacks")
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
.RequireRateLimiting("advisory-ai");
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.KnowledgeSearch;
using StellaOps.AdvisoryAI.WebService.Security;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
@@ -17,11 +18,14 @@ public static class KnowledgeSearchEndpoints
public static RouteGroupBuilder MapKnowledgeSearchEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/v1/advisory-ai")
.WithTags("Advisory AI - Knowledge Search");
.WithTags("Advisory AI - Knowledge Search")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
group.MapPost("/search", SearchAsync)
.WithName("AdvisoryAiKnowledgeSearch")
.WithSummary("Searches AdvisoryAI deterministic knowledge index (docs/api/doctor).")
.WithDescription("Performs a hybrid full-text and vector similarity search over the AdvisoryAI deterministic knowledge index, which is composed of product documentation, OpenAPI specs, and Doctor health check projections. Supports filtering by content type (docs, api, doctor), product, version, service, and tags. Returns ranked result snippets with actionable open-actions for UI navigation.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<AdvisoryKnowledgeSearchResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden);
@@ -29,6 +33,8 @@ public static class KnowledgeSearchEndpoints
group.MapPost("/index/rebuild", RebuildIndexAsync)
.WithName("AdvisoryAiKnowledgeIndexRebuild")
.WithSummary("Rebuilds AdvisoryAI knowledge search index from deterministic local sources.")
.WithDescription("Triggers a full rebuild of the knowledge search index from local deterministic sources: product documentation files, embedded OpenAPI specs, and Doctor health check metadata. The rebuild is synchronous and returns document, chunk, and operation counts with duration. Requires admin-level scope; does not fetch external content.")
.RequireAuthorization(AdvisoryAIPolicies.AdminPolicy)
.Produces<AdvisoryKnowledgeRebuildResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden);
@@ -187,7 +193,10 @@ public static class KnowledgeSearchEndpoints
CheckCode = result.Open.Doctor.CheckCode,
Severity = result.Open.Doctor.Severity,
CanRun = result.Open.Doctor.CanRun,
RunCommand = result.Open.Doctor.RunCommand
RunCommand = result.Open.Doctor.RunCommand,
Control = result.Open.Doctor.Control,
RequiresConfirmation = result.Open.Doctor.RequiresConfirmation,
IsDestructive = result.Open.Doctor.IsDestructive
}
};
@@ -350,6 +359,12 @@ public sealed record AdvisoryKnowledgeOpenDoctorAction
public bool CanRun { get; init; } = true;
public string RunCommand { get; init; } = string.Empty;
public string Control { get; init; } = "safe";
public bool RequiresConfirmation { get; init; }
public bool IsDestructive { get; init; }
}
public sealed record AdvisoryKnowledgeSearchDiagnostics

View File

@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.Inference.LlmProviders;
using StellaOps.AdvisoryAI.Plugin.Unified;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Plugin.Abstractions.Capabilities;
using System.Security.Cryptography;
using System.Text;
@@ -27,17 +28,21 @@ public static class LlmAdapterEndpoints
public static RouteGroupBuilder MapLlmAdapterEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/v1/advisory-ai/adapters")
.WithTags("Advisory AI - LLM Adapters");
.WithTags("Advisory AI - LLM Adapters")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
group.MapGet("/llm/providers", ListProvidersAsync)
.WithName("ListLlmProviders")
.WithSummary("Lists LLM providers exposed via the unified adapter layer.")
.WithDescription("Returns all LLM providers registered in the unified plugin catalog, including their configuration status, validation result, availability, and the completion path to use for each provider. Configured-but-invalid providers are included with error details. Use this endpoint to discover which providers are ready to serve completions before invoking them.")
.Produces<IReadOnlyList<LlmProviderExposureResponse>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden);
group.MapPost("/llm/{providerId}/chat/completions", CompleteWithProviderAsync)
.WithName("LlmProviderChatCompletions")
.WithSummary("OpenAI-compatible chat completion for a specific unified provider.")
.WithDescription("Submits a chat completion request to the specified LLM provider via the unified adapter layer using an OpenAI-compatible message format. Streaming is not supported; use non-streaming mode only. Returns 404 if the provider is not configured for adapter exposure, 503 if the provider is temporarily unavailable. Caller scopes are validated against gateway-managed headers.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<OpenAiChatCompletionResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden)
@@ -47,6 +52,8 @@ public static class LlmAdapterEndpoints
group.MapPost("/openai/v1/chat/completions", CompleteOpenAiCompatAsync)
.WithName("OpenAiAdapterChatCompletions")
.WithSummary("OpenAI-compatible chat completion alias backed by providerId=openai.")
.WithDescription("Convenience alias that routes chat completion requests to the provider with id 'openai', using the same OpenAI-compatible request/response format as the generic provider endpoint. Intended for drop-in compatibility with clients expecting the standard OpenAI path. Streaming is not supported.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<OpenAiChatCompletionResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status403Forbidden)

View File

@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.Runs;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.Determinism;
using System.Collections.Immutable;
@@ -27,64 +28,82 @@ public static class RunEndpoints
public static RouteGroupBuilder MapRunEndpoints(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/v1/runs")
.WithTags("Runs");
.WithTags("Runs")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
group.MapPost("/", CreateRunAsync)
.WithName("CreateRun")
.WithSummary("Creates a new AI investigation run")
.WithDescription("Creates a new AI investigation run scoped to the authenticated tenant, capturing the title, objective, and optional CVE/component/SBOM context. The run begins in the Created state and accumulates events as the investigation progresses. Returns 201 with the initial run state and a Location header.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunDto>(StatusCodes.Status201Created)
.ProducesValidationProblem();
group.MapGet("/{runId}", GetRunAsync)
.WithName("GetRun")
.WithSummary("Gets a run by ID")
.WithDescription("Returns the current state of an AI investigation run, including status, event count, artifact count, content digest, attestation flag, context, and approval info. Returns 404 if the run does not exist or belongs to a different tenant.")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/", QueryRunsAsync)
.WithName("QueryRuns")
.WithSummary("Queries runs with filters")
.WithDescription("Returns a paginated list of AI investigation runs for the current tenant, optionally filtered by initiator, CVE ID, component, and status. Supports skip/take pagination. Results are ordered by creation time descending.")
.Produces<RunQueryResultDto>();
group.MapGet("/{runId}/timeline", GetTimelineAsync)
.WithName("GetRunTimeline")
.WithSummary("Gets the event timeline for a run")
.WithDescription("Returns the ordered event timeline for an AI investigation run, including user turns, assistant turns, proposed actions, approvals, and artifact additions. Supports skip/take pagination over the event sequence. Returns 404 if the run does not exist.")
.Produces<ImmutableArray<RunEventDto>>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/events", AddEventAsync)
.WithName("AddRunEvent")
.WithSummary("Adds an event to a run")
.WithDescription("Appends a typed event to an active AI investigation run, supporting arbitrary event types with optional content payload, evidence links, and parent event reference for threading. Returns 201 with the created event. Returns 404 if the run does not exist or is in a terminal state.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/turns/user", AddUserTurnAsync)
.WithName("AddUserTurn")
.WithSummary("Adds a user turn to the run")
.WithDescription("Appends a user conversational turn to an active AI investigation run, recording the message text, actor ID, and optional evidence links. User turns drive the investigation dialogue and are included in the run content digest for attestation purposes.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/turns/assistant", AddAssistantTurnAsync)
.WithName("AddAssistantTurn")
.WithSummary("Adds an assistant turn to the run")
.WithDescription("Appends an AI assistant conversational turn to an active run, recording the generated message and optional evidence links. Assistant turns are included in the run content digest and contribute to the attestable evidence chain for the investigation.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/actions", ProposeActionAsync)
.WithName("ProposeAction")
.WithSummary("Proposes an action in the run")
.WithDescription("Records an AI-proposed action in a run, including the action type, subject, rationale, parameters, and whether human approval is required before execution. Actions flagged as requiring approval transition the run to PendingApproval once approval is requested. Returns 404 if the run is not active.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/approval/request", RequestApprovalAsync)
.WithName("RequestApproval")
.WithSummary("Requests approval for pending actions")
.WithDescription("Transitions a run to the PendingApproval state and notifies the designated approvers. The request body specifies the approver IDs and an optional reason. Returns the updated run state. Returns 404 if the run does not exist or is not in a state that allows approval requests.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/approval/decide", ApproveAsync)
.WithName("ApproveRun")
.WithSummary("Approves or rejects a run")
.WithDescription("Records an approval or rejection decision for a run in PendingApproval state. On approval, the run transitions back to Active so approved actions can be executed. On rejection, the run is cancelled. Returns 400 if the run is not in an approvable state.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -92,6 +111,8 @@ public static class RunEndpoints
group.MapPost("/{runId}/actions/{actionEventId}/execute", ExecuteActionAsync)
.WithName("ExecuteAction")
.WithSummary("Executes an approved action")
.WithDescription("Marks a previously proposed and approved action as executed, recording the execution result in the run timeline. Only actions that have been approved may be executed; attempting to execute a pending or rejected action returns 400. Returns 404 if the run or action event does not exist.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunEventDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -99,12 +120,16 @@ public static class RunEndpoints
group.MapPost("/{runId}/artifacts", AddArtifactAsync)
.WithName("AddArtifact")
.WithSummary("Adds an artifact to the run")
.WithDescription("Attaches an artifact (evidence pack, report, SBOM snippet, or other typed asset) to an active run. The artifact is recorded with its content digest, media type, size, and optional inline content. Adding an artifact updates the run's content digest, contributing to its attestation chain.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/complete", CompleteRunAsync)
.WithName("CompleteRun")
.WithSummary("Completes a run")
.WithDescription("Transitions an active AI investigation run to the Completed terminal state, optionally recording a summary of findings. Once completed, the run is immutable and ready for attestation. Returns 400 if the run is already in a terminal state.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -112,6 +137,8 @@ public static class RunEndpoints
group.MapPost("/{runId}/cancel", CancelRunAsync)
.WithName("CancelRun")
.WithSummary("Cancels a run")
.WithDescription("Transitions an active or pending-approval AI investigation run to the Cancelled terminal state, optionally recording a cancellation reason. Cancelled runs are immutable and excluded from active and pending-approval queries. Returns 400 if the run is already in a terminal state.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -119,12 +146,16 @@ public static class RunEndpoints
group.MapPost("/{runId}/handoff", HandOffRunAsync)
.WithName("HandOffRun")
.WithSummary("Hands off a run to another user")
.WithDescription("Transfers ownership of an active AI investigation run to another user within the same tenant. A hand-off event is recorded in the run timeline with the target user ID and an optional message. Returns 404 if the run does not exist or the target user is not valid.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/attest", AttestRunAsync)
.WithName("AttestRun")
.WithSummary("Creates an attestation for a completed run")
.WithDescription("Generates and persists a cryptographic attestation for a completed AI investigation run, recording the content digest, model metadata, and claim hashes. The attestation can optionally be signed via the attestation sign endpoint. Returns 400 if the run is not in a terminal state or has already been attested.")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
@@ -132,11 +163,13 @@ public static class RunEndpoints
group.MapGet("/active", GetActiveRunsAsync)
.WithName("GetActiveRuns")
.WithSummary("Gets active runs for the current user")
.WithDescription("Returns up to 50 AI investigation runs in Created, Active, or PendingApproval state that were initiated by the current user within the authenticated tenant. Use this endpoint to resume in-progress investigations or surface runs awaiting user input.")
.Produces<ImmutableArray<RunDto>>();
group.MapGet("/pending-approval", GetPendingApprovalAsync)
.WithName("GetPendingApproval")
.WithSummary("Gets runs pending approval")
.WithDescription("Returns up to 50 AI investigation runs in the PendingApproval state for the authenticated tenant. Use this endpoint to surface runs that are blocked on a human approval decision before their proposed actions can be executed.")
.Produces<ImmutableArray<RunDto>>();
return group;

View File

@@ -24,7 +24,9 @@ using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.Remediation;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.AdvisoryAI.WebService.Endpoints;
using StellaOps.AdvisoryAI.WebService.Security;
using StellaOps.AdvisoryAI.WebService.Services;
using StellaOps.Auth.Abstractions;
using StellaOps.Evidence.Pack;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Router.AspNet;
@@ -87,6 +89,12 @@ builder.Services.TryAddSingleton<IEvidencePackSigner, NullEvidencePackSigner>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
builder.Services
.AddAuthentication(AdvisoryAiHeaderAuthenticationHandler.SchemeName)
.AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, AdvisoryAiHeaderAuthenticationHandler>(
AdvisoryAiHeaderAuthenticationHandler.SchemeName,
static _ => { });
builder.Services.AddAuthorization(options => options.AddAdvisoryAIPolicies());
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
@@ -136,90 +144,115 @@ if (app.Environment.IsDevelopment())
}
app.UseStellaOpsCors();
app.UseAuthorization();
app.UseRateLimiter();
app.TryUseStellaRouter(routerEnabled);
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapPost("/v1/advisory-ai/pipeline/{taskType}", HandleSinglePlan)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapPost("/v1/advisory-ai/pipeline:batch", HandleBatchPlans)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapGet("/v1/advisory-ai/outputs/{cacheKey}", HandleGetOutput)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
// Explanation endpoints (SPRINT_20251226_015_AI_zastava_companion)
app.MapPost("/v1/advisory-ai/explain", HandleExplain)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapGet("/v1/advisory-ai/explain/{explanationId}/replay", HandleExplanationReplay)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
app.MapPost("/v1/advisory-ai/companion/explain", HandleCompanionExplain)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
// Remediation endpoints (SPRINT_20251226_016_AI_remedy_autopilot)
app.MapPost("/v1/advisory-ai/remediation/plan", HandleRemediationPlan)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapPost("/v1/advisory-ai/remediation/apply", HandleApplyRemediation)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapGet("/v1/advisory-ai/remediation/status/{prId}", HandleRemediationStatus)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
// Policy Studio endpoints (SPRINT_20251226_017_AI_policy_copilot)
app.MapPost("/v1/advisory-ai/policy/studio/parse", HandlePolicyParse)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapPost("/v1/advisory-ai/policy/studio/generate", HandlePolicyGenerate)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapPost("/v1/advisory-ai/policy/studio/validate", HandlePolicyValidate)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapPost("/v1/advisory-ai/policy/studio/compile", HandlePolicyCompile)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
// VEX-AI-016: Consent endpoints
app.MapGet("/v1/advisory-ai/consent", HandleGetConsent)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
app.MapPost("/v1/advisory-ai/consent", HandleGrantConsent)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapDelete("/v1/advisory-ai/consent", HandleRevokeConsent)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
// VEX-AI-016: Justification endpoint
app.MapPost("/v1/advisory-ai/justify", HandleJustify)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
// VEX-AI-016: Remediate alias (maps to remediation/plan)
app.MapPost("/v1/advisory-ai/remediate", HandleRemediate)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
// VEX-AI-016: Rate limits endpoint
app.MapGet("/v1/advisory-ai/rate-limits", HandleGetRateLimits)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
// Chat endpoints (SPRINT_20260107_006_003 CH-005)
app.MapPost("/v1/advisory-ai/conversations", HandleCreateConversation)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapGet("/v1/advisory-ai/conversations/{conversationId}", HandleGetConversation)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
app.MapPost("/v1/advisory-ai/conversations/{conversationId}/turns", HandleAddTurn)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapDelete("/v1/advisory-ai/conversations/{conversationId}", HandleDeleteConversation)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy);
app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
.RequireRateLimiting("advisory-ai");
.RequireRateLimiting("advisory-ai")
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy);
// Chat gateway endpoints (controlled conversational interface)
app.MapChatEndpoints();

View File

@@ -0,0 +1,103 @@
// <copyright file="AdvisoryAIPolicies.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.AspNetCore.Authorization;
using StellaOps.Auth.Abstractions;
using System.Security.Claims;
namespace StellaOps.AdvisoryAI.WebService.Security;
/// <summary>
/// Named authorization policy constants and registration for the AdvisoryAI service.
/// Every business endpoint MUST use a named policy from this class via
/// <c>.RequireAuthorization(AdvisoryAIPolicies.XxxPolicy)</c>.
///
/// Scope hierarchy (any of these grants access to the corresponding level):
/// - View : advisory-ai:view
/// - Operate : advisory-ai:operate, advisory-ai:view (operate implies view)
/// - Admin : advisory-ai:admin, advisory-ai:operate (admin implies operate + view)
/// </summary>
public static class AdvisoryAIPolicies
{
/// <summary>Policy for read-only access to AI artefacts (outputs, attestations, evidence packs).</summary>
public const string ViewPolicy = "advisory-ai.view";
/// <summary>Policy for inference and workflow execution (pipeline, explain, remediate, chat, search).</summary>
public const string OperatePolicy = "advisory-ai.operate";
/// <summary>Policy for administrative operations (index rebuild, adapter configuration).</summary>
public const string AdminPolicy = "advisory-ai.admin";
/// <summary>
/// Registers all AdvisoryAI named policies into the authorization options.
/// Call from <c>builder.Services.AddAuthorization(options => options.AddAdvisoryAIPolicies())</c>.
/// </summary>
public static void AddAdvisoryAIPolicies(this AuthorizationOptions options)
{
ArgumentNullException.ThrowIfNull(options);
// View: advisory-ai:view OR advisory-ai:operate OR advisory-ai:admin
options.AddPolicy(ViewPolicy, policy =>
{
policy.AddAuthenticationSchemes(AdvisoryAiHeaderAuthenticationHandler.SchemeName);
policy.RequireAuthenticatedUser();
policy.RequireAssertion(ctx => HasAnyScope(ctx.User,
StellaOpsScopes.AdvisoryAiView,
StellaOpsScopes.AdvisoryAiOperate,
StellaOpsScopes.AdvisoryAiAdmin));
});
// Operate: advisory-ai:operate OR advisory-ai:admin
options.AddPolicy(OperatePolicy, policy =>
{
policy.AddAuthenticationSchemes(AdvisoryAiHeaderAuthenticationHandler.SchemeName);
policy.RequireAuthenticatedUser();
policy.RequireAssertion(ctx => HasAnyScope(ctx.User,
StellaOpsScopes.AdvisoryAiOperate,
StellaOpsScopes.AdvisoryAiAdmin));
});
// Admin: advisory-ai:admin only
options.AddPolicy(AdminPolicy, policy =>
{
policy.AddAuthenticationSchemes(AdvisoryAiHeaderAuthenticationHandler.SchemeName);
policy.RequireAuthenticatedUser();
policy.RequireAssertion(ctx => HasAnyScope(ctx.User,
StellaOpsScopes.AdvisoryAiAdmin));
});
}
/// <summary>
/// Returns true if the principal holds at least one of the specified scopes.
/// Scopes are read from the <c>scope</c> claim (space-delimited) and individual
/// <c>scp</c> claims as set by <see cref="AdvisoryAiHeaderAuthenticationHandler"/>.
/// </summary>
private static bool HasAnyScope(ClaimsPrincipal user, params string[] allowedScopes)
{
var allowed = new HashSet<string>(allowedScopes, StringComparer.OrdinalIgnoreCase);
foreach (var claim in user.FindAll(StellaOpsClaimTypes.Scope))
{
foreach (var token in claim.Value.Split(
' ',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (allowed.Contains(token))
{
return true;
}
}
}
foreach (var claim in user.FindAll(StellaOpsClaimTypes.ScopeItem))
{
if (!string.IsNullOrWhiteSpace(claim.Value) && allowed.Contains(claim.Value.Trim()))
{
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;
namespace StellaOps.AdvisoryAI.WebService.Security;
internal sealed class AdvisoryAiHeaderAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "AdvisoryAiHeader";
public AdvisoryAiHeaderAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim>();
var actor = FirstHeaderValue("X-StellaOps-Actor")
?? FirstHeaderValue("X-User-Id")
?? "anonymous";
claims.Add(new Claim(ClaimTypes.NameIdentifier, actor));
claims.Add(new Claim(ClaimTypes.Name, actor));
var tenant = FirstHeaderValue("X-StellaOps-Tenant")
?? FirstHeaderValue("X-Tenant-Id");
if (!string.IsNullOrWhiteSpace(tenant))
{
claims.Add(new Claim("tenant_id", tenant));
}
AddScopeClaims(claims, Request.Headers["X-StellaOps-Scopes"]);
AddScopeClaims(claims, Request.Headers["X-Stella-Scopes"]);
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
private string? FirstHeaderValue(string headerName)
{
if (!Request.Headers.TryGetValue(headerName, out var values))
{
return null;
}
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
private static void AddScopeClaims(List<Claim> claims, IEnumerable<string> values)
{
foreach (var value in values)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
foreach (var token in value.Split(
[' ', ','],
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
claims.Add(new Claim("scope", token));
}
}
}
}

View File

@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260222_051-AKS-API | DONE | Extended AKS search/open-action endpoint contract and added header-based authentication wiring (`AddAuthentication` + `AddAuthorization` + `UseAuthorization`) so `RequireAuthorization()` endpoints execute without runtime middleware errors. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -11,7 +11,34 @@ internal sealed record DoctorSearchSeedEntry(
string RunCommand,
IReadOnlyList<string> Symptoms,
IReadOnlyList<string> Tags,
IReadOnlyList<string> References);
IReadOnlyList<string> References,
DoctorSearchControl? Control = null);
internal sealed record DoctorSearchControl(
string Mode,
bool RequiresConfirmation,
bool IsDestructive,
bool RequiresBackup,
string? InspectCommand,
string? VerificationCommand);
internal sealed record DoctorControlSeedEntry(
string CheckCode,
string Control,
bool RequiresConfirmation,
bool IsDestructive,
bool RequiresBackup,
string? InspectCommand,
string? VerificationCommand,
IReadOnlyList<string> Keywords,
string? Title = null,
string? Severity = null,
string? Description = null,
string? Remediation = null,
string? RunCommand = null,
IReadOnlyList<string>? Symptoms = null,
IReadOnlyList<string>? Tags = null,
IReadOnlyList<string>? References = null);
internal static class DoctorSearchSeedLoader
{
@@ -33,3 +60,24 @@ internal static class DoctorSearchSeedLoader
.ToList();
}
}
internal static class DoctorControlSeedLoader
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static IReadOnlyList<DoctorControlSeedEntry> Load(string absolutePath)
{
if (!File.Exists(absolutePath))
{
return [];
}
using var stream = File.OpenRead(absolutePath);
var entries = JsonSerializer.Deserialize<List<DoctorControlSeedEntry>>(stream, JsonOptions) ?? [];
return entries
.Where(static entry => !string.IsNullOrWhiteSpace(entry.CheckCode))
.OrderBy(static entry => entry.CheckCode, StringComparer.Ordinal)
.ToList();
}
}

View File

@@ -61,16 +61,16 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
private async Task<KnowledgeIndexSnapshot> BuildSnapshotAsync(CancellationToken cancellationToken)
{
var repositoryRoot = ResolveRepositoryRoot();
var effective = ResolveEffectiveOptions();
var documents = new Dictionary<string, KnowledgeSourceDocument>(StringComparer.Ordinal);
var chunks = new Dictionary<string, KnowledgeChunkDocument>(StringComparer.Ordinal);
var apiSpecs = new Dictionary<string, KnowledgeApiSpec>(StringComparer.Ordinal);
var apiOperations = new Dictionary<string, KnowledgeApiOperation>(StringComparer.Ordinal);
var doctorProjections = new Dictionary<string, KnowledgeDoctorProjection>(StringComparer.Ordinal);
IngestMarkdown(repositoryRoot, documents, chunks);
IngestOpenApi(repositoryRoot, documents, chunks, apiSpecs, apiOperations);
await IngestDoctorAsync(repositoryRoot, documents, chunks, doctorProjections, cancellationToken).ConfigureAwait(false);
IngestMarkdown(effective, documents, chunks);
IngestOpenApi(effective, documents, chunks, apiSpecs, apiOperations);
await IngestDoctorAsync(effective, documents, chunks, doctorProjections, cancellationToken).ConfigureAwait(false);
return new KnowledgeIndexSnapshot(
documents.Values.OrderBy(static item => item.DocId, StringComparer.Ordinal).ToArray(),
@@ -80,36 +80,64 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
doctorProjections.Values.OrderBy(static item => item.CheckCode, StringComparer.Ordinal).ToArray());
}
private string ResolveRepositoryRoot()
private EffectiveIngestionOptions ResolveEffectiveOptions()
{
if (string.IsNullOrWhiteSpace(_options.RepositoryRoot))
var repositoryRoot = string.IsNullOrWhiteSpace(_options.RepositoryRoot)
? Directory.GetCurrentDirectory()
: Path.IsPathRooted(_options.RepositoryRoot)
? Path.GetFullPath(_options.RepositoryRoot)
: Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _options.RepositoryRoot));
var markdownRoots = (_options.MarkdownRoots ?? [])
.Where(static root => !string.IsNullOrWhiteSpace(root))
.Select(static root => root.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static root => root, StringComparer.Ordinal)
.ToArray();
if (markdownRoots.Length == 0)
{
return Directory.GetCurrentDirectory();
markdownRoots = ["docs"];
}
if (Path.IsPathRooted(_options.RepositoryRoot))
var openApiRoots = (_options.OpenApiRoots ?? [])
.Where(static root => !string.IsNullOrWhiteSpace(root))
.Select(static root => root.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static root => root, StringComparer.Ordinal)
.ToArray();
if (openApiRoots.Length == 0)
{
return Path.GetFullPath(_options.RepositoryRoot);
openApiRoots = ["src", "devops/compose"];
}
return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _options.RepositoryRoot));
return new EffectiveIngestionOptions(
string.IsNullOrWhiteSpace(_options.Product) ? "stella-ops" : _options.Product.Trim(),
string.IsNullOrWhiteSpace(_options.Version) ? "local" : _options.Version.Trim(),
repositoryRoot,
_options.DoctorChecksEndpoint ?? string.Empty,
_options.DoctorSeedPath ?? string.Empty,
_options.DoctorControlsPath ?? string.Empty,
_options.MarkdownAllowListPath ?? string.Empty,
markdownRoots,
_options.OpenApiAggregatePath ?? string.Empty,
openApiRoots);
}
private void IngestMarkdown(
string repositoryRoot,
EffectiveIngestionOptions options,
IDictionary<string, KnowledgeSourceDocument> documents,
IDictionary<string, KnowledgeChunkDocument> chunks)
{
var markdownFiles = EnumerateMarkdownFiles(repositoryRoot);
var markdownFiles = EnumerateMarkdownFiles(options.RepositoryRoot, options);
foreach (var filePath in markdownFiles)
{
var relativePath = ToRelativeRepositoryPath(repositoryRoot, filePath);
var relativePath = ToRelativeRepositoryPath(options.RepositoryRoot, filePath);
var lines = File.ReadAllLines(filePath);
var content = string.Join('\n', lines);
var title = ExtractMarkdownDocumentTitle(lines, relativePath);
var pathTags = ExtractPathTags(relativePath);
var docId = KnowledgeSearchText.StableId("doc", "markdown", _options.Product, _options.Version, relativePath);
var docId = KnowledgeSearchText.StableId("doc", "markdown", options.Product, options.Version, relativePath);
var docMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
{
["kind"] = "markdown",
@@ -120,8 +148,8 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
documents[docId] = new KnowledgeSourceDocument(
docId,
"markdown",
_options.Product,
_options.Version,
options.Product,
options.Version,
"repo",
relativePath,
title,
@@ -158,16 +186,16 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
}
private void IngestOpenApi(
string repositoryRoot,
EffectiveIngestionOptions options,
IDictionary<string, KnowledgeSourceDocument> documents,
IDictionary<string, KnowledgeChunkDocument> chunks,
IDictionary<string, KnowledgeApiSpec> apiSpecs,
IDictionary<string, KnowledgeApiOperation> apiOperations)
{
var apiFiles = EnumerateOpenApiFiles(repositoryRoot);
var apiFiles = EnumerateOpenApiFiles(options.RepositoryRoot, options);
foreach (var filePath in apiFiles)
{
var relativePath = ToRelativeRepositoryPath(repositoryRoot, filePath);
var relativePath = ToRelativeRepositoryPath(options.RepositoryRoot, filePath);
JsonDocument document;
try
{
@@ -194,7 +222,7 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
var apiVersion = TryGetNestedString(root, "info", "version");
var pathTags = ExtractPathTags(relativePath);
var docId = KnowledgeSearchText.StableId("doc", "openapi", _options.Product, _options.Version, relativePath);
var docId = KnowledgeSearchText.StableId("doc", "openapi", options.Product, options.Version, relativePath);
var docMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
{
["kind"] = "openapi",
@@ -206,15 +234,15 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
documents[docId] = new KnowledgeSourceDocument(
docId,
"openapi",
_options.Product,
_options.Version,
options.Product,
options.Version,
"repo",
relativePath,
title,
KnowledgeSearchText.StableId(root.GetRawText()),
docMetadata);
var specId = KnowledgeSearchText.StableId("api-spec", _options.Product, _options.Version, relativePath, service);
var specId = KnowledgeSearchText.StableId("api-spec", options.Product, options.Version, relativePath, service);
apiSpecs[specId] = new KnowledgeApiSpec(
specId,
docId,
@@ -299,19 +327,24 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
}
private async Task IngestDoctorAsync(
string repositoryRoot,
EffectiveIngestionOptions options,
IDictionary<string, KnowledgeSourceDocument> documents,
IDictionary<string, KnowledgeChunkDocument> chunks,
IDictionary<string, KnowledgeDoctorProjection> doctorProjections,
CancellationToken cancellationToken)
{
var seedPath = ResolvePath(repositoryRoot, _options.DoctorSeedPath);
var seedPath = ResolvePath(options.RepositoryRoot, options.DoctorSeedPath);
var seedEntries = DoctorSearchSeedLoader.Load(seedPath)
.ToDictionary(static entry => entry.CheckCode, StringComparer.OrdinalIgnoreCase);
var endpointEntries = await LoadDoctorEndpointMetadataAsync(cancellationToken).ConfigureAwait(false);
var controlsPath = ResolvePath(options.RepositoryRoot, options.DoctorControlsPath);
var controlEntries = DoctorControlSeedLoader.Load(controlsPath)
.ToDictionary(static entry => entry.CheckCode, StringComparer.OrdinalIgnoreCase);
var endpointEntries = await LoadDoctorEndpointMetadataAsync(options.DoctorChecksEndpoint, cancellationToken).ConfigureAwait(false);
var checkCodes = seedEntries.Keys
.Union(endpointEntries.Keys, StringComparer.OrdinalIgnoreCase)
.Union(controlEntries.Keys, StringComparer.OrdinalIgnoreCase)
.OrderBy(static code => code, StringComparer.Ordinal)
.ToList();
@@ -319,11 +352,12 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
{
seedEntries.TryGetValue(checkCode, out var seeded);
endpointEntries.TryGetValue(checkCode, out var endpoint);
controlEntries.TryGetValue(checkCode, out var seededControl);
var title = seeded?.Title ?? endpoint?.Title ?? checkCode;
var severity = NormalizeSeverity(seeded?.Severity ?? endpoint?.Severity ?? "warn");
var description = seeded?.Description ?? endpoint?.Description ?? string.Empty;
var remediation = seeded?.Remediation;
var title = seeded?.Title ?? endpoint?.Title ?? seededControl?.Title ?? checkCode;
var severity = NormalizeSeverity(seeded?.Severity ?? endpoint?.Severity ?? seededControl?.Severity ?? "warn");
var description = seeded?.Description ?? endpoint?.Description ?? seededControl?.Description ?? string.Empty;
var remediation = seeded?.Remediation ?? seededControl?.Remediation;
if (string.IsNullOrWhiteSpace(remediation))
{
remediation = !string.IsNullOrWhiteSpace(description)
@@ -331,45 +365,69 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
: $"Inspect {checkCode} and run targeted diagnostics.";
}
var runCommand = string.IsNullOrWhiteSpace(seeded?.RunCommand)
var seededRunCommand = seeded?.RunCommand;
if (string.IsNullOrWhiteSpace(seededRunCommand) && !string.IsNullOrWhiteSpace(seededControl?.RunCommand))
{
seededRunCommand = seededControl.RunCommand;
}
var runCommand = string.IsNullOrWhiteSpace(seededRunCommand)
? $"stella doctor run --check {checkCode}"
: seeded!.RunCommand.Trim();
: seededRunCommand.Trim();
var symptoms = MergeOrdered(
seeded?.Symptoms ?? [],
endpoint?.Symptoms ?? [],
seededControl?.Symptoms ?? [],
seededControl?.Keywords ?? [],
ExpandSymptomsFromText(description),
ExpandSymptomsFromText(title));
var tags = MergeOrdered(
seeded?.Tags ?? [],
endpoint?.Tags ?? [],
seededControl?.Tags ?? [],
["doctor", "diagnostics"]);
var references = MergeOrdered(
seeded?.References ?? [],
endpoint?.References ?? []);
endpoint?.References ?? [],
seededControl?.References ?? []);
var docId = KnowledgeSearchText.StableId("doc", "doctor", _options.Product, _options.Version, checkCode);
var control = BuildDoctorControl(
checkCode,
severity,
runCommand,
seeded?.Control,
seededControl,
symptoms,
title,
description);
var docId = KnowledgeSearchText.StableId("doc", "doctor", options.Product, options.Version, checkCode);
var docMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
{
["kind"] = "doctor",
["checkCode"] = checkCode,
["tags"] = tags
["tags"] = tags,
["control"] = control.Control,
["requiresConfirmation"] = control.RequiresConfirmation,
["isDestructive"] = control.IsDestructive,
["requiresBackup"] = control.RequiresBackup
});
documents[docId] = new KnowledgeSourceDocument(
docId,
"doctor",
_options.Product,
_options.Version,
options.Product,
options.Version,
"doctor",
$"doctor://{checkCode}",
title,
KnowledgeSearchText.StableId(checkCode, title, remediation),
docMetadata);
var body = BuildDoctorSearchBody(checkCode, title, severity, description, remediation, runCommand, symptoms, references);
var body = BuildDoctorSearchBody(checkCode, title, severity, description, remediation, runCommand, symptoms, references, control);
var anchor = KnowledgeSearchText.Slugify(checkCode);
var chunkId = KnowledgeSearchText.StableId("chunk", "doctor", checkCode, severity);
var chunkMetadata = CreateJsonDocument(new SortedDictionary<string, object?>(StringComparer.Ordinal)
@@ -378,7 +436,14 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
["severity"] = severity,
["runCommand"] = runCommand,
["tags"] = tags,
["service"] = "doctor"
["service"] = "doctor",
["control"] = control.Control,
["requiresConfirmation"] = control.RequiresConfirmation,
["isDestructive"] = control.IsDestructive,
["requiresBackup"] = control.RequiresBackup,
["inspectCommand"] = control.InspectCommand,
["verificationCommand"] = control.VerificationCommand,
["keywords"] = control.Keywords
});
chunks[chunkId] = new KnowledgeChunkDocument(
@@ -407,9 +472,9 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
}
}
private async Task<Dictionary<string, DoctorEndpointMetadata>> LoadDoctorEndpointMetadataAsync(CancellationToken cancellationToken)
private async Task<Dictionary<string, DoctorEndpointMetadata>> LoadDoctorEndpointMetadataAsync(string endpoint, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.DoctorChecksEndpoint))
if (string.IsNullOrWhiteSpace(endpoint))
{
return new Dictionary<string, DoctorEndpointMetadata>(StringComparer.OrdinalIgnoreCase);
}
@@ -419,10 +484,10 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
using var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromMilliseconds(Math.Max(500, _options.QueryTimeoutMs));
using var response = await client.GetAsync(_options.DoctorChecksEndpoint, cancellationToken).ConfigureAwait(false);
using var response = await client.GetAsync(endpoint, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Doctor check metadata endpoint {Endpoint} returned {StatusCode}.", _options.DoctorChecksEndpoint, (int)response.StatusCode);
_logger.LogWarning("Doctor check metadata endpoint {Endpoint} returned {StatusCode}.", endpoint, (int)response.StatusCode);
return new Dictionary<string, DoctorEndpointMetadata>(StringComparer.OrdinalIgnoreCase);
}
@@ -482,7 +547,7 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
_logger.LogWarning(ex, "Failed to load doctor metadata from {Endpoint}.", _options.DoctorChecksEndpoint);
_logger.LogWarning(ex, "Failed to load doctor metadata from {Endpoint}.", endpoint);
return new Dictionary<string, DoctorEndpointMetadata>(StringComparer.OrdinalIgnoreCase);
}
}
@@ -603,30 +668,61 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
return true;
}
private IReadOnlyList<string> EnumerateMarkdownFiles(string repositoryRoot)
private IReadOnlyList<string> EnumerateMarkdownFiles(string repositoryRoot, EffectiveIngestionOptions options)
{
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var root in _options.MarkdownRoots.OrderBy(static item => item, StringComparer.Ordinal))
{
var absoluteRoot = ResolvePath(repositoryRoot, root);
if (!Directory.Exists(absoluteRoot))
{
continue;
}
foreach (var file in Directory.EnumerateFiles(absoluteRoot, "*.md", SearchOption.AllDirectories))
{
files.Add(Path.GetFullPath(file));
}
var allowListPath = ResolvePath(repositoryRoot, options.MarkdownAllowListPath);
var allowListIncludes = MarkdownSourceAllowListLoader.LoadIncludes(allowListPath);
foreach (var includePath in allowListIncludes)
{
AddMarkdownFilesFromPath(repositoryRoot, includePath, files);
}
if (files.Count > 0)
{
return files.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase).ToArray();
}
foreach (var root in options.MarkdownRoots.OrderBy(static item => item, StringComparer.Ordinal))
{
AddMarkdownFilesFromPath(repositoryRoot, root, files);
}
return files.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase).ToArray();
}
private IReadOnlyList<string> EnumerateOpenApiFiles(string repositoryRoot)
private void AddMarkdownFilesFromPath(string repositoryRoot, string configuredPath, ISet<string> files)
{
var absolutePath = ResolvePath(repositoryRoot, configuredPath);
if (File.Exists(absolutePath) &&
Path.GetExtension(absolutePath).Equals(".md", StringComparison.OrdinalIgnoreCase))
{
files.Add(Path.GetFullPath(absolutePath));
return;
}
if (!Directory.Exists(absolutePath))
{
return;
}
foreach (var file in Directory.EnumerateFiles(absolutePath, "*.md", SearchOption.AllDirectories))
{
files.Add(Path.GetFullPath(file));
}
}
private IReadOnlyList<string> EnumerateOpenApiFiles(string repositoryRoot, EffectiveIngestionOptions options)
{
var aggregatePath = ResolvePath(repositoryRoot, options.OpenApiAggregatePath);
if (File.Exists(aggregatePath))
{
return [Path.GetFullPath(aggregatePath)];
}
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var root in _options.OpenApiRoots.OrderBy(static item => item, StringComparer.Ordinal))
foreach (var root in options.OpenApiRoots.OrderBy(static item => item, StringComparer.Ordinal))
{
var absoluteRoot = ResolvePath(repositoryRoot, root);
if (!Directory.Exists(absoluteRoot))
@@ -764,7 +860,8 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
string remediation,
string runCommand,
IReadOnlyList<string> symptoms,
IReadOnlyList<string> references)
IReadOnlyList<string> references,
DoctorControlSeedEntry control)
{
var builder = new StringBuilder();
builder.Append("check: ").Append(checkCode).AppendLine();
@@ -778,6 +875,14 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
builder.Append("remediation: ").Append(remediation).AppendLine();
builder.Append("run: ").Append(runCommand).AppendLine();
builder.Append("control: ").Append(control.Control).AppendLine();
builder.Append("requiresConfirmation: ").Append(control.RequiresConfirmation ? "true" : "false").AppendLine();
builder.Append("isDestructive: ").Append(control.IsDestructive ? "true" : "false").AppendLine();
builder.Append("requiresBackup: ").Append(control.RequiresBackup ? "true" : "false").AppendLine();
if (!string.IsNullOrWhiteSpace(control.InspectCommand))
{
builder.Append("inspect: ").Append(control.InspectCommand).AppendLine();
}
if (symptoms.Count > 0)
{
@@ -789,9 +894,96 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
builder.Append("references: ").Append(string.Join(", ", references)).AppendLine();
}
if (control.Keywords.Count > 0)
{
builder.Append("keywords: ").Append(string.Join(", ", control.Keywords)).AppendLine();
}
return builder.ToString().Trim();
}
private static DoctorControlSeedEntry BuildDoctorControl(
string checkCode,
string severity,
string runCommand,
DoctorSearchControl? embeddedControl,
DoctorControlSeedEntry? seededControl,
IReadOnlyList<string> symptoms,
string title,
string description)
{
var mode = NormalizeControlMode(seededControl?.Control ?? embeddedControl?.Mode ?? InferControlFromSeverity(severity));
var requiresConfirmation = seededControl?.RequiresConfirmation ?? embeddedControl?.RequiresConfirmation ?? !mode.Equals("safe", StringComparison.Ordinal);
var isDestructive = seededControl?.IsDestructive ?? embeddedControl?.IsDestructive ?? mode.Equals("destructive", StringComparison.Ordinal);
var requiresBackup = seededControl?.RequiresBackup ?? embeddedControl?.RequiresBackup ?? isDestructive;
var inspectCommand = FirstNonEmpty(
seededControl?.InspectCommand,
embeddedControl?.InspectCommand,
$"stella doctor run --check {checkCode} --mode quick");
var verificationCommand = FirstNonEmpty(
seededControl?.VerificationCommand,
embeddedControl?.VerificationCommand,
runCommand);
var keywords = MergeOrdered(
seededControl?.Keywords ?? [],
symptoms,
ExpandSymptomsFromText(title),
ExpandSymptomsFromText(description));
return new DoctorControlSeedEntry(
checkCode,
mode,
requiresConfirmation,
isDestructive,
requiresBackup,
inspectCommand,
verificationCommand,
keywords);
}
private static string FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return string.Empty;
}
private static string InferControlFromSeverity(string severity)
{
return NormalizeSeverity(severity) switch
{
"fail" => "manual",
"warn" => "safe",
_ => "safe"
};
}
private static string NormalizeControlMode(string control)
{
if (string.IsNullOrWhiteSpace(control))
{
return "safe";
}
return control.Trim().ToLowerInvariant() switch
{
"safe" => "safe",
"manual" => "manual",
"destructive" => "destructive",
"disabled" => "disabled",
_ => "safe"
};
}
private static JsonDocument CloneOrDefault(JsonElement source, string propertyName, string fallbackJson)
{
if (source.ValueKind == JsonValueKind.Object && source.TryGetProperty(propertyName, out var value))
@@ -989,6 +1181,18 @@ internal sealed class KnowledgeIndexer : IKnowledgeIndexer
return JsonDocument.Parse(json);
}
private sealed record EffectiveIngestionOptions(
string Product,
string Version,
string RepositoryRoot,
string DoctorChecksEndpoint,
string DoctorSeedPath,
string DoctorControlsPath,
string MarkdownAllowListPath,
IReadOnlyList<string> MarkdownRoots,
string OpenApiAggregatePath,
IReadOnlyList<string> OpenApiRoots);
private sealed record DoctorEndpointMetadata(
string Title,
string Severity,

View File

@@ -65,7 +65,10 @@ public sealed record KnowledgeOpenDoctorAction(
string CheckCode,
string Severity,
bool CanRun,
string RunCommand);
string RunCommand,
string Control = "safe",
bool RequiresConfirmation = false,
bool IsDestructive = false);
public sealed record KnowledgeSearchDiagnostics(
int FtsMatches,

View File

@@ -42,6 +42,14 @@ public sealed class KnowledgeSearchOptions
public string DoctorSeedPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-seed.json";
public string DoctorControlsPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/doctor-search-controls.json";
public string MarkdownAllowListPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/KnowledgeSearch/knowledge-docs-allowlist.json";
public string OpenApiAggregatePath { get; set; } = "devops/compose/openapi_current.json";
public List<string> MarkdownRoots { get; set; } = ["docs"];
public List<string> OpenApiRoots { get; set; } = ["src", "devops/compose"];

View File

@@ -11,6 +11,66 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
{
private const int ReciprocalRankConstant = 60;
private static readonly Regex MethodPathPattern = new("\\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\\s+(/[^\\s]+)", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private static readonly string[] ApiIntentTerms =
[
"endpoint",
"api",
"openapi",
"swagger",
"operation",
"route",
"path",
"method",
"contract"
];
private static readonly string[] DoctorIntentTerms =
[
"doctor",
"check",
"readiness",
"health",
"diagnostic",
"remediation",
"symptom"
];
private static readonly string[] DocsIntentTerms =
[
"how to",
"how do i",
"guide",
"runbook",
"troubleshoot",
"documentation",
"docs",
"playbook",
"steps"
];
private static readonly HashSet<string> NoiseTerms = new(StringComparer.Ordinal)
{
"a",
"an",
"and",
"api",
"do",
"endpoint",
"for",
"how",
"i",
"in",
"is",
"method",
"of",
"on",
"operation",
"or",
"path",
"route",
"the",
"to",
"what",
"where",
"which"
};
private readonly KnowledgeSearchOptions _options;
private readonly IKnowledgeSearchStore _store;
@@ -181,9 +241,31 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
var normalizedQuery = query.Trim();
var lowerQuery = normalizedQuery.ToLowerInvariant();
var metadata = row.Metadata.RootElement;
var isApi = row.Kind.Equals("api_operation", StringComparison.OrdinalIgnoreCase);
var isDoctor = row.Kind.Equals("doctor_check", StringComparison.OrdinalIgnoreCase);
var isDocs = !isApi && !isDoctor;
var boost = 0d;
if (row.Kind.Equals("doctor_check", StringComparison.OrdinalIgnoreCase))
var apiIntent = ContainsAnyTerm(lowerQuery, ApiIntentTerms);
var doctorIntent = ContainsAnyTerm(lowerQuery, DoctorIntentTerms);
var docsIntent = ContainsAnyTerm(lowerQuery, DocsIntentTerms);
if (apiIntent)
{
boost += isApi ? 0.28d : -0.04d;
}
if (doctorIntent && isDoctor)
{
boost += 0.20d;
}
if (docsIntent && isDocs)
{
boost += 0.12d;
}
if (isDoctor)
{
var checkCode = GetMetadataString(metadata, "checkCode");
if (!string.IsNullOrWhiteSpace(checkCode) && checkCode.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase))
@@ -192,8 +274,16 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
}
}
if (row.Kind.Equals("api_operation", StringComparison.OrdinalIgnoreCase))
if (isApi)
{
var apiText = $"{row.Title} {row.Body}".ToLowerInvariant();
var termMatches = ExtractSalientTerms(lowerQuery)
.Count(term => apiText.Contains(term, StringComparison.Ordinal));
if (termMatches > 0)
{
boost += Math.Min(0.30d, termMatches * 0.08d);
}
var operationId = GetMetadataString(metadata, "operationId");
if (!string.IsNullOrWhiteSpace(operationId) && operationId.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase))
{
@@ -249,6 +339,40 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
return boost;
}
private static bool ContainsAnyTerm(string query, IReadOnlyList<string> terms)
{
if (string.IsNullOrWhiteSpace(query) || terms.Count == 0)
{
return false;
}
foreach (var term in terms)
{
if (query.Contains(term, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static IReadOnlyList<string> ExtractSalientTerms(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
return query
.Split([' ', '\t', '\r', '\n', ':', ';', ',', '.', '/', '\\', '?', '!', '[', ']', '{', '}', '(', ')', '"', '\''], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static token => token.ToLowerInvariant())
.Where(static token => token.Length >= 4)
.Where(token => !NoiseTerms.Contains(token))
.Distinct(StringComparer.Ordinal)
.ToImmutableArray();
}
private KnowledgeSearchResult BuildResult(
KnowledgeChunkRow row,
string query,
@@ -282,8 +406,11 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
Doctor: new KnowledgeOpenDoctorAction(
GetMetadataString(metadata, "checkCode") ?? row.Title,
GetMetadataString(metadata, "severity") ?? "warn",
true,
GetMetadataString(metadata, "runCommand") ?? $"stella doctor run --check {row.Title}")),
!string.Equals(GetMetadataString(metadata, "control"), "disabled", StringComparison.OrdinalIgnoreCase),
GetMetadataString(metadata, "runCommand") ?? $"stella doctor run --check {row.Title}",
GetMetadataString(metadata, "control") ?? "safe",
GetMetadataBoolean(metadata, "requiresConfirmation"),
GetMetadataBoolean(metadata, "isDestructive"))),
_ => new KnowledgeOpenAction(
KnowledgeOpenActionType.Docs,
Docs: new KnowledgeOpenDocAction(
@@ -360,6 +487,22 @@ internal sealed class KnowledgeSearchService : IKnowledgeSearchService
.ToImmutableArray();
}
private static bool GetMetadataBoolean(JsonElement metadata, string propertyName)
{
if (metadata.ValueKind != JsonValueKind.Object || !metadata.TryGetProperty(propertyName, out var value))
{
return false;
}
return value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed,
_ => false
};
}
private int ResolveTopK(int? requested)
{
var fallback = Math.Max(1, _options.DefaultTopK);

View File

@@ -0,0 +1,92 @@
using System.Text.Json;
namespace StellaOps.AdvisoryAI.KnowledgeSearch;
internal static class MarkdownSourceAllowListLoader
{
public static IReadOnlyList<string> LoadIncludes(string absolutePath)
{
if (!File.Exists(absolutePath))
{
return [];
}
try
{
using var stream = File.OpenRead(absolutePath);
using var document = JsonDocument.Parse(stream);
return ExtractIncludes(document.RootElement);
}
catch (JsonException)
{
return [];
}
catch (IOException)
{
return [];
}
catch (UnauthorizedAccessException)
{
return [];
}
}
private static IReadOnlyList<string> ExtractIncludes(JsonElement element)
{
IEnumerable<string> values = [];
if (element.ValueKind == JsonValueKind.Array)
{
values = ReadStringArray(element);
}
else if (element.ValueKind == JsonValueKind.Object)
{
if (TryGetArray(element, "include", out var include))
{
values = ReadStringArray(include);
}
else if (TryGetArray(element, "includes", out include))
{
values = ReadStringArray(include);
}
else if (TryGetArray(element, "paths", out include))
{
values = ReadStringArray(include);
}
}
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
}
private static bool TryGetArray(JsonElement element, string propertyName, out JsonElement value)
{
if (element.TryGetProperty(propertyName, out value) && value.ValueKind == JsonValueKind.Array)
{
return true;
}
value = default;
return false;
}
private static IEnumerable<string> ReadStringArray(JsonElement array)
{
foreach (var item in array.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.String)
{
continue;
}
var value = item.GetString();
if (!string.IsNullOrWhiteSpace(value))
{
yield return value;
}
}
}
}

View File

@@ -0,0 +1,114 @@
[
{
"checkCode": "check.airgap.bundle.integrity",
"control": "manual",
"requiresConfirmation": true,
"isDestructive": false,
"requiresBackup": false,
"inspectCommand": "stella doctor run --check check.airgap.bundle.integrity --mode quick",
"verificationCommand": "stella doctor run --check check.airgap.bundle.integrity",
"keywords": [
"checksum mismatch",
"signature invalid",
"offline import failed"
]
},
{
"checkCode": "check.core.db.connectivity",
"control": "manual",
"requiresConfirmation": true,
"isDestructive": false,
"requiresBackup": false,
"inspectCommand": "stella doctor run --check check.core.db.connectivity --mode quick",
"verificationCommand": "stella doctor run --check check.core.db.connectivity",
"keywords": [
"connection refused",
"database unavailable",
"timeout expired"
]
},
{
"checkCode": "check.core.disk.space",
"control": "manual",
"requiresConfirmation": true,
"isDestructive": false,
"requiresBackup": false,
"inspectCommand": "stella doctor run --check check.core.disk.space --mode quick",
"verificationCommand": "stella doctor run --check check.core.disk.space",
"keywords": [
"disk full",
"no space left on device",
"write failure"
]
},
{
"checkCode": "check.integrations.secrets.binding",
"control": "safe",
"requiresConfirmation": false,
"isDestructive": false,
"requiresBackup": false,
"inspectCommand": "stella doctor run --check check.integrations.secrets.binding --mode quick",
"verificationCommand": "stella doctor run --check check.integrations.secrets.binding",
"keywords": [
"auth failed",
"invalid credential",
"secret missing"
]
},
{
"checkCode": "check.release.policy.gate",
"control": "safe",
"requiresConfirmation": false,
"isDestructive": false,
"requiresBackup": false,
"inspectCommand": "stella doctor run --check check.release.policy.gate --mode quick",
"verificationCommand": "stella doctor run --check check.release.policy.gate",
"keywords": [
"missing attestation",
"policy gate failed",
"promotion blocked"
]
},
{
"checkCode": "check.router.gateway.routes",
"control": "safe",
"requiresConfirmation": false,
"isDestructive": false,
"requiresBackup": false,
"inspectCommand": "stella doctor run --check check.router.gateway.routes --mode quick",
"verificationCommand": "stella doctor run --check check.router.gateway.routes",
"keywords": [
"404 on expected endpoint",
"gateway routing",
"route missing"
]
},
{
"checkCode": "check.security.oidc.readiness",
"control": "safe",
"requiresConfirmation": false,
"isDestructive": false,
"requiresBackup": false,
"inspectCommand": "stella doctor run --check check.security.oidc.readiness --mode quick",
"verificationCommand": "stella doctor run --check check.security.oidc.readiness",
"keywords": [
"invalid issuer",
"jwks fetch failed",
"oidc setup"
]
},
{
"checkCode": "check.telemetry.pipeline.delivery",
"control": "safe",
"requiresConfirmation": false,
"isDestructive": false,
"requiresBackup": false,
"inspectCommand": "stella doctor run --check check.telemetry.pipeline.delivery --mode quick",
"verificationCommand": "stella doctor run --check check.telemetry.pipeline.delivery",
"keywords": [
"delivery timeout",
"queue backlog",
"telemetry lag"
]
}
]

View File

@@ -0,0 +1,16 @@
{
"schema": "stellaops.advisoryai.docs-allowlist.v1",
"include": [
"docs/README.md",
"docs/INSTALL_GUIDE.md",
"docs/modules/advisory-ai",
"docs/modules/authority",
"docs/modules/cli",
"docs/modules/platform",
"docs/modules/policy",
"docs/modules/router",
"docs/modules/scanner",
"docs/operations",
"docs/operations/devops/runbooks"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,11 @@
<InternalsVisibleTo Include="StellaOps.AdvisoryAI.Tests" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Storage/Migrations/*.sql" />
<EmbeddedResource Include="Storage\Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="Storage\EfCore\CompiledModels\AdvisoryAiDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<None Update="KnowledgeSearch/doctor-search-seed.json">
@@ -20,10 +24,13 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
@@ -36,5 +43,7 @@
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Canonicalization\StellaOps.Canonicalization.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,24 +2,25 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.AdvisoryAI.Chat;
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
using StellaOps.AdvisoryAI.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Repositories;
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Storage;
/// <summary>
/// PostgreSQL-backed conversation storage.
/// PostgreSQL-backed conversation storage using EF Core.
/// Sprint: SPRINT_20260107_006_003 Task CH-008
/// Sprint: SPRINT_20260222_074 (EF Core conversion)
/// </summary>
public sealed class ConversationStore : IConversationStore, IAsyncDisposable
public sealed class ConversationStore : RepositoryBase<AdvisoryAiDataSource>, IConversationStore, IAsyncDisposable
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<ConversationStore> _logger;
private readonly ConversationStoreOptions _options;
private readonly TimeProvider _timeProvider;
@@ -32,13 +33,12 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
/// Initializes a new instance of the <see cref="ConversationStore"/> class.
/// </summary>
public ConversationStore(
NpgsqlDataSource dataSource,
AdvisoryAiDataSource dataSource,
ILogger<ConversationStore> logger,
ConversationStoreOptions? options = null,
TimeProvider? timeProvider = null)
: base(dataSource, logger)
{
_dataSource = dataSource;
_logger = logger;
_options = options ?? new ConversationStoreOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
@@ -48,28 +48,37 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
Conversation conversation,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO advisoryai.conversations (
conversation_id, tenant_id, user_id, created_at, updated_at,
context, metadata
) VALUES (
@conversationId, @tenantId, @userId, @createdAt, @updatedAt,
@context::jsonb, @metadata::jsonb
)
""";
await using var connection = await DataSource.OpenConnectionAsync(
conversation.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AdvisoryAiDbContextFactory.Create(
connection, CommandTimeoutSeconds, GetSchemaName());
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("conversationId", conversation.ConversationId);
cmd.Parameters.AddWithValue("tenantId", conversation.TenantId);
cmd.Parameters.AddWithValue("userId", conversation.UserId);
cmd.Parameters.AddWithValue("createdAt", conversation.CreatedAt);
cmd.Parameters.AddWithValue("updatedAt", conversation.UpdatedAt);
cmd.Parameters.AddWithValue("context", JsonSerializer.Serialize(conversation.Context, JsonOptions));
cmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(conversation.Metadata, JsonOptions));
var entity = new ConversationEntity
{
ConversationId = conversation.ConversationId,
TenantId = conversation.TenantId,
UserId = conversation.UserId,
CreatedAt = conversation.CreatedAt.UtcDateTime,
UpdatedAt = conversation.UpdatedAt.UtcDateTime,
Context = JsonSerializer.Serialize(conversation.Context, JsonOptions),
Metadata = JsonSerializer.Serialize(conversation.Metadata, JsonOptions)
};
await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
dbContext.Conversations.Add(entity);
_logger.LogInformation(
try
{
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Idempotency: conversation already exists, treat as success.
Logger.LogDebug(
"Conversation {ConversationId} already exists (idempotent create)",
conversation.ConversationId);
}
Logger.LogInformation(
"Created conversation {ConversationId} for user {UserId}",
conversation.ConversationId, conversation.UserId);
@@ -81,25 +90,32 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
string conversationId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT * FROM advisoryai.conversations
WHERE conversation_id = @conversationId
""";
await using var connection = await DataSource.OpenConnectionAsync(
string.Empty, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = AdvisoryAiDbContextFactory.Create(
connection, CommandTimeoutSeconds, GetSchemaName());
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("conversationId", conversationId);
var entity = await dbContext.Conversations
.AsNoTracking()
.FirstOrDefaultAsync(c => c.ConversationId == conversationId, cancellationToken)
.ConfigureAwait(false);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
if (entity is null)
{
return null;
}
var conversation = await MapConversationAsync(reader, cancellationToken).ConfigureAwait(false);
var conversation = MapConversation(entity);
// Load turns
var turns = await GetTurnsAsync(conversationId, cancellationToken).ConfigureAwait(false);
// Load turns ordered by timestamp ASC (preserves original ordering semantics)
var turnEntities = await dbContext.Turns
.AsNoTracking()
.Where(t => t.ConversationId == conversationId)
.OrderBy(t => t.Timestamp)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var turns = turnEntities.Select(MapTurn).ToImmutableArray();
return conversation with { Turns = turns };
}
@@ -111,26 +127,20 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
int limit = 20,
CancellationToken cancellationToken = default)
{
var sql = string.Create(CultureInfo.InvariantCulture, $"""
SELECT * FROM advisoryai.conversations
WHERE tenant_id = @tenantId AND user_id = @userId
ORDER BY updated_at DESC
LIMIT {limit}
""");
await using var connection = await DataSource.OpenConnectionAsync(
tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var dbContext = AdvisoryAiDbContextFactory.Create(
connection, CommandTimeoutSeconds, GetSchemaName());
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("tenantId", tenantId);
cmd.Parameters.AddWithValue("userId", userId);
var entities = await dbContext.Conversations
.AsNoTracking()
.Where(c => c.TenantId == tenantId && c.UserId == userId)
.OrderByDescending(c => c.UpdatedAt)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var conversations = new List<Conversation>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
conversations.Add(await MapConversationAsync(reader, cancellationToken).ConfigureAwait(false));
}
return conversations;
return entities.Select(MapConversation).ToList();
}
/// <inheritdoc />
@@ -139,49 +149,39 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
ConversationTurn turn,
CancellationToken cancellationToken = default)
{
const string insertSql = """
INSERT INTO advisoryai.turns (
turn_id, conversation_id, role, content, timestamp,
evidence_links, proposed_actions, metadata
) VALUES (
@turnId, @conversationId, @role, @content, @timestamp,
@evidenceLinks::jsonb, @proposedActions::jsonb, @metadata::jsonb
)
""";
const string updateSql = """
UPDATE advisoryai.conversations
SET updated_at = @updatedAt
WHERE conversation_id = @conversationId
""";
await using var transaction = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync(
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AdvisoryAiDbContextFactory.Create(
connection, CommandTimeoutSeconds, GetSchemaName());
// Insert turn
await using (var insertCmd = _dataSource.CreateCommand(insertSql))
var turnEntity = new TurnEntity
{
insertCmd.Parameters.AddWithValue("turnId", turn.TurnId);
insertCmd.Parameters.AddWithValue("conversationId", conversationId);
insertCmd.Parameters.AddWithValue("role", turn.Role.ToString());
insertCmd.Parameters.AddWithValue("content", turn.Content);
insertCmd.Parameters.AddWithValue("timestamp", turn.Timestamp);
insertCmd.Parameters.AddWithValue("evidenceLinks", JsonSerializer.Serialize(turn.EvidenceLinks, JsonOptions));
insertCmd.Parameters.AddWithValue("proposedActions", JsonSerializer.Serialize(turn.ProposedActions, JsonOptions));
insertCmd.Parameters.AddWithValue("metadata", JsonSerializer.Serialize(turn.Metadata, JsonOptions));
TurnId = turn.TurnId,
ConversationId = conversationId,
Role = turn.Role.ToString(),
Content = turn.Content,
Timestamp = turn.Timestamp.UtcDateTime,
EvidenceLinks = JsonSerializer.Serialize(turn.EvidenceLinks, JsonOptions),
ProposedActions = JsonSerializer.Serialize(turn.ProposedActions, JsonOptions),
Metadata = JsonSerializer.Serialize(turn.Metadata, JsonOptions)
};
await insertCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
dbContext.Turns.Add(turnEntity);
// Update conversation timestamp
await using (var updateCmd = _dataSource.CreateCommand(updateSql))
{
updateCmd.Parameters.AddWithValue("conversationId", conversationId);
updateCmd.Parameters.AddWithValue("updatedAt", turn.Timestamp);
var conversation = await dbContext.Conversations
.FirstOrDefaultAsync(c => c.ConversationId == conversationId, cancellationToken)
.ConfigureAwait(false);
await updateCmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (conversation is not null)
{
conversation.UpdatedAt = turn.Timestamp.UtcDateTime;
}
_logger.LogDebug(
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
Logger.LogDebug(
"Added turn {TurnId} to conversation {ConversationId}",
turn.TurnId, conversationId);
@@ -193,19 +193,19 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
string conversationId,
CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM advisoryai.conversations
WHERE conversation_id = @conversationId
""";
await using var connection = await DataSource.OpenConnectionAsync(
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AdvisoryAiDbContextFactory.Create(
connection, CommandTimeoutSeconds, GetSchemaName());
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("conversationId", conversationId);
var rowsAffected = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
var rowsAffected = await dbContext.Conversations
.Where(c => c.ConversationId == conversationId)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
if (rowsAffected > 0)
{
_logger.LogInformation("Deleted conversation {ConversationId}", conversationId);
Logger.LogInformation("Deleted conversation {ConversationId}", conversationId);
}
return rowsAffected > 0;
@@ -216,128 +216,129 @@ public sealed class ConversationStore : IConversationStore, IAsyncDisposable
TimeSpan maxAge,
CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM advisoryai.conversations
WHERE updated_at < @cutoff
""";
var cutoff = _timeProvider.GetUtcNow() - maxAge;
var cutoffUtc = cutoff.UtcDateTime;
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("cutoff", cutoff);
await using var connection = await DataSource.OpenConnectionAsync(
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = AdvisoryAiDbContextFactory.Create(
connection, CommandTimeoutSeconds, GetSchemaName());
var rowsDeleted = await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
var rowsDeleted = await dbContext.Conversations
.Where(c => c.UpdatedAt < cutoffUtc)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
if (rowsDeleted > 0)
{
_logger.LogInformation(
Logger.LogInformation(
"Cleaned up {Count} expired conversations older than {MaxAge}",
rowsDeleted, maxAge);
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
public ValueTask DisposeAsync()
{
// NpgsqlDataSource is typically managed by DI, so we don't dispose it here
await Task.CompletedTask;
// DataSource is managed by DI, so we don't dispose it here
return ValueTask.CompletedTask;
}
private async Task<ImmutableArray<ConversationTurn>> GetTurnsAsync(
string conversationId,
CancellationToken cancellationToken)
private string GetSchemaName()
{
const string sql = """
SELECT * FROM advisoryai.turns
WHERE conversation_id = @conversationId
ORDER BY timestamp ASC
""";
await using var cmd = _dataSource.CreateCommand(sql);
cmd.Parameters.AddWithValue("conversationId", conversationId);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
var turns = new List<ConversationTurn>();
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
{
turns.Add(MapTurn(reader));
return DataSource.SchemaName!;
}
return turns.ToImmutableArray();
return AdvisoryAiDataSource.DefaultSchemaName;
}
private async Task<Conversation> MapConversationAsync(
NpgsqlDataReader reader,
CancellationToken cancellationToken)
private static Conversation MapConversation(ConversationEntity entity)
{
_ = cancellationToken; // Suppress unused parameter warning
var contextJson = reader.IsDBNull(reader.GetOrdinal("context"))
? null : reader.GetString(reader.GetOrdinal("context"));
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
? null : reader.GetString(reader.GetOrdinal("metadata"));
var context = contextJson != null
? JsonSerializer.Deserialize<ConversationContext>(contextJson, JsonOptions) ?? new ConversationContext()
var context = entity.Context != null
? JsonSerializer.Deserialize<ConversationContext>(entity.Context, JsonOptions) ?? new ConversationContext()
: new ConversationContext();
var metadata = metadataJson != null
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(metadataJson, JsonOptions)
var metadata = entity.Metadata != null
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(entity.Metadata, JsonOptions)
?? ImmutableDictionary<string, string>.Empty
: ImmutableDictionary<string, string>.Empty;
return new Conversation
{
ConversationId = reader.GetString(reader.GetOrdinal("conversation_id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
UserId = reader.GetString(reader.GetOrdinal("user_id")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
ConversationId = entity.ConversationId,
TenantId = entity.TenantId,
UserId = entity.UserId,
CreatedAt = ToUtcOffset(entity.CreatedAt),
UpdatedAt = ToUtcOffset(entity.UpdatedAt),
Context = context,
Metadata = metadata,
Turns = ImmutableArray<ConversationTurn>.Empty
};
}
private static ConversationTurn MapTurn(NpgsqlDataReader reader)
private static ConversationTurn MapTurn(TurnEntity entity)
{
var evidenceLinksJson = reader.IsDBNull(reader.GetOrdinal("evidence_links"))
? null : reader.GetString(reader.GetOrdinal("evidence_links"));
var proposedActionsJson = reader.IsDBNull(reader.GetOrdinal("proposed_actions"))
? null : reader.GetString(reader.GetOrdinal("proposed_actions"));
var metadataJson = reader.IsDBNull(reader.GetOrdinal("metadata"))
? null : reader.GetString(reader.GetOrdinal("metadata"));
var evidenceLinks = evidenceLinksJson != null
? JsonSerializer.Deserialize<ImmutableArray<EvidenceLink>>(evidenceLinksJson, JsonOptions)
var evidenceLinks = entity.EvidenceLinks != null
? JsonSerializer.Deserialize<ImmutableArray<EvidenceLink>>(entity.EvidenceLinks, JsonOptions)
: ImmutableArray<EvidenceLink>.Empty;
var proposedActions = proposedActionsJson != null
? JsonSerializer.Deserialize<ImmutableArray<ProposedAction>>(proposedActionsJson, JsonOptions)
var proposedActions = entity.ProposedActions != null
? JsonSerializer.Deserialize<ImmutableArray<ProposedAction>>(entity.ProposedActions, JsonOptions)
: ImmutableArray<ProposedAction>.Empty;
var metadata = metadataJson != null
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(metadataJson, JsonOptions)
var metadata = entity.Metadata != null
? JsonSerializer.Deserialize<ImmutableDictionary<string, string>>(entity.Metadata, JsonOptions)
?? ImmutableDictionary<string, string>.Empty
: ImmutableDictionary<string, string>.Empty;
var roleStr = reader.GetString(reader.GetOrdinal("role"));
var role = Enum.TryParse<TurnRole>(roleStr, ignoreCase: true, out var parsedRole)
var role = Enum.TryParse<TurnRole>(entity.Role, ignoreCase: true, out var parsedRole)
? parsedRole
: TurnRole.User;
return new ConversationTurn
{
TurnId = reader.GetString(reader.GetOrdinal("turn_id")),
TurnId = entity.TurnId,
Role = role,
Content = reader.GetString(reader.GetOrdinal("content")),
Timestamp = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("timestamp")),
Content = entity.Content,
Timestamp = ToUtcOffset(entity.Timestamp),
EvidenceLinks = evidenceLinks,
ProposedActions = proposedActions,
Metadata = metadata
};
}
private static DateTimeOffset ToUtcOffset(DateTime value)
{
if (value.Kind == DateTimeKind.Utc)
{
return new DateTimeOffset(value, TimeSpan.Zero);
}
if (value.Kind == DateTimeKind.Local)
{
return new DateTimeOffset(value.ToUniversalTime(), TimeSpan.Zero);
}
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero);
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
return true;
}
current = current.InnerException;
}
return false;
}
}
/// <summary>

View File

@@ -0,0 +1,6 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels;
using StellaOps.AdvisoryAI.Storage.EfCore.Context;
[assembly: DbContext(typeof(AdvisoryAiDbContext), optimizedModel: typeof(AdvisoryAiDbContextModel))]

View File

@@ -0,0 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.AdvisoryAI.Storage.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
{
[DbContext(typeof(AdvisoryAiDbContext))]
public partial class AdvisoryAiDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static AdvisoryAiDbContextModel()
{
var model = new AdvisoryAiDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (AdvisoryAiDbContextModel)model.FinalizeModel();
}
private static AdvisoryAiDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,34 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
{
public partial class AdvisoryAiDbContextModel
{
private AdvisoryAiDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("a2b3c4d5-e6f7-4890-ab12-cd34ef567890"), entityTypeCount: 2)
{
}
partial void Initialize()
{
var conversationEntity = ConversationEntityEntityType.Create(this);
var turnEntity = TurnEntityEntityType.Create(this);
ConversationEntityEntityType.CreateAnnotations(conversationEntity);
TurnEntityEntityType.CreateAnnotations(turnEntity);
TurnEntityEntityType.CreateForeignKeys(turnEntity, conversationEntity);
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -0,0 +1,121 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class ConversationEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.AdvisoryAI.Storage.EfCore.Models.ConversationEntity",
typeof(ConversationEntity),
baseEntityType,
propertyCount: 7,
namedIndexCount: 2,
keyCount: 1);
var conversationId = runtimeEntityType.AddProperty(
"ConversationId",
typeof(string),
propertyInfo: typeof(ConversationEntity).GetProperty("ConversationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ConversationEntity).GetField("<ConversationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
conversationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
conversationId.AddAnnotation("Relational:ColumnName", "conversation_id");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(ConversationEntity).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ConversationEntity).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var userId = runtimeEntityType.AddProperty(
"UserId",
typeof(string),
propertyInfo: typeof(ConversationEntity).GetProperty("UserId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ConversationEntity).GetField("<UserId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
userId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
userId.AddAnnotation("Relational:ColumnName", "user_id");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTime),
propertyInfo: typeof(ConversationEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ConversationEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTime),
propertyInfo: typeof(ConversationEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ConversationEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
var context = runtimeEntityType.AddProperty(
"Context",
typeof(string),
propertyInfo: typeof(ConversationEntity).GetProperty("Context", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ConversationEntity).GetField("<Context>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
context.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
context.AddAnnotation("Relational:ColumnName", "context");
context.AddAnnotation("Relational:ColumnType", "jsonb");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(ConversationEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ConversationEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
var key = runtimeEntityType.AddKey(
new[] { conversationId });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "conversations_pkey");
var idx_advisoryai_conversations_tenant_updated = runtimeEntityType.AddIndex(
new[] { tenantId, updatedAt },
name: "idx_advisoryai_conversations_tenant_updated");
idx_advisoryai_conversations_tenant_updated.AddAnnotation("Relational:IsDescending", new[] { false, true });
var idx_advisoryai_conversations_tenant_user = runtimeEntityType.AddIndex(
new[] { tenantId, userId },
name: "idx_advisoryai_conversations_tenant_user");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "advisoryai");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "conversations");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,158 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class TurnEntityEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.AdvisoryAI.Storage.EfCore.Models.TurnEntity",
typeof(TurnEntity),
baseEntityType,
propertyCount: 8,
namedIndexCount: 2,
foreignKeyCount: 1,
keyCount: 1);
var turnId = runtimeEntityType.AddProperty(
"TurnId",
typeof(string),
propertyInfo: typeof(TurnEntity).GetProperty("TurnId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TurnEntity).GetField("<TurnId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
turnId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
turnId.AddAnnotation("Relational:ColumnName", "turn_id");
var conversationId = runtimeEntityType.AddProperty(
"ConversationId",
typeof(string),
propertyInfo: typeof(TurnEntity).GetProperty("ConversationId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TurnEntity).GetField("<ConversationId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
conversationId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
conversationId.AddAnnotation("Relational:ColumnName", "conversation_id");
var role = runtimeEntityType.AddProperty(
"Role",
typeof(string),
propertyInfo: typeof(TurnEntity).GetProperty("Role", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TurnEntity).GetField("<Role>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
role.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
role.AddAnnotation("Relational:ColumnName", "role");
var content = runtimeEntityType.AddProperty(
"Content",
typeof(string),
propertyInfo: typeof(TurnEntity).GetProperty("Content", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TurnEntity).GetField("<Content>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
content.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
content.AddAnnotation("Relational:ColumnName", "content");
var timestamp = runtimeEntityType.AddProperty(
"Timestamp",
typeof(DateTime),
propertyInfo: typeof(TurnEntity).GetProperty("Timestamp", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TurnEntity).GetField("<Timestamp>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
timestamp.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
timestamp.AddAnnotation("Relational:ColumnName", "timestamp");
var evidenceLinks = runtimeEntityType.AddProperty(
"EvidenceLinks",
typeof(string),
propertyInfo: typeof(TurnEntity).GetProperty("EvidenceLinks", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TurnEntity).GetField("<EvidenceLinks>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
evidenceLinks.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
evidenceLinks.AddAnnotation("Relational:ColumnName", "evidence_links");
evidenceLinks.AddAnnotation("Relational:ColumnType", "jsonb");
var proposedActions = runtimeEntityType.AddProperty(
"ProposedActions",
typeof(string),
propertyInfo: typeof(TurnEntity).GetProperty("ProposedActions", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TurnEntity).GetField("<ProposedActions>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
proposedActions.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
proposedActions.AddAnnotation("Relational:ColumnName", "proposed_actions");
proposedActions.AddAnnotation("Relational:ColumnType", "jsonb");
var metadata = runtimeEntityType.AddProperty(
"Metadata",
typeof(string),
propertyInfo: typeof(TurnEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TurnEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
metadata.AddAnnotation("Relational:ColumnName", "metadata");
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
var key = runtimeEntityType.AddKey(
new[] { turnId });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "turns_pkey");
var idx_advisoryai_turns_conversation = runtimeEntityType.AddIndex(
new[] { conversationId },
name: "idx_advisoryai_turns_conversation");
var idx_advisoryai_turns_conversation_timestamp = runtimeEntityType.AddIndex(
new[] { conversationId, timestamp },
name: "idx_advisoryai_turns_conversation_timestamp");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "advisoryai");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "turns");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
public static void CreateForeignKeys(RuntimeEntityType runtimeEntityType, RuntimeEntityType conversationEntityEntityType)
{
var conversationId = runtimeEntityType.FindProperty("ConversationId");
var fk = runtimeEntityType.AddForeignKey(
new[] { conversationId },
conversationEntityEntityType.FindKey(new[] { conversationEntityEntityType.FindProperty("ConversationId") }),
conversationEntityEntityType,
deleteBehavior: Microsoft.EntityFrameworkCore.DeleteBehavior.Cascade,
required: true);
fk.AddAnnotation("Relational:Name", "fk_turns_conversation_id");
var navigation = runtimeEntityType.AddNavigation(
"Conversation",
fk,
onDependent: true,
typeof(ConversationEntity),
propertyInfo: typeof(TurnEntity).GetProperty("Conversation", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(TurnEntity).GetField("<Conversation>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
var inverseNavigation = conversationEntityEntityType.AddNavigation(
"Turns",
fk,
onDependent: false,
typeof(System.Collections.Generic.ICollection<TurnEntity>),
propertyInfo: typeof(ConversationEntity).GetProperty("Turns", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(ConversationEntity).GetField("<Turns>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,22 @@
// <copyright file="AdvisoryAiDbContext.Partial.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.EntityFrameworkCore;
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
namespace StellaOps.AdvisoryAI.Storage.EfCore.Context;
public partial class AdvisoryAiDbContext
{
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TurnEntity>(entity =>
{
entity.HasOne(e => e.Conversation)
.WithMany(c => c.Turns)
.HasForeignKey(e => e.ConversationId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@@ -0,0 +1,84 @@
// <copyright file="AdvisoryAiDbContext.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.EntityFrameworkCore;
using StellaOps.AdvisoryAI.Storage.EfCore.Models;
namespace StellaOps.AdvisoryAI.Storage.EfCore.Context;
public partial class AdvisoryAiDbContext : DbContext
{
private readonly string _schemaName;
public AdvisoryAiDbContext(DbContextOptions<AdvisoryAiDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "advisoryai"
: schemaName.Trim();
}
public virtual DbSet<ConversationEntity> Conversations { get; set; }
public virtual DbSet<TurnEntity> Turns { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var schemaName = _schemaName;
modelBuilder.Entity<ConversationEntity>(entity =>
{
entity.HasKey(e => e.ConversationId).HasName("conversations_pkey");
entity.ToTable("conversations", schemaName);
entity.HasIndex(e => new { e.TenantId, e.UpdatedAt }, "idx_advisoryai_conversations_tenant_updated")
.IsDescending(false, true);
entity.HasIndex(e => new { e.TenantId, e.UserId }, "idx_advisoryai_conversations_tenant_user");
entity.Property(e => e.ConversationId).HasColumnName("conversation_id");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.UserId).HasColumnName("user_id");
entity.Property(e => e.CreatedAt).HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.Property(e => e.Context)
.HasColumnType("jsonb")
.HasColumnName("context");
entity.Property(e => e.Metadata)
.HasColumnType("jsonb")
.HasColumnName("metadata");
});
modelBuilder.Entity<TurnEntity>(entity =>
{
entity.HasKey(e => e.TurnId).HasName("turns_pkey");
entity.ToTable("turns", schemaName);
entity.HasIndex(e => e.ConversationId, "idx_advisoryai_turns_conversation");
entity.HasIndex(e => new { e.ConversationId, e.Timestamp }, "idx_advisoryai_turns_conversation_timestamp");
entity.Property(e => e.TurnId).HasColumnName("turn_id");
entity.Property(e => e.ConversationId).HasColumnName("conversation_id");
entity.Property(e => e.Role).HasColumnName("role");
entity.Property(e => e.Content).HasColumnName("content");
entity.Property(e => e.Timestamp).HasColumnName("timestamp");
entity.Property(e => e.EvidenceLinks)
.HasColumnType("jsonb")
.HasColumnName("evidence_links");
entity.Property(e => e.ProposedActions)
.HasColumnType("jsonb")
.HasColumnName("proposed_actions");
entity.Property(e => e.Metadata)
.HasColumnType("jsonb")
.HasColumnName("metadata");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,32 @@
// <copyright file="AdvisoryAiDesignTimeDbContextFactory.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.AdvisoryAI.Storage.EfCore.Context;
public sealed class AdvisoryAiDesignTimeDbContextFactory : IDesignTimeDbContextFactory<AdvisoryAiDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=advisoryai,public";
private const string ConnectionStringEnvironmentVariable =
"STELLAOPS_ADVISORYAI_EF_CONNECTION";
public AdvisoryAiDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<AdvisoryAiDbContext>()
.UseNpgsql(connectionString)
.Options;
return new AdvisoryAiDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,16 @@
// <copyright file="ConversationEntity.Partials.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
/// <summary>
/// Navigation properties for ConversationEntity.
/// </summary>
public partial class ConversationEntity
{
/// <summary>
/// Turns belonging to this conversation.
/// </summary>
public virtual ICollection<TurnEntity> Turns { get; set; } = new List<TurnEntity>();
}

View File

@@ -0,0 +1,25 @@
// <copyright file="ConversationEntity.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for advisoryai.conversations table.
/// </summary>
public partial class ConversationEntity
{
public string ConversationId { get; set; } = null!;
public string TenantId { get; set; } = null!;
public string UserId { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public string? Context { get; set; }
public string? Metadata { get; set; }
}

View File

@@ -0,0 +1,16 @@
// <copyright file="TurnEntity.Partials.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
/// <summary>
/// Navigation properties for TurnEntity.
/// </summary>
public partial class TurnEntity
{
/// <summary>
/// Parent conversation.
/// </summary>
public virtual ConversationEntity? Conversation { get; set; }
}

View File

@@ -0,0 +1,27 @@
// <copyright file="TurnEntity.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
namespace StellaOps.AdvisoryAI.Storage.EfCore.Models;
/// <summary>
/// EF Core entity for advisoryai.turns table.
/// </summary>
public partial class TurnEntity
{
public string TurnId { get; set; } = null!;
public string ConversationId { get; set; } = null!;
public string Role { get; set; } = null!;
public string Content { get; set; } = null!;
public DateTime Timestamp { get; set; }
public string? EvidenceLinks { get; set; }
public string? ProposedActions { get; set; }
public string? Metadata { get; set; }
}

View File

@@ -0,0 +1,48 @@
// <copyright file="AdvisoryAiDataSource.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.AdvisoryAI.Storage.Postgres;
/// <summary>
/// PostgreSQL data source for AdvisoryAI module.
/// </summary>
public sealed class AdvisoryAiDataSource : DataSourceBase
{
/// <summary>
/// Default schema name for AdvisoryAI tables.
/// </summary>
public const string DefaultSchemaName = "advisoryai";
/// <summary>
/// Creates a new AdvisoryAI data source.
/// </summary>
public AdvisoryAiDataSource(IOptions<PostgresOptions> options, ILogger<AdvisoryAiDataSource> logger)
: base(CreateOptions(options.Value), logger)
{
}
/// <inheritdoc />
protected override string ModuleName => "AdvisoryAI";
/// <inheritdoc />
protected override void ConfigureDataSourceBuilder(NpgsqlDataSourceBuilder builder)
{
base.ConfigureDataSourceBuilder(builder);
}
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
}
}

View File

@@ -0,0 +1,31 @@
// <copyright file="AdvisoryAiDbContextFactory.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.AdvisoryAI.Storage.EfCore.CompiledModels;
using StellaOps.AdvisoryAI.Storage.EfCore.Context;
namespace StellaOps.AdvisoryAI.Storage.Postgres;
internal static class AdvisoryAiDbContextFactory
{
public static AdvisoryAiDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? AdvisoryAiDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<AdvisoryAiDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, AdvisoryAiDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model when schema mapping matches the default model.
optionsBuilder.UseModel(AdvisoryAiDbContextModel.Instance);
}
return new AdvisoryAiDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -5,6 +5,7 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260222_051-AKS-INGEST | DONE | Added deterministic AKS ingestion controls: markdown allow-list manifest loading, OpenAPI aggregate source path support, and doctor control projection integration for search chunks, including fallback doctor metadata hydration from controls projection fields. |
| AUDIT-0017-M | DONE | Maintainability audit for StellaOps.AdvisoryAI. |
| AUDIT-0017-T | DONE | Test coverage audit for StellaOps.AdvisoryAI. |
| AUDIT-0017-A | DONE | Pending approval for changes. |
@@ -18,4 +19,5 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
| QA-AIAI-VERIFY-003 | DONE | FLOW verification complete for `ai-action-policy-gate` with Tier 0/1/2 artifacts under `docs/qa/feature-checks/runs/advisoryai/ai-action-policy-gate/run-001/`. |
| QA-AIAI-VERIFY-004 | DONE | FLOW verification complete for `ai-codex-zastava-companion` with Tier 0/1/2 artifacts under `docs/qa/feature-checks/runs/advisoryai/ai-codex-zastava-companion/run-002/`. |
| QA-AIAI-VERIFY-005 | DONE | FLOW verification complete for `deterministic-ai-artifact-replay` with Tier 0/1/2 artifacts under `docs/qa/feature-checks/runs/advisoryai/deterministic-ai-artifact-replay/run-001/`. |
| SPRINT_20260222_074-ADVAI-EF | DONE | AdvisoryAI Storage DAL converted from Npgsql repositories to EF Core v10. Created AdvisoryAiDataSource, AdvisoryAiDbContext, ConversationEntity/TurnEntity models, compiled model artifacts, runtime DbContextFactory with UseModel for default schema. ConversationStore rewritten to use EF Core (per-operation DbContext, AsNoTracking reads, ExecuteDeleteAsync bulk deletes, UniqueViolation idempotency). AdvisoryAiMigrationModulePlugin registered in Platform migration registry. Sequential builds pass (0 errors, 0 warnings). 560/584 tests pass; 24 failures are pre-existing auth-related (403 Forbidden), not storage-related. |

View File

@@ -4,6 +4,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260222_051-AKS-TESTS | DONE | Revalidated AKS tests with xUnit v3 `--filter-class`: `KnowledgeSearchEndpointsIntegrationTests` (3/3) and `*KnowledgeSearch*` suite slice (6/6) on 2026-02-22. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using StellaOps.AirGap.Controller.Endpoints.Contracts;
using StellaOps.AirGap.Controller.Security;
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
@@ -11,30 +11,30 @@ namespace StellaOps.AirGap.Controller.Endpoints;
internal static class AirGapEndpoints
{
private const string StatusScope = "airgap:status:read";
private const string SealScope = "airgap:seal";
private const string VerifyScope = "airgap:verify";
public static RouteGroupBuilder MapAirGapEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/system/airgap")
.RequireAuthorization();
.RequireAuthorization(AirGapPolicies.StatusRead);
group.MapGet("/status", HandleStatus)
.RequireScope(StatusScope)
.WithName("AirGapStatus");
.RequireAuthorization(AirGapPolicies.StatusRead)
.WithName("AirGapStatus")
.WithDescription("Returns the current air-gap seal status for the tenant including seal state, staleness evaluation, and content budget freshness. Requires airgap:status:read scope.");
group.MapPost("/seal", HandleSeal)
.RequireScope(SealScope)
.WithName("AirGapSeal");
.RequireAuthorization(AirGapPolicies.Seal)
.WithName("AirGapSeal")
.WithDescription("Seals the air-gap environment for the tenant by recording a policy hash, time anchor, and staleness budget. Returns the updated seal status including staleness evaluation. Requires airgap:seal scope.");
group.MapPost("/unseal", HandleUnseal)
.RequireScope(SealScope)
.WithName("AirGapUnseal");
.RequireAuthorization(AirGapPolicies.Seal)
.WithName("AirGapUnseal")
.WithDescription("Unseals the air-gap environment for the tenant, allowing normal connectivity. Returns the updated unsealed status. Requires airgap:seal scope.");
group.MapPost("/verify", HandleVerify)
.RequireScope(VerifyScope)
.WithName("AirGapVerify");
.RequireAuthorization(AirGapPolicies.Verify)
.WithName("AirGapVerify")
.WithDescription("Verifies the current air-gap state against a provided policy hash and deterministic replay evidence. Returns a verification result indicating whether the seal state matches the expected evidence. Requires airgap:verify scope.");
return group;
}
@@ -235,34 +235,3 @@ internal static class AirGapEndpoints
}
}
internal static class AuthorizationExtensions
{
public static RouteHandlerBuilder RequireScope(this RouteHandlerBuilder builder, string requiredScope)
{
return builder.RequireAuthorization(policy =>
{
policy.RequireAssertion(ctx =>
{
if (ctx.User.HasClaim(c => c.Type == StellaOpsClaimTypes.ScopeItem))
{
return ctx.User.FindAll(StellaOpsClaimTypes.ScopeItem)
.Select(c => c.Value)
.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
}
var scopes = ctx.User.FindAll(StellaOpsClaimTypes.Scope)
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToArray();
if (scopes.Length == 0)
{
scopes = ctx.User.FindAll("scp")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToArray();
}
return scopes.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
});
});
}
}

View File

@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Authentication;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.AirGap.Controller.Auth;
using StellaOps.AirGap.Controller.DependencyInjection;
using StellaOps.AirGap.Controller.Endpoints;
using StellaOps.AirGap.Controller.Security;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
@@ -12,7 +14,17 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(HeaderScopeAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, HeaderScopeAuthenticationHandler>(HeaderScopeAuthenticationHandler.SchemeName, _ => { });
builder.Services.AddAuthorization();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(AirGapPolicies.StatusRead, policy =>
policy.RequireAssertion(ctx => AirGapScopeAssertion.HasScope(ctx, StellaOpsScopes.AirgapStatusRead)));
options.AddPolicy(AirGapPolicies.Seal, policy =>
policy.RequireAssertion(ctx => AirGapScopeAssertion.HasScope(ctx, StellaOpsScopes.AirgapSeal)));
options.AddPolicy(AirGapPolicies.Import, policy =>
policy.RequireAssertion(ctx => AirGapScopeAssertion.HasScope(ctx, StellaOpsScopes.AirgapImport)));
options.AddPolicy(AirGapPolicies.Verify, policy =>
policy.RequireAssertion(ctx => AirGapScopeAssertion.HasScope(ctx, "airgap:verify")));
});
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.AddAirGapController(builder.Configuration);

View File

@@ -0,0 +1,64 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
using Microsoft.AspNetCore.Authorization;
using StellaOps.Auth.Abstractions;
namespace StellaOps.AirGap.Controller.Security;
/// <summary>
/// Named authorization policy constants for the AirGap Controller service.
/// Policies are registered via assertion-based policies in Program.cs using
/// <see cref="AirGapScopeAssertion"/> to evaluate claims from the HeaderScope
/// authentication handler.
/// </summary>
internal static class AirGapPolicies
{
/// <summary>Policy for reading air-gap status and staleness information. Requires airgap:status:read scope.</summary>
public const string StatusRead = "AirGap.StatusRead";
/// <summary>Policy for sealing and unsealing the air-gap environment. Requires airgap:seal scope.</summary>
public const string Seal = "AirGap.Seal";
/// <summary>Policy for importing offline bundles while in air-gapped mode. Requires airgap:import scope.</summary>
public const string Import = "AirGap.Import";
/// <summary>Policy for verifying air-gap state against policy hash and replay evidence. Requires airgap:verify scope.</summary>
public const string Verify = "AirGap.Verify";
}
/// <summary>
/// Scope assertion helper for AirGap policies. Evaluates scope claims populated by
/// the HeaderScope authentication handler against a required scope string.
/// </summary>
internal static class AirGapScopeAssertion
{
/// <summary>
/// Returns <c>true</c> when the authenticated principal carries the required scope
/// in either <see cref="StellaOpsClaimTypes.ScopeItem"/> or space-delimited
/// <see cref="StellaOpsClaimTypes.Scope"/> / <c>scp</c> claims.
/// </summary>
public static bool HasScope(AuthorizationHandlerContext context, string requiredScope)
{
var user = context.User;
if (user.HasClaim(c => c.Type == StellaOpsClaimTypes.ScopeItem))
{
return user.FindAll(StellaOpsClaimTypes.ScopeItem)
.Select(c => c.Value)
.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
}
var scopes = user.FindAll(StellaOpsClaimTypes.Scope)
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToArray();
if (scopes.Length == 0)
{
scopes = user.FindAll("scp")
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToArray();
}
return scopes.Contains(requiredScope, StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,9 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using StellaOps.AirGap.Persistence.EfCore.CompiledModels;
using StellaOps.AirGap.Persistence.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
[assembly: DbContextModel(typeof(AirGapDbContext), typeof(AirGapDbContextModel))]

View File

@@ -0,0 +1,48 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.AirGap.Persistence.EfCore.Context;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.AirGap.Persistence.EfCore.CompiledModels
{
[DbContext(typeof(AirGapDbContext))]
public partial class AirGapDbContextModel : RuntimeModel
{
private static readonly bool _useOldBehavior31751 =
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
static AirGapDbContextModel()
{
var model = new AirGapDbContextModel();
if (_useOldBehavior31751)
{
model.Initialize();
}
else
{
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
thread.Start();
thread.Join();
void RunInitialization()
{
model.Initialize();
}
}
model.Customize();
_instance = (AirGapDbContextModel)model.FinalizeModel();
}
private static AirGapDbContextModel _instance;
public static IModel Instance => _instance;
partial void Initialize();
partial void Customize();
}
}

View File

@@ -0,0 +1,34 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.AirGap.Persistence.EfCore.CompiledModels
{
public partial class AirGapDbContextModel
{
private AirGapDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("fd07ee8a-66dd-4965-a96c-9898cb1ec690"), entityTypeCount: 3)
{
}
partial void Initialize()
{
var bundleVersion = BundleVersionEntityType.Create(this);
var bundleVersionHistory = BundleVersionHistoryEntityType.Create(this);
var state = StateEntityType.Create(this);
BundleVersionEntityType.CreateAnnotations(bundleVersion);
BundleVersionHistoryEntityType.CreateAnnotations(bundleVersionHistory);
StateEntityType.CreateAnnotations(state);
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
AddAnnotation("ProductVersion", "10.0.0");
AddAnnotation("Relational:MaxIdentifierLength", 63);
}
}
}

View File

@@ -0,0 +1,181 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.AirGap.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.AirGap.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class BundleVersionEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.AirGap.Persistence.EfCore.Models.BundleVersion",
typeof(BundleVersion),
baseEntityType,
propertyCount: 14,
namedIndexCount: 1,
keyCount: 1);
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(BundleVersion).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var bundleType = runtimeEntityType.AddProperty(
"BundleType",
typeof(string),
propertyInfo: typeof(BundleVersion).GetProperty("BundleType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<BundleType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
bundleType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
bundleType.AddAnnotation("Relational:ColumnName", "bundle_type");
var activatedAt = runtimeEntityType.AddProperty(
"ActivatedAt",
typeof(DateTime),
propertyInfo: typeof(BundleVersion).GetProperty("ActivatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<ActivatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
activatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
activatedAt.AddAnnotation("Relational:ColumnName", "activated_at");
var bundleCreatedAt = runtimeEntityType.AddProperty(
"BundleCreatedAt",
typeof(DateTime),
propertyInfo: typeof(BundleVersion).GetProperty("BundleCreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<BundleCreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
bundleCreatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
bundleCreatedAt.AddAnnotation("Relational:ColumnName", "bundle_created_at");
var bundleDigest = runtimeEntityType.AddProperty(
"BundleDigest",
typeof(string),
propertyInfo: typeof(BundleVersion).GetProperty("BundleDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<BundleDigest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
bundleDigest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
bundleDigest.AddAnnotation("Relational:ColumnName", "bundle_digest");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTime),
propertyInfo: typeof(BundleVersion).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var forceActivateReason = runtimeEntityType.AddProperty(
"ForceActivateReason",
typeof(string),
propertyInfo: typeof(BundleVersion).GetProperty("ForceActivateReason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<ForceActivateReason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
forceActivateReason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
forceActivateReason.AddAnnotation("Relational:ColumnName", "force_activate_reason");
var major = runtimeEntityType.AddProperty(
"Major",
typeof(int),
propertyInfo: typeof(BundleVersion).GetProperty("Major", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<Major>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0);
major.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
major.AddAnnotation("Relational:ColumnName", "major");
var minor = runtimeEntityType.AddProperty(
"Minor",
typeof(int),
propertyInfo: typeof(BundleVersion).GetProperty("Minor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<Minor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0);
minor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
minor.AddAnnotation("Relational:ColumnName", "minor");
var patch = runtimeEntityType.AddProperty(
"Patch",
typeof(int),
propertyInfo: typeof(BundleVersion).GetProperty("Patch", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<Patch>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0);
patch.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
patch.AddAnnotation("Relational:ColumnName", "patch");
var prerelease = runtimeEntityType.AddProperty(
"Prerelease",
typeof(string),
propertyInfo: typeof(BundleVersion).GetProperty("Prerelease", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<Prerelease>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
prerelease.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
prerelease.AddAnnotation("Relational:ColumnName", "prerelease");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTime),
propertyInfo: typeof(BundleVersion).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var versionString = runtimeEntityType.AddProperty(
"VersionString",
typeof(string),
propertyInfo: typeof(BundleVersion).GetProperty("VersionString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<VersionString>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
versionString.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
versionString.AddAnnotation("Relational:ColumnName", "version_string");
var wasForceActivated = runtimeEntityType.AddProperty(
"WasForceActivated",
typeof(bool),
propertyInfo: typeof(BundleVersion).GetProperty("WasForceActivated", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersion).GetField("<WasForceActivated>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: false);
wasForceActivated.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
wasForceActivated.AddAnnotation("Relational:ColumnName", "was_force_activated");
var key = runtimeEntityType.AddKey(
new[] { tenantId, bundleType });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "bundle_versions_pkey");
var idx_airgap_bundle_versions_tenant = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_airgap_bundle_versions_tenant");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "airgap");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "bundle_versions");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,189 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.AirGap.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.AirGap.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class BundleVersionHistoryEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.AirGap.Persistence.EfCore.Models.BundleVersionHistory",
typeof(BundleVersionHistory),
baseEntityType,
propertyCount: 15,
namedIndexCount: 1,
keyCount: 1);
var id = runtimeEntityType.AddProperty(
"Id",
typeof(long),
propertyInfo: typeof(BundleVersionHistory).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
afterSaveBehavior: PropertySaveBehavior.Throw,
sentinel: 0L);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
id.AddAnnotation("Relational:DefaultValueSql", "nextval('bundle_version_history_id_seq'::regclass)");
var activatedAt = runtimeEntityType.AddProperty(
"ActivatedAt",
typeof(DateTime),
propertyInfo: typeof(BundleVersionHistory).GetProperty("ActivatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<ActivatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
activatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
activatedAt.AddAnnotation("Relational:ColumnName", "activated_at");
var bundleCreatedAt = runtimeEntityType.AddProperty(
"BundleCreatedAt",
typeof(DateTime),
propertyInfo: typeof(BundleVersionHistory).GetProperty("BundleCreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<BundleCreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
bundleCreatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
bundleCreatedAt.AddAnnotation("Relational:ColumnName", "bundle_created_at");
var bundleDigest = runtimeEntityType.AddProperty(
"BundleDigest",
typeof(string),
propertyInfo: typeof(BundleVersionHistory).GetProperty("BundleDigest", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<BundleDigest>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
bundleDigest.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
bundleDigest.AddAnnotation("Relational:ColumnName", "bundle_digest");
var bundleType = runtimeEntityType.AddProperty(
"BundleType",
typeof(string),
propertyInfo: typeof(BundleVersionHistory).GetProperty("BundleType", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<BundleType>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
bundleType.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
bundleType.AddAnnotation("Relational:ColumnName", "bundle_type");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTime),
propertyInfo: typeof(BundleVersionHistory).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var deactivatedAt = runtimeEntityType.AddProperty(
"DeactivatedAt",
typeof(DateTime?),
propertyInfo: typeof(BundleVersionHistory).GetProperty("DeactivatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<DeactivatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
deactivatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
deactivatedAt.AddAnnotation("Relational:ColumnName", "deactivated_at");
var forceActivateReason = runtimeEntityType.AddProperty(
"ForceActivateReason",
typeof(string),
propertyInfo: typeof(BundleVersionHistory).GetProperty("ForceActivateReason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<ForceActivateReason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
forceActivateReason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
forceActivateReason.AddAnnotation("Relational:ColumnName", "force_activate_reason");
var major = runtimeEntityType.AddProperty(
"Major",
typeof(int),
propertyInfo: typeof(BundleVersionHistory).GetProperty("Major", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<Major>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0);
major.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
major.AddAnnotation("Relational:ColumnName", "major");
var minor = runtimeEntityType.AddProperty(
"Minor",
typeof(int),
propertyInfo: typeof(BundleVersionHistory).GetProperty("Minor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<Minor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0);
minor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
minor.AddAnnotation("Relational:ColumnName", "minor");
var patch = runtimeEntityType.AddProperty(
"Patch",
typeof(int),
propertyInfo: typeof(BundleVersionHistory).GetProperty("Patch", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<Patch>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0);
patch.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
patch.AddAnnotation("Relational:ColumnName", "patch");
var prerelease = runtimeEntityType.AddProperty(
"Prerelease",
typeof(string),
propertyInfo: typeof(BundleVersionHistory).GetProperty("Prerelease", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<Prerelease>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
prerelease.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
prerelease.AddAnnotation("Relational:ColumnName", "prerelease");
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(BundleVersionHistory).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var versionString = runtimeEntityType.AddProperty(
"VersionString",
typeof(string),
propertyInfo: typeof(BundleVersionHistory).GetProperty("VersionString", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<VersionString>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
versionString.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
versionString.AddAnnotation("Relational:ColumnName", "version_string");
var wasForceActivated = runtimeEntityType.AddProperty(
"WasForceActivated",
typeof(bool),
propertyInfo: typeof(BundleVersionHistory).GetProperty("WasForceActivated", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(BundleVersionHistory).GetField("<WasForceActivated>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: false);
wasForceActivated.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
wasForceActivated.AddAnnotation("Relational:ColumnName", "was_force_activated");
var key = runtimeEntityType.AddKey(
new[] { id });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "bundle_version_history_pkey");
var idx_airgap_bundle_version_history_tenant = runtimeEntityType.AddIndex(
new[] { tenantId, bundleType, activatedAt },
name: "idx_airgap_bundle_version_history_tenant");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "airgap");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "bundle_version_history");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -0,0 +1,169 @@
// <auto-generated />
using System;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using StellaOps.AirGap.Persistence.EfCore.Models;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.AirGap.Persistence.EfCore.CompiledModels
{
[EntityFrameworkInternal]
public partial class StateEntityType
{
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
{
var runtimeEntityType = model.AddEntityType(
"StellaOps.AirGap.Persistence.EfCore.Models.State",
typeof(State),
baseEntityType,
propertyCount: 11,
namedIndexCount: 2,
keyCount: 1);
var tenantId = runtimeEntityType.AddProperty(
"TenantId",
typeof(string),
propertyInfo: typeof(State).GetProperty("TenantId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<TenantId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
afterSaveBehavior: PropertySaveBehavior.Throw);
tenantId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
var contentBudgets = runtimeEntityType.AddProperty(
"ContentBudgets",
typeof(string),
propertyInfo: typeof(State).GetProperty("ContentBudgets", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<ContentBudgets>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
contentBudgets.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
contentBudgets.AddAnnotation("Relational:ColumnName", "content_budgets");
contentBudgets.AddAnnotation("Relational:ColumnType", "jsonb");
contentBudgets.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var createdAt = runtimeEntityType.AddProperty(
"CreatedAt",
typeof(DateTime),
propertyInfo: typeof(State).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var driftBaselineSeconds = runtimeEntityType.AddProperty(
"DriftBaselineSeconds",
typeof(long),
propertyInfo: typeof(State).GetProperty("DriftBaselineSeconds", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<DriftBaselineSeconds>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: 0L);
driftBaselineSeconds.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
driftBaselineSeconds.AddAnnotation("Relational:ColumnName", "drift_baseline_seconds");
var id = runtimeEntityType.AddProperty(
"Id",
typeof(string),
propertyInfo: typeof(State).GetProperty("Id", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<Id>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
id.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
id.AddAnnotation("Relational:ColumnName", "id");
var lastTransitionAt = runtimeEntityType.AddProperty(
"LastTransitionAt",
typeof(DateTime),
propertyInfo: typeof(State).GetProperty("LastTransitionAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<LastTransitionAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
lastTransitionAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
lastTransitionAt.AddAnnotation("Relational:ColumnName", "last_transition_at");
lastTransitionAt.AddAnnotation("Relational:DefaultValueSql", "'0001-01-01 00:00:00+00'::timestamp with time zone");
var policyHash = runtimeEntityType.AddProperty(
"PolicyHash",
typeof(string),
propertyInfo: typeof(State).GetProperty("PolicyHash", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<PolicyHash>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
nullable: true);
policyHash.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
policyHash.AddAnnotation("Relational:ColumnName", "policy_hash");
var @sealed = runtimeEntityType.AddProperty(
"Sealed",
typeof(bool),
propertyInfo: typeof(State).GetProperty("Sealed", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<Sealed>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
sentinel: false);
@sealed.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
@sealed.AddAnnotation("Relational:ColumnName", "sealed");
var stalenessBudget = runtimeEntityType.AddProperty(
"StalenessBudget",
typeof(string),
propertyInfo: typeof(State).GetProperty("StalenessBudget", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<StalenessBudget>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
stalenessBudget.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
stalenessBudget.AddAnnotation("Relational:ColumnName", "staleness_budget");
stalenessBudget.AddAnnotation("Relational:ColumnType", "jsonb");
stalenessBudget.AddAnnotation("Relational:DefaultValueSql", "'{\"breachSeconds\": 7200, \"warningSeconds\": 3600}'::jsonb");
var timeAnchor = runtimeEntityType.AddProperty(
"TimeAnchor",
typeof(string),
propertyInfo: typeof(State).GetProperty("TimeAnchor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<TimeAnchor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd);
timeAnchor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
timeAnchor.AddAnnotation("Relational:ColumnName", "time_anchor");
timeAnchor.AddAnnotation("Relational:ColumnType", "jsonb");
timeAnchor.AddAnnotation("Relational:DefaultValueSql", "'{}'::jsonb");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTime),
propertyInfo: typeof(State).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(State).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
valueGenerated: ValueGenerated.OnAdd,
sentinel: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
var key = runtimeEntityType.AddKey(
new[] { tenantId });
runtimeEntityType.SetPrimaryKey(key);
key.AddAnnotation("Relational:Name", "state_pkey");
var idx_airgap_state_sealed = runtimeEntityType.AddIndex(
new[] { @sealed },
name: "idx_airgap_state_sealed");
idx_airgap_state_sealed.AddAnnotation("Relational:Filter", "(sealed = true)");
var idx_airgap_state_tenant = runtimeEntityType.AddIndex(
new[] { tenantId },
name: "idx_airgap_state_tenant");
return runtimeEntityType;
}
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
{
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
runtimeEntityType.AddAnnotation("Relational:Schema", "airgap");
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
runtimeEntityType.AddAnnotation("Relational:TableName", "state");
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
Customize(runtimeEntityType);
}
static partial void Customize(RuntimeEntityType runtimeEntityType);
}
}

View File

@@ -1,35 +1,129 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.AirGap.Persistence.EfCore.Models;
namespace StellaOps.AirGap.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for AirGap module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// </summary>
public class AirGapDbContext : DbContext
public partial class AirGapDbContext : DbContext
{
private readonly string _schemaName;
public AirGapDbContext(DbContextOptions<AirGapDbContext> options)
: this(options, null)
{
}
public AirGapDbContext(DbContextOptions<AirGapDbContext> options, IOptions<PostgresOptions>? postgresOptions)
public AirGapDbContext(DbContextOptions<AirGapDbContext> options, string? schemaName = null)
: base(options)
{
var schema = postgresOptions?.Value.SchemaName;
_schemaName = string.IsNullOrWhiteSpace(schema)
? AirGapDataSource.DefaultSchemaName
: schema;
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "airgap"
: schemaName.Trim();
}
public virtual DbSet<BundleVersion> BundleVersions { get; set; }
public virtual DbSet<BundleVersionHistory> BundleVersionHistories { get; set; }
public virtual DbSet<State> States { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(_schemaName);
base.OnModelCreating(modelBuilder);
var schemaName = _schemaName;
modelBuilder.Entity<BundleVersion>(entity =>
{
entity.HasKey(e => new { e.TenantId, e.BundleType }).HasName("bundle_versions_pkey");
entity.ToTable("bundle_versions", schemaName);
entity.HasIndex(e => e.TenantId, "idx_airgap_bundle_versions_tenant");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.BundleType).HasColumnName("bundle_type");
entity.Property(e => e.ActivatedAt).HasColumnName("activated_at");
entity.Property(e => e.BundleCreatedAt).HasColumnName("bundle_created_at");
entity.Property(e => e.BundleDigest).HasColumnName("bundle_digest");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.ForceActivateReason).HasColumnName("force_activate_reason");
entity.Property(e => e.Major).HasColumnName("major");
entity.Property(e => e.Minor).HasColumnName("minor");
entity.Property(e => e.Patch).HasColumnName("patch");
entity.Property(e => e.Prerelease).HasColumnName("prerelease");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
entity.Property(e => e.VersionString).HasColumnName("version_string");
entity.Property(e => e.WasForceActivated).HasColumnName("was_force_activated");
});
modelBuilder.Entity<BundleVersionHistory>(entity =>
{
entity.HasKey(e => e.Id).HasName("bundle_version_history_pkey");
entity.ToTable("bundle_version_history", schemaName);
entity.HasIndex(e => new { e.TenantId, e.BundleType, e.ActivatedAt }, "idx_airgap_bundle_version_history_tenant").IsDescending(false, false, true);
entity.Property(e => e.Id)
.HasDefaultValueSql("nextval('bundle_version_history_id_seq'::regclass)")
.HasColumnName("id");
entity.Property(e => e.ActivatedAt).HasColumnName("activated_at");
entity.Property(e => e.BundleCreatedAt).HasColumnName("bundle_created_at");
entity.Property(e => e.BundleDigest).HasColumnName("bundle_digest");
entity.Property(e => e.BundleType).HasColumnName("bundle_type");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.DeactivatedAt).HasColumnName("deactivated_at");
entity.Property(e => e.ForceActivateReason).HasColumnName("force_activate_reason");
entity.Property(e => e.Major).HasColumnName("major");
entity.Property(e => e.Minor).HasColumnName("minor");
entity.Property(e => e.Patch).HasColumnName("patch");
entity.Property(e => e.Prerelease).HasColumnName("prerelease");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.VersionString).HasColumnName("version_string");
entity.Property(e => e.WasForceActivated).HasColumnName("was_force_activated");
});
modelBuilder.Entity<State>(entity =>
{
entity.HasKey(e => e.TenantId).HasName("state_pkey");
entity.ToTable("state", schemaName);
entity.HasIndex(e => e.Sealed, "idx_airgap_state_sealed").HasFilter("(sealed = true)");
entity.HasIndex(e => e.TenantId, "idx_airgap_state_tenant");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
entity.Property(e => e.ContentBudgets)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("content_budgets");
entity.Property(e => e.CreatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.DriftBaselineSeconds).HasColumnName("drift_baseline_seconds");
entity.Property(e => e.Id).HasColumnName("id");
entity.Property(e => e.LastTransitionAt)
.HasDefaultValueSql("'0001-01-01 00:00:00+00'::timestamp with time zone")
.HasColumnName("last_transition_at");
entity.Property(e => e.PolicyHash).HasColumnName("policy_hash");
entity.Property(e => e.Sealed).HasColumnName("sealed");
entity.Property(e => e.StalenessBudget)
.HasDefaultValueSql("'{\"breachSeconds\": 7200, \"warningSeconds\": 3600}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("staleness_budget");
entity.Property(e => e.TimeAnchor)
.HasDefaultValueSql("'{}'::jsonb")
.HasColumnType("jsonb")
.HasColumnName("time_anchor");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.AirGap.Persistence.EfCore.Context;
public sealed class AirGapDesignTimeDbContextFactory : IDesignTimeDbContextFactory<AirGapDbContext>
{
private const string DefaultConnectionString = "Host=localhost;Port=55434;Database=postgres;Username=postgres;Password=postgres;Search Path=airgap,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_AIRGAP_EF_CONNECTION";
public AirGapDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<AirGapDbContext>()
.UseNpgsql(connectionString)
.Options;
return new AirGapDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
namespace StellaOps.AirGap.Persistence.EfCore.Models;
public partial class BundleVersion
{
public string TenantId { get; set; } = null!;
public string BundleType { get; set; } = null!;
public string VersionString { get; set; } = null!;
public int Major { get; set; }
public int Minor { get; set; }
public int Patch { get; set; }
public string? Prerelease { get; set; }
public DateTime BundleCreatedAt { get; set; }
public string BundleDigest { get; set; } = null!;
public DateTime ActivatedAt { get; set; }
public bool WasForceActivated { get; set; }
public string? ForceActivateReason { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
namespace StellaOps.AirGap.Persistence.EfCore.Models;
public partial class BundleVersionHistory
{
public long Id { get; set; }
public string TenantId { get; set; } = null!;
public string BundleType { get; set; } = null!;
public string VersionString { get; set; } = null!;
public int Major { get; set; }
public int Minor { get; set; }
public int Patch { get; set; }
public string? Prerelease { get; set; }
public DateTime BundleCreatedAt { get; set; }
public string BundleDigest { get; set; } = null!;
public DateTime ActivatedAt { get; set; }
public DateTime? DeactivatedAt { get; set; }
public bool WasForceActivated { get; set; }
public string? ForceActivateReason { get; set; }
public DateTime CreatedAt { get; set; }
}

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
namespace StellaOps.AirGap.Persistence.EfCore.Models;
public partial class State
{
public string Id { get; set; } = null!;
public string TenantId { get; set; } = null!;
public bool Sealed { get; set; }
public string? PolicyHash { get; set; }
public string TimeAnchor { get; set; } = null!;
public DateTime LastTransitionAt { get; set; }
public string StalenessBudget { get; set; } = null!;
public long DriftBaselineSeconds { get; set; }
public string ContentBudgets { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,28 @@
using System;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.AirGap.Persistence.EfCore.CompiledModels;
using StellaOps.AirGap.Persistence.EfCore.Context;
namespace StellaOps.AirGap.Persistence.Postgres;
internal static class AirGapDbContextFactory
{
public static AirGapDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? AirGapDataSource.DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<AirGapDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, AirGapDataSource.DefaultSchemaName, StringComparison.Ordinal))
{
// Use the static compiled model module when schema mapping matches the default model.
optionsBuilder.UseModel(AirGapDbContextModel.Instance);
}
return new AirGapDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -1,37 +1,42 @@
using Npgsql;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Persistence.EfCore.Models;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
public sealed partial class PostgresAirGapStateStore
{
private AirGapState Map(NpgsqlDataReader reader)
private AirGapState Map(State row)
{
var id = reader.GetString(0);
var tenantId = reader.GetString(1);
var sealed_ = reader.GetBoolean(2);
var policyHash = reader.IsDBNull(3) ? null : reader.GetString(3);
var timeAnchorJson = reader.GetFieldValue<string>(4);
var lastTransitionAt = reader.GetFieldValue<DateTimeOffset>(5);
var stalenessBudgetJson = reader.GetFieldValue<string>(6);
var driftBaselineSeconds = reader.GetInt64(7);
var contentBudgetsJson = reader.IsDBNull(8) ? null : reader.GetFieldValue<string>(8);
var timeAnchor = DeserializeTimeAnchor(timeAnchorJson);
var stalenessBudget = DeserializeStalenessBudget(stalenessBudgetJson);
var contentBudgets = DeserializeContentBudgets(contentBudgetsJson);
var timeAnchor = DeserializeTimeAnchor(row.TimeAnchor);
var stalenessBudget = DeserializeStalenessBudget(row.StalenessBudget);
var contentBudgets = DeserializeContentBudgets(row.ContentBudgets);
return new AirGapState
{
Id = id,
TenantId = tenantId,
Sealed = sealed_,
PolicyHash = policyHash,
Id = row.Id,
TenantId = row.TenantId,
Sealed = row.Sealed,
PolicyHash = row.PolicyHash,
TimeAnchor = timeAnchor,
LastTransitionAt = lastTransitionAt,
LastTransitionAt = ToUtcOffset(row.LastTransitionAt),
StalenessBudget = stalenessBudget,
DriftBaselineSeconds = driftBaselineSeconds,
DriftBaselineSeconds = row.DriftBaselineSeconds,
ContentBudgets = contentBudgets
};
}
private static DateTimeOffset ToUtcOffset(DateTime value)
{
if (value.Kind == DateTimeKind.Utc)
{
return new DateTimeOffset(value, TimeSpan.Zero);
}
if (value.Kind == DateTimeKind.Local)
{
return new DateTimeOffset(value.ToUniversalTime(), TimeSpan.Zero);
}
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero);
}
}

View File

@@ -1,6 +1,4 @@
using System;
using System.Threading;
using Npgsql;
using Microsoft.EntityFrameworkCore;
using StellaOps.AirGap.Controller.Domain;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
@@ -13,44 +11,33 @@ public sealed partial class PostgresAirGapStateStore
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
var tenantKey = NormalizeTenantId(tenantId);
var stateTable = GetQualifiedTableName("state");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", cancellationToken)
.ConfigureAwait(false);
var sql = $$"""
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
FROM {{stateTable}}
WHERE tenant_id = @tenant_id;
""";
await using var dbContext = AirGapDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantKey);
var current = await dbContext.States
.AsNoTracking()
.FirstOrDefaultAsync(s => s.TenantId == tenantKey, cancellationToken)
.ConfigureAwait(false);
await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
if (current is not null)
{
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return Map(reader);
}
return Map(current);
}
// Fallback for legacy rows stored without normalization.
await using var fallbackCommand = CreateCommand($$"""
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
FROM {{stateTable}}
WHERE LOWER(tenant_id) = LOWER(@tenant_id)
ORDER BY updated_at DESC, id DESC
LIMIT 1;
""", connection);
AddParameter(fallbackCommand, "tenant_id", tenantId);
await using var fallbackReader = await fallbackCommand.ExecuteReaderAsync(cancellationToken)
var lowerTenant = tenantId.Trim().ToLowerInvariant();
var fallback = await dbContext.States
.AsNoTracking()
.Where(s => s.TenantId.ToLower() == lowerTenant)
.OrderByDescending(s => s.UpdatedAt)
.ThenByDescending(s => s.Id)
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (await fallbackReader.ReadAsync(cancellationToken).ConfigureAwait(false))
if (fallback is not null)
{
return Map(fallbackReader);
return Map(fallback);
}
return new AirGapState { TenantId = tenantId };

View File

@@ -1,6 +1,7 @@
using System;
using System.Threading;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Persistence.EfCore.Models;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
@@ -12,42 +13,89 @@ public sealed partial class PostgresAirGapStateStore
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
var tenantKey = NormalizeTenantId(state.TenantId);
var stateTable = GetQualifiedTableName("state");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", cancellationToken)
.ConfigureAwait(false);
var sql = $$"""
INSERT INTO {{stateTable}} (
id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
)
VALUES (
@id, @tenant_id, @sealed, @policy_hash, @time_anchor, @last_transition_at,
@staleness_budget, @drift_baseline_seconds, @content_budgets
)
ON CONFLICT (tenant_id) DO UPDATE SET
id = EXCLUDED.id,
sealed = EXCLUDED.sealed,
policy_hash = EXCLUDED.policy_hash,
time_anchor = EXCLUDED.time_anchor,
last_transition_at = EXCLUDED.last_transition_at,
staleness_budget = EXCLUDED.staleness_budget,
drift_baseline_seconds = EXCLUDED.drift_baseline_seconds,
content_budgets = EXCLUDED.content_budgets,
updated_at = NOW();
""";
await using var dbContext = AirGapDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", state.Id);
AddParameter(command, "tenant_id", tenantKey);
AddParameter(command, "sealed", state.Sealed);
AddParameter(command, "policy_hash", (object?)state.PolicyHash ?? DBNull.Value);
AddJsonbParameter(command, "time_anchor", SerializeTimeAnchor(state.TimeAnchor));
AddParameter(command, "last_transition_at", state.LastTransitionAt);
AddJsonbParameter(command, "staleness_budget", SerializeStalenessBudget(state.StalenessBudget));
AddParameter(command, "drift_baseline_seconds", state.DriftBaselineSeconds);
AddJsonbParameter(command, "content_budgets", SerializeContentBudgets(state.ContentBudgets));
var existing = await dbContext.States
.FirstOrDefaultAsync(s => s.TenantId == tenantKey, cancellationToken)
.ConfigureAwait(false);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
if (existing is null)
{
dbContext.States.Add(ToEntity(state, tenantKey));
}
else
{
Apply(existing, state, tenantKey);
existing.UpdatedAt = DateTime.UtcNow;
}
try
{
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
dbContext.ChangeTracker.Clear();
var conflict = await dbContext.States
.FirstOrDefaultAsync(s => s.TenantId == tenantKey, cancellationToken)
.ConfigureAwait(false);
if (conflict is null)
{
throw;
}
Apply(conflict, state, tenantKey);
conflict.UpdatedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
private State ToEntity(AirGapState state, string tenantKey)
{
return new State
{
Id = state.Id,
TenantId = tenantKey,
Sealed = state.Sealed,
PolicyHash = state.PolicyHash,
TimeAnchor = SerializeTimeAnchor(state.TimeAnchor),
LastTransitionAt = state.LastTransitionAt.UtcDateTime,
StalenessBudget = SerializeStalenessBudget(state.StalenessBudget),
DriftBaselineSeconds = state.DriftBaselineSeconds,
ContentBudgets = SerializeContentBudgets(state.ContentBudgets),
UpdatedAt = DateTime.UtcNow
};
}
private void Apply(State entity, AirGapState state, string tenantKey)
{
entity.Id = state.Id;
entity.TenantId = tenantKey;
entity.Sealed = state.Sealed;
entity.PolicyHash = state.PolicyHash;
entity.TimeAnchor = SerializeTimeAnchor(state.TimeAnchor);
entity.LastTransitionAt = state.LastTransitionAt.UtcDateTime;
entity.StalenessBudget = SerializeStalenessBudget(state.StalenessBudget);
entity.DriftBaselineSeconds = state.DriftBaselineSeconds;
entity.ContentBudgets = SerializeContentBudgets(state.ContentBudgets);
}
private static bool IsUniqueViolation(DbUpdateException exception)
{
Exception? current = exception;
while (current is not null)
{
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
{
return true;
}
current = current.InnerException;
}
return false;
}
}

View File

@@ -1,63 +1,60 @@
using System.Threading;
using Npgsql;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.AirGap.Persistence.EfCore.Models;
using BundleVersionEntity = StellaOps.AirGap.Persistence.EfCore.Models.BundleVersion;
using BundleVersionHistoryEntity = StellaOps.AirGap.Persistence.EfCore.Models.BundleVersionHistory;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
public sealed partial class PostgresBundleVersionStore
{
private static BundleVersionRecord Map(NpgsqlDataReader reader)
private static BundleVersionRecord Map(BundleVersionEntity row)
{
var tenantId = reader.GetString(0);
var bundleType = reader.GetString(1);
var versionString = reader.GetString(2);
var major = reader.GetInt32(3);
var minor = reader.GetInt32(4);
var patch = reader.GetInt32(5);
var prerelease = reader.IsDBNull(6) ? null : reader.GetString(6);
var bundleCreatedAt = reader.GetFieldValue<DateTimeOffset>(7);
var bundleDigest = reader.GetString(8);
var activatedAt = reader.GetFieldValue<DateTimeOffset>(9);
var wasForceActivated = reader.GetBoolean(10);
var forceActivateReason = reader.IsDBNull(11) ? null : reader.GetString(11);
return new BundleVersionRecord(
TenantId: tenantId,
BundleType: bundleType,
VersionString: versionString,
Major: major,
Minor: minor,
Patch: patch,
Prerelease: prerelease,
BundleCreatedAt: bundleCreatedAt,
BundleDigest: bundleDigest,
ActivatedAt: activatedAt,
WasForceActivated: wasForceActivated,
ForceActivateReason: forceActivateReason);
TenantId: row.TenantId,
BundleType: row.BundleType,
VersionString: row.VersionString,
Major: row.Major,
Minor: row.Minor,
Patch: row.Patch,
Prerelease: row.Prerelease,
BundleCreatedAt: ToUtcOffset(row.BundleCreatedAt),
BundleDigest: row.BundleDigest,
ActivatedAt: ToUtcOffset(row.ActivatedAt),
WasForceActivated: row.WasForceActivated,
ForceActivateReason: row.ForceActivateReason);
}
private async Task<BundleVersionRecord?> GetCurrentForUpdateAsync(
NpgsqlConnection connection,
NpgsqlTransaction transaction,
string versionTable,
string tenantKey,
string bundleTypeKey,
CancellationToken ct)
private static BundleVersionRecord Map(BundleVersionHistoryEntity row)
{
var sql = $$"""
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
FROM {{versionTable}}
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type
FOR UPDATE;
""";
await using var command = CreateCommand(sql, connection);
command.Transaction = transaction;
AddParameter(command, "tenant_id", tenantKey);
AddParameter(command, "bundle_type", bundleTypeKey);
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? Map(reader) : null;
return new BundleVersionRecord(
TenantId: row.TenantId,
BundleType: row.BundleType,
VersionString: row.VersionString,
Major: row.Major,
Minor: row.Minor,
Patch: row.Patch,
Prerelease: row.Prerelease,
BundleCreatedAt: ToUtcOffset(row.BundleCreatedAt),
BundleDigest: row.BundleDigest,
ActivatedAt: ToUtcOffset(row.ActivatedAt),
WasForceActivated: row.WasForceActivated,
ForceActivateReason: row.ForceActivateReason);
}
private static DateTimeOffset ToUtcOffset(DateTime value)
{
if (value.Kind == DateTimeKind.Utc)
{
return new DateTimeOffset(value, TimeSpan.Zero);
}
if (value.Kind == DateTimeKind.Local)
{
return new DateTimeOffset(value.ToUniversalTime(), TimeSpan.Zero);
}
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc), TimeSpan.Zero);
}
private static DateTime ToUtcDateTime(DateTimeOffset value) => value.UtcDateTime;
}

View File

@@ -1,7 +1,4 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Npgsql;
using Microsoft.EntityFrameworkCore;
using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
@@ -21,21 +18,17 @@ public sealed partial class PostgresBundleVersionStore
var tenantKey = NormalizeKey(tenantId);
var bundleTypeKey = NormalizeKey(bundleType);
var versionTable = GetQualifiedTableName("bundle_versions");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", ct).ConfigureAwait(false);
var sql = $$"""
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
FROM {{versionTable}}
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type;
""";
await using var dbContext = AirGapDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantKey);
AddParameter(command, "bundle_type", bundleTypeKey);
var row = await dbContext.BundleVersions
.AsNoTracking()
.FirstOrDefaultAsync(
b => b.TenantId == tenantKey && b.BundleType == bundleTypeKey,
ct)
.ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
return await reader.ReadAsync(ct).ConfigureAwait(false) ? Map(reader) : null;
return row is null ? null : Map(row);
}
public async Task<IReadOnlyList<BundleVersionRecord>> GetHistoryAsync(
@@ -57,29 +50,18 @@ public sealed partial class PostgresBundleVersionStore
var tenantKey = NormalizeKey(tenantId);
var bundleTypeKey = NormalizeKey(bundleType);
var historyTable = GetQualifiedTableName("bundle_version_history");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", ct).ConfigureAwait(false);
var sql = $$"""
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
FROM {{historyTable}}
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type
ORDER BY activated_at DESC, id DESC
LIMIT @limit;
""";
await using var dbContext = AirGapDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantKey);
AddParameter(command, "bundle_type", bundleTypeKey);
AddParameter(command, "limit", limit);
var rows = await dbContext.BundleVersionHistories
.AsNoTracking()
.Where(b => b.TenantId == tenantKey && b.BundleType == bundleTypeKey)
.OrderByDescending(b => b.ActivatedAt)
.ThenByDescending(b => b.Id)
.Take(limit)
.ToListAsync(ct)
.ConfigureAwait(false);
await using var reader = await command.ExecuteReaderAsync(ct).ConfigureAwait(false);
var results = new List<BundleVersionRecord>();
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
results.Add(Map(reader));
}
return results;
return rows.Select(Map).ToList();
}
}

View File

@@ -1,58 +1,49 @@
using System;
using System.Threading;
using Npgsql;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.AirGap.Persistence.EfCore.Context;
using StellaOps.AirGap.Persistence.EfCore.Models;
using BundleVersionEntity = StellaOps.AirGap.Persistence.EfCore.Models.BundleVersion;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
public sealed partial class PostgresBundleVersionStore
{
private async Task UpsertCurrentAsync(
NpgsqlConnection connection,
NpgsqlTransaction tx,
string versionTable,
private static void UpsertCurrent(
AirGapDbContext dbContext,
BundleVersionEntity? currentEntity,
BundleVersionRecord record,
string tenantKey,
string bundleTypeKey,
CancellationToken ct)
string bundleTypeKey)
{
var upsertSql = $$"""
INSERT INTO {{versionTable}} (
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
)
VALUES (
@tenant_id, @bundle_type, @version_string, @major, @minor, @patch, @prerelease,
@bundle_created_at, @bundle_digest, @activated_at, @was_force_activated, @force_activate_reason
)
ON CONFLICT (tenant_id, bundle_type) DO UPDATE SET
version_string = EXCLUDED.version_string,
major = EXCLUDED.major,
minor = EXCLUDED.minor,
patch = EXCLUDED.patch,
prerelease = EXCLUDED.prerelease,
bundle_created_at = EXCLUDED.bundle_created_at,
bundle_digest = EXCLUDED.bundle_digest,
activated_at = EXCLUDED.activated_at,
was_force_activated = EXCLUDED.was_force_activated,
force_activate_reason = EXCLUDED.force_activate_reason,
updated_at = NOW();
""";
if (currentEntity is null)
{
dbContext.BundleVersions.Add(new BundleVersionEntity
{
TenantId = tenantKey,
BundleType = bundleTypeKey,
VersionString = record.VersionString,
Major = record.Major,
Minor = record.Minor,
Patch = record.Patch,
Prerelease = record.Prerelease,
BundleCreatedAt = ToUtcDateTime(record.BundleCreatedAt),
BundleDigest = record.BundleDigest,
ActivatedAt = ToUtcDateTime(record.ActivatedAt),
WasForceActivated = record.WasForceActivated,
ForceActivateReason = record.ForceActivateReason
});
return;
}
await using var upsertCmd = CreateCommand(upsertSql, connection);
upsertCmd.Transaction = tx;
AddParameter(upsertCmd, "tenant_id", tenantKey);
AddParameter(upsertCmd, "bundle_type", bundleTypeKey);
AddParameter(upsertCmd, "version_string", record.VersionString);
AddParameter(upsertCmd, "major", record.Major);
AddParameter(upsertCmd, "minor", record.Minor);
AddParameter(upsertCmd, "patch", record.Patch);
AddParameter(upsertCmd, "prerelease", (object?)record.Prerelease ?? DBNull.Value);
AddParameter(upsertCmd, "bundle_created_at", record.BundleCreatedAt);
AddParameter(upsertCmd, "bundle_digest", record.BundleDigest);
AddParameter(upsertCmd, "activated_at", record.ActivatedAt);
AddParameter(upsertCmd, "was_force_activated", record.WasForceActivated);
AddParameter(upsertCmd, "force_activate_reason", (object?)record.ForceActivateReason ?? DBNull.Value);
await upsertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
currentEntity.VersionString = record.VersionString;
currentEntity.Major = record.Major;
currentEntity.Minor = record.Minor;
currentEntity.Patch = record.Patch;
currentEntity.Prerelease = record.Prerelease;
currentEntity.BundleCreatedAt = ToUtcDateTime(record.BundleCreatedAt);
currentEntity.BundleDigest = record.BundleDigest;
currentEntity.ActivatedAt = ToUtcDateTime(record.ActivatedAt);
currentEntity.WasForceActivated = record.WasForceActivated;
currentEntity.ForceActivateReason = record.ForceActivateReason;
currentEntity.UpdatedAt = DateTime.UtcNow;
}
}

View File

@@ -1,71 +1,56 @@
using System;
using System.Threading;
using Npgsql;
using Microsoft.EntityFrameworkCore;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.AirGap.Persistence.EfCore.Context;
using StellaOps.AirGap.Persistence.EfCore.Models;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
public sealed partial class PostgresBundleVersionStore
{
private async Task CloseHistoryAsync(
NpgsqlConnection connection,
NpgsqlTransaction tx,
string historyTable,
private static async Task CloseHistoryAsync(
AirGapDbContext dbContext,
BundleVersionRecord record,
string tenantKey,
string bundleTypeKey,
CancellationToken ct)
{
var closeHistorySql = $$"""
UPDATE {{historyTable}}
SET deactivated_at = @activated_at
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type AND deactivated_at IS NULL;
""";
var activeRows = await dbContext.BundleVersionHistories
.Where(h => h.TenantId == tenantKey && h.BundleType == bundleTypeKey && h.DeactivatedAt == null)
.ToListAsync(ct)
.ConfigureAwait(false);
await using var closeCmd = CreateCommand(closeHistorySql, connection);
closeCmd.Transaction = tx;
AddParameter(closeCmd, "tenant_id", tenantKey);
AddParameter(closeCmd, "bundle_type", bundleTypeKey);
AddParameter(closeCmd, "activated_at", record.ActivatedAt);
await closeCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
if (activeRows.Count == 0)
{
return;
}
var deactivatedAt = ToUtcDateTime(record.ActivatedAt);
foreach (var row in activeRows)
{
row.DeactivatedAt = deactivatedAt;
}
}
private async Task InsertHistoryAsync(
NpgsqlConnection connection,
NpgsqlTransaction tx,
string historyTable,
private static void InsertHistory(
AirGapDbContext dbContext,
BundleVersionRecord record,
string tenantKey,
string bundleTypeKey,
CancellationToken ct)
string bundleTypeKey)
{
var historySql = $$"""
INSERT INTO {{historyTable}} (
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, deactivated_at,
was_force_activated, force_activate_reason
)
VALUES (
@tenant_id, @bundle_type, @version_string, @major, @minor, @patch, @prerelease,
@bundle_created_at, @bundle_digest, @activated_at, NULL,
@was_force_activated, @force_activate_reason
);
""";
await using var historyCmd = CreateCommand(historySql, connection);
historyCmd.Transaction = tx;
AddParameter(historyCmd, "tenant_id", tenantKey);
AddParameter(historyCmd, "bundle_type", bundleTypeKey);
AddParameter(historyCmd, "version_string", record.VersionString);
AddParameter(historyCmd, "major", record.Major);
AddParameter(historyCmd, "minor", record.Minor);
AddParameter(historyCmd, "patch", record.Patch);
AddParameter(historyCmd, "prerelease", (object?)record.Prerelease ?? DBNull.Value);
AddParameter(historyCmd, "bundle_created_at", record.BundleCreatedAt);
AddParameter(historyCmd, "bundle_digest", record.BundleDigest);
AddParameter(historyCmd, "activated_at", record.ActivatedAt);
AddParameter(historyCmd, "was_force_activated", record.WasForceActivated);
AddParameter(historyCmd, "force_activate_reason", (object?)record.ForceActivateReason ?? DBNull.Value);
await historyCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
dbContext.BundleVersionHistories.Add(new BundleVersionHistory
{
TenantId = tenantKey,
BundleType = bundleTypeKey,
VersionString = record.VersionString,
Major = record.Major,
Minor = record.Minor,
Patch = record.Patch,
Prerelease = record.Prerelease,
BundleCreatedAt = ToUtcDateTime(record.BundleCreatedAt),
BundleDigest = record.BundleDigest,
ActivatedAt = ToUtcDateTime(record.ActivatedAt),
WasForceActivated = record.WasForceActivated,
ForceActivateReason = record.ForceActivateReason
});
}
}

View File

@@ -1,5 +1,5 @@
using System;
using System.Threading;
using System.Data;
using Microsoft.EntityFrameworkCore;
using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
@@ -14,30 +14,25 @@ public sealed partial class PostgresBundleVersionStore
var tenantKey = NormalizeKey(record.TenantId);
var bundleTypeKey = NormalizeKey(record.BundleType);
var versionTable = GetQualifiedTableName("bundle_versions");
var historyTable = GetQualifiedTableName("bundle_version_history");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", ct).ConfigureAwait(false);
await using var tx = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
await using var dbContext = AirGapDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct)
.ConfigureAwait(false);
var current = await GetCurrentForUpdateAsync(
connection,
tx,
versionTable,
tenantKey,
bundleTypeKey,
var currentEntity = await dbContext.BundleVersions
.FirstOrDefaultAsync(
b => b.TenantId == tenantKey && b.BundleType == bundleTypeKey,
ct)
.ConfigureAwait(false);
var current = currentEntity is null ? null : Map(currentEntity);
EnsureMonotonicVersion(record, current);
await CloseHistoryAsync(connection, tx, historyTable, record, tenantKey, bundleTypeKey, ct)
.ConfigureAwait(false);
await InsertHistoryAsync(connection, tx, historyTable, record, tenantKey, bundleTypeKey, ct)
.ConfigureAwait(false);
await UpsertCurrentAsync(connection, tx, versionTable, record, tenantKey, bundleTypeKey, ct)
.ConfigureAwait(false);
await CloseHistoryAsync(dbContext, record, tenantKey, bundleTypeKey, ct).ConfigureAwait(false);
InsertHistory(dbContext, record, tenantKey, bundleTypeKey);
UpsertCurrent(dbContext, currentEntity, record, tenantKey, bundleTypeKey);
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
}
}

View File

@@ -14,6 +14,11 @@
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\AirGapDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />

View File

@@ -1,8 +1,14 @@
# StellaOps.AirGap.Persistence Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
Source of truth:
- `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`
- `docs/implplan/SPRINT_20260222_064_AirGap_next_smallest_module_dal_to_efcore.md`
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AirGap/__Libraries/StellaOps.AirGap.Persistence/StellaOps.AirGap.Persistence.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| AIRGAP-EF-01 | DONE | Scaffolded EF models/context for AirGap schema (`state`, `bundle_versions`, `bundle_version_history`). |
| AIRGAP-EF-02 | DONE | Converted `PostgresAirGapStateStore` and `PostgresBundleVersionStore` DAL flows to EF Core with preserved contracts. |
| AIRGAP-EF-03 | DONE | Added compiled model generation and static model runtime wiring for default `airgap` schema. |
| AIRGAP-EF-04 | DONE | Completed sequential build/test + docs updates for AirGap EF migration workflow. |

View File

@@ -30,7 +30,8 @@ public static class VerdictEndpoints
group.MapPost("/", CreateVerdict)
.WithName("CreateVerdict")
.WithSummary("Append a new verdict to the ledger")
.WithDescription("Creates a new verdict entry with cryptographic chain linking")
.WithDescription("Appends a new release verdict to the immutable hash-chained ledger. Each entry records the decision (approve/reject), policy bundle ID, verifier image digest, and signer key ID. Returns 409 Conflict if chain integrity would be violated. Requires attestor:write scope.")
.RequireAuthorization("attestor:write")
.Produces<CreateVerdictResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
@@ -39,25 +40,30 @@ public static class VerdictEndpoints
group.MapGet("/", QueryVerdicts)
.WithName("QueryVerdicts")
.WithSummary("Query verdicts by bom-ref")
.WithDescription("Returns all verdicts for a given package/artifact reference")
.WithDescription("Returns all verdict ledger entries for a specific package bom-ref (PURL or container digest), filtered by tenant. Results are ordered chronologically for chain traversal. Requires attestor:read scope.")
.RequireAuthorization("attestor:read")
.Produces<IReadOnlyList<VerdictResponse>>();
group.MapGet("/{hash}", GetVerdictByHash)
.WithName("GetVerdictByHash")
.WithSummary("Get a verdict by its hash")
.WithDescription("Returns a specific verdict entry by its SHA-256 hash")
.WithDescription("Returns a specific verdict ledger entry identified by its SHA-256 hash digest. Returns 404 if no entry exists with the given hash. Requires attestor:read scope.")
.RequireAuthorization("attestor:read")
.Produces<VerdictResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/chain/verify", VerifyChain)
.WithName("VerifyChainIntegrity")
.WithSummary("Verify ledger chain integrity")
.WithDescription("Walks the hash chain to verify cryptographic integrity")
.WithDescription("Walks the full verdict ledger hash chain for the tenant and verifies that every entry's previous-hash pointer is cryptographically valid. Returns a structured result with the total entries checked and any integrity violations found. Requires attestor:read scope.")
.RequireAuthorization("attestor:read")
.Produces<ChainVerificationResult>();
group.MapGet("/latest", GetLatestVerdict)
.WithName("GetLatestVerdict")
.WithSummary("Get the latest verdict for a bom-ref")
.WithDescription("Returns the most recent verdict ledger entry for a specific bom-ref in the tenant. Useful for gating deployments based on the last-known release decision. Returns 404 if no verdict has been recorded. Requires attestor:read scope.")
.RequireAuthorization("attestor:read")
.Produces<VerdictResponse>()
.Produces(StatusCodes.Status404NotFound);
}

View File

@@ -37,6 +37,8 @@ internal static class AttestorWebServiceEndpoints
return Results.Ok(response);
})
.WithName("ListAttestations")
.WithDescription("Lists attestation entries from the repository with optional filters. Returns a paginated result with continuation token for incremental sync. Requires attestor:read scope.")
.RequireAuthorization("attestor:read")
.RequireRateLimiting("attestor-reads");
@@ -60,6 +62,8 @@ internal static class AttestorWebServiceEndpoints
var package = await bundleService.ExportAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Ok(package);
})
.WithName("ExportAttestationBundle")
.WithDescription("Exports attestations as a portable bundle package with optional filters by artifact digest, date range, and predicate type. Used for offline transfer and air-gap synchronization. Requires attestor:read scope.")
.RequireAuthorization("attestor:read")
.RequireRateLimiting("attestor-reads")
.Produces<AttestorBundlePackage>(StatusCodes.Status200OK);
@@ -74,6 +78,8 @@ internal static class AttestorWebServiceEndpoints
var result = await bundleService.ImportAsync(package, cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
})
.WithName("ImportAttestationBundle")
.WithDescription("Imports a portable attestation bundle package into the attestor store. All entries within the bundle are validated before persistence. Returns a summary of imported and skipped entries. Requires attestor:write scope.")
.RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions")
.Produces<AttestorBundleImportResult>(StatusCodes.Status200OK);
@@ -146,8 +152,11 @@ internal static class AttestorWebServiceEndpoints
["code"] = signingEx.Code
});
}
}).RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions");
})
.WithName("SignAttestation")
.WithDescription("Signs an attestation payload using the configured key and DSSE envelope format. Requires a valid client certificate and authenticated principal. Returns the signed bundle with key metadata and optional Rekor submission details. Requires attestor:write scope.")
.RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions");
// In-toto link creation endpoint
app.MapPost("/api/v1/attestor/links", async (
@@ -278,9 +287,12 @@ internal static class AttestorWebServiceEndpoints
["code"] = signingEx.Code
});
}
}).RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions")
.Produces<InTotoLinkCreateResponseDto>(StatusCodes.Status200OK);
})
.WithName("CreateInTotoLink")
.WithDescription("Creates and signs an in-toto link metadata object for a named step, including materials, products, command, environment, and return value. Returns the signed DSSE envelope with optional Rekor entry. Requires attestor:write scope.")
.RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions")
.Produces<InTotoLinkCreateResponseDto>(StatusCodes.Status200OK);
app.MapPost("/api/v1/rekor/entries", async (AttestorSubmissionRequest request, HttpContext httpContext, IAttestorSubmissionService submissionService, CancellationToken cancellationToken) =>
{
@@ -316,16 +328,22 @@ internal static class AttestorWebServiceEndpoints
});
}
})
.WithName("SubmitRekorEntry")
.WithDescription("Submits an attestation entry to the configured Rekor transparency log. Requires a valid client certificate and authenticated principal. Returns the Rekor entry details including UUID, log index, and inclusion proof. Requires attestor:write scope.")
.RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions");
app.MapGet("/api/v1/rekor/entries/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
await GetAttestationDetailResultAsync(uuid, refresh is true, verificationService, cancellationToken))
.WithName("GetRekorEntry")
.WithDescription("Retrieves a Rekor transparency log entry by UUID, including inclusion proof, checkpoint, and artifact metadata. Set refresh=true to bypass cache and fetch the latest state from Rekor. Requires attestor:read scope.")
.RequireAuthorization("attestor:read")
.RequireRateLimiting("attestor-reads");
app.MapGet("/api/v1/attestations/{uuid}", async (string uuid, bool? refresh, IAttestorVerificationService verificationService, CancellationToken cancellationToken) =>
await GetAttestationDetailResultAsync(uuid, refresh is true, verificationService, cancellationToken))
.WithName("GetAttestationByUuid")
.WithDescription("Retrieves an attestation entry by UUID, including inclusion proof, checkpoint, artifact metadata, and optional mirror status. Equivalent to the Rekor entry endpoint but accessed by attestor UUID alias. Requires attestor:read scope.")
.RequireAuthorization("attestor:read")
.RequireRateLimiting("attestor-reads");
@@ -349,6 +367,8 @@ internal static class AttestorWebServiceEndpoints
});
}
})
.WithName("VerifyRekorEntry")
.WithDescription("Verifies an attestation against the Rekor transparency log, checking inclusion proof, checkpoint consistency, and signature validity. Returns a structured verification result with per-check diagnostics. Requires attestor:verify scope.")
.RequireAuthorization("attestor:verify")
.RequireRateLimiting("attestor-verifications");
@@ -374,8 +394,11 @@ internal static class AttestorWebServiceEndpoints
job = await jobStore.CreateAsync(job!, cancellationToken).ConfigureAwait(false);
var response = BulkVerificationContracts.MapJob(job);
return Results.Accepted($"/api/v1/rekor/verify:bulk/{job.Id}", response);
}).RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-bulk");
})
.WithName("CreateBulkVerificationJob")
.WithDescription("Enqueues a bulk attestation verification job for processing multiple entries asynchronously. Returns 202 Accepted with the job ID and a polling URL. Queue depth is enforced by quota configuration. Requires attestor:write scope.")
.RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-bulk");
app.MapGet("/api/v1/rekor/verify:bulk/{jobId}", async (
string jobId,
@@ -395,7 +418,10 @@ internal static class AttestorWebServiceEndpoints
}
return Results.Ok(BulkVerificationContracts.MapJob(job));
}).RequireAuthorization("attestor:write");
})
.WithName("GetBulkVerificationJob")
.WithDescription("Returns the current status and results of a bulk attestation verification job by job ID. The job is only visible to the principal that submitted it. Returns 404 for unknown or unauthorized job IDs. Requires attestor:write scope.")
.RequireAuthorization("attestor:write");
// SPDX 3.0.1 Build Profile export endpoint (BP-007)
app.MapPost("/api/v1/attestations:export-build", (
@@ -512,6 +538,8 @@ internal static class AttestorWebServiceEndpoints
return Results.Ok(response);
})
.WithName("ExportSpdx3BuildAttestation")
.WithDescription("Exports a build attestation payload as an SPDX 3.0.1 Build Profile element, including builder identity, invocation details, configuration source, materials, and build timestamps. Returns structured SPDX document and optional DSSE envelope. Requires attestor:write scope.")
.RequireAuthorization("attestor:write")
.RequireRateLimiting("attestor-submissions")
.Produces<Spdx3BuildExportResponseDto>(StatusCodes.Status200OK);

View File

@@ -29,11 +29,15 @@ public static class PredicateRegistryEndpoints
group.MapGet("/", ListPredicateTypes)
.WithName("ListPredicateTypes")
.WithSummary("List all registered predicate types")
.WithDescription("Returns a paginated list of registered in-toto predicate type schemas from the registry, with optional filters by category and active status. Used to discover supported predicate URIs for attestation creation.")
.RequireAuthorization("attestor:read")
.Produces<PredicateTypeListResponse>(StatusCodes.Status200OK);
group.MapGet("/{uri}", GetPredicateType)
.WithName("GetPredicateType")
.WithSummary("Get predicate type schema by URI")
.WithDescription("Retrieves the full schema definition for a predicate type identified by its URI. The URI must be URL-encoded when passed as a path segment. Returns 404 if the predicate type is not registered.")
.RequireAuthorization("attestor:read")
.Produces<PredicateTypeRegistryEntry>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
}

View File

@@ -22,7 +22,7 @@ internal static class WatchlistEndpoints
{
var group = app.MapGroup("/api/v1/watchlist")
.WithTags("Watchlist")
.RequireAuthorization();
.RequireAuthorization("watchlist:read");
// List watchlist entries
group.MapGet("", ListWatchlistEntries)

View File

@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Attestor.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model stub for ProofChainDbContext.
/// This is a placeholder that delegates to runtime model building.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
[DbContext(typeof(ProofChainDbContext))]
public partial class AttestorDbContextModel : RuntimeModel
{
private static AttestorDbContextModel _instance;
public static IModel Instance
{
get
{
if (_instance == null)
{
_instance = new AttestorDbContextModel();
_instance.Initialize();
_instance.Customize();
}
return _instance;
}
}
partial void Initialize();
partial void Customize();
}

View File

@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
#pragma warning disable 219, 612, 618
#nullable disable
namespace StellaOps.Attestor.Persistence.EfCore.CompiledModels;
/// <summary>
/// Compiled model builder stub for ProofChainDbContext.
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
/// </summary>
public partial class AttestorDbContextModel
{
partial void Initialize()
{
// Stub: when a real compiled model is generated, entity types will be registered here.
// The runtime factory will fall back to reflection-based model building for all schemas
// until this stub is replaced with a full compiled model.
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace StellaOps.Attestor.Persistence.EfCore.Context;
/// <summary>
/// Design-time DbContext factory for dotnet ef CLI tooling.
/// Used by scaffold and optimize commands.
/// </summary>
public sealed class AttestorDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ProofChainDbContext>
{
private const string DefaultConnectionString =
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=proofchain,public";
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_ATTESTOR_EF_CONNECTION";
public ProofChainDbContext CreateDbContext(string[] args)
{
var connectionString = ResolveConnectionString();
var options = new DbContextOptionsBuilder<ProofChainDbContext>()
.UseNpgsql(connectionString)
.Options;
return new ProofChainDbContext(options);
}
private static string ResolveConnectionString()
{
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
}
}

View File

@@ -31,15 +31,16 @@ public static class PersistenceServiceCollectionExtensions
}
/// <summary>
/// Registers the predicate type registry repository backed by PostgreSQL.
/// Sprint: SPRINT_20260219_010 (PSR-02)
/// Registers the predicate type registry repository backed by PostgreSQL with EF Core.
/// Sprint: SPRINT_20260222_092 (ATTEST-EF-03)
/// </summary>
public static IServiceCollection AddPredicateTypeRegistry(
this IServiceCollection services,
string connectionString)
string connectionString,
string? schemaName = null)
{
services.TryAddSingleton<IPredicateTypeRegistryRepository>(
new PostgresPredicateTypeRegistryRepository(connectionString));
new PostgresPredicateTypeRegistryRepository(connectionString, schemaName));
return services;
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Attestor.Persistence.EfCore.CompiledModels;
namespace StellaOps.Attestor.Persistence.Postgres;
/// <summary>
/// Runtime factory for creating <see cref="ProofChainDbContext"/> instances.
/// Uses the static compiled model when schema matches the default; falls back to
/// reflection-based model building for non-default schemas (integration tests).
/// </summary>
internal static class AttestorDbContextFactory
{
public const string DefaultSchemaName = "proofchain";
public static ProofChainDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
? DefaultSchemaName
: schemaName.Trim();
var optionsBuilder = new DbContextOptionsBuilder<ProofChainDbContext>()
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
if (string.Equals(normalizedSchema, DefaultSchemaName, StringComparison.Ordinal))
{
// Guard: only use compiled model if it has entity types registered.
// Empty stub models bypass OnModelCreating and cause DbSet errors.
var compiledModel = AttestorDbContextModel.Instance;
if (compiledModel.GetEntityTypes().Any())
{
optionsBuilder.UseModel(compiledModel);
}
}
return new ProofChainDbContext(optionsBuilder.Options, normalizedSchema);
}
}

View File

@@ -1,16 +1,23 @@
using Microsoft.EntityFrameworkCore;
using StellaOps.Attestor.Persistence.Entities;
using StellaOps.Attestor.Persistence.Repositories;
namespace StellaOps.Attestor.Persistence;
/// <summary>
/// Entity Framework Core DbContext for proof chain persistence.
/// Maps to the proofchain and attestor PostgreSQL schemas.
/// </summary>
public class ProofChainDbContext : DbContext
public partial class ProofChainDbContext : DbContext
{
public ProofChainDbContext(DbContextOptions<ProofChainDbContext> options)
private readonly string _schemaName;
public ProofChainDbContext(DbContextOptions<ProofChainDbContext> options, string? schemaName = null)
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "proofchain"
: schemaName.Trim();
}
/// <summary>
@@ -43,16 +50,34 @@ public class ProofChainDbContext : DbContext
/// </summary>
public DbSet<AuditLogEntity> AuditLog => Set<AuditLogEntity>();
/// <summary>
/// Verdict ledger table.
/// </summary>
public virtual DbSet<VerdictLedgerEntry> VerdictLedger { get; set; }
/// <summary>
/// Predicate type registry table.
/// </summary>
public DbSet<PredicateTypeRegistryEntry> PredicateTypeRegistry => Set<PredicateTypeRegistryEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure schema
modelBuilder.HasDefaultSchema("proofchain");
var schemaName = _schemaName;
// Configure default schema
modelBuilder.HasDefaultSchema(schemaName);
// Configure custom enum
modelBuilder.HasPostgresEnum(schemaName, "verification_result",
new[] { "pass", "fail", "pending" });
// SbomEntryEntity configuration
modelBuilder.Entity<SbomEntryEntity>(entity =>
{
entity.HasKey(e => e.EntryId).HasName("sbom_entries_pkey");
entity.ToTable("sbom_entries", schemaName);
entity.HasIndex(e => e.BomDigest).HasDatabaseName("idx_sbom_entries_bom_digest");
entity.HasIndex(e => e.Purl).HasDatabaseName("idx_sbom_entries_purl");
entity.HasIndex(e => e.ArtifactDigest).HasDatabaseName("idx_sbom_entries_artifact");
@@ -86,6 +111,8 @@ public class ProofChainDbContext : DbContext
// DsseEnvelopeEntity configuration
modelBuilder.Entity<DsseEnvelopeEntity>(entity =>
{
entity.HasKey(e => e.EnvId).HasName("dsse_envelopes_pkey");
entity.ToTable("dsse_envelopes", schemaName);
entity.HasIndex(e => new { e.EntryId, e.PredicateType })
.HasDatabaseName("idx_dsse_entry_predicate");
entity.HasIndex(e => e.SignerKeyId).HasDatabaseName("idx_dsse_signer");
@@ -103,6 +130,8 @@ public class ProofChainDbContext : DbContext
// SpineEntity configuration
modelBuilder.Entity<SpineEntity>(entity =>
{
entity.HasKey(e => e.EntryId).HasName("spines_pkey");
entity.ToTable("spines", schemaName);
entity.HasIndex(e => e.BundleId).HasDatabaseName("idx_spines_bundle").IsUnique();
entity.HasIndex(e => e.AnchorId).HasDatabaseName("idx_spines_anchor");
entity.HasIndex(e => e.PolicyVersion).HasDatabaseName("idx_spines_policy");
@@ -119,6 +148,8 @@ public class ProofChainDbContext : DbContext
// TrustAnchorEntity configuration
modelBuilder.Entity<TrustAnchorEntity>(entity =>
{
entity.HasKey(e => e.AnchorId).HasName("trust_anchors_pkey");
entity.ToTable("trust_anchors", schemaName);
entity.HasIndex(e => e.PurlPattern).HasDatabaseName("idx_trust_anchors_pattern");
entity.HasIndex(e => e.IsActive)
.HasDatabaseName("idx_trust_anchors_active")
@@ -134,6 +165,8 @@ public class ProofChainDbContext : DbContext
// RekorEntryEntity configuration
modelBuilder.Entity<RekorEntryEntity>(entity =>
{
entity.HasKey(e => e.DsseSha256).HasName("rekor_entries_pkey");
entity.ToTable("rekor_entries", schemaName);
entity.HasIndex(e => e.LogIndex).HasDatabaseName("idx_rekor_log_index");
entity.HasIndex(e => e.LogId).HasDatabaseName("idx_rekor_log_id");
entity.HasIndex(e => e.Uuid).HasDatabaseName("idx_rekor_uuid");
@@ -151,6 +184,8 @@ public class ProofChainDbContext : DbContext
// AuditLogEntity configuration
modelBuilder.Entity<AuditLogEntity>(entity =>
{
entity.HasKey(e => e.LogId).HasName("audit_log_pkey");
entity.ToTable("audit_log", schemaName);
entity.HasIndex(e => new { e.EntityType, e.EntityId })
.HasDatabaseName("idx_audit_entity");
entity.HasIndex(e => e.CreatedAt)
@@ -160,8 +195,104 @@ public class ProofChainDbContext : DbContext
.HasDefaultValueSql("NOW()")
.ValueGeneratedOnAdd();
});
// VerdictLedgerEntry configuration
modelBuilder.Entity<VerdictLedgerEntry>(entity =>
{
entity.HasKey(e => e.LedgerId).HasName("verdict_ledger_pkey");
entity.ToTable("verdict_ledger", schemaName);
entity.HasIndex(e => e.BomRef).HasDatabaseName("idx_verdict_ledger_bom_ref");
entity.HasIndex(e => e.RekorUuid)
.HasDatabaseName("idx_verdict_ledger_rekor_uuid")
.HasFilter("rekor_uuid IS NOT NULL");
entity.HasIndex(e => e.CreatedAt)
.HasDatabaseName("idx_verdict_ledger_created_at")
.IsDescending();
entity.HasIndex(e => new { e.TenantId, e.CreatedAt })
.HasDatabaseName("idx_verdict_ledger_tenant_created")
.IsDescending(false, true);
entity.HasIndex(e => e.VerdictHash)
.HasDatabaseName("uq_verdict_hash")
.IsUnique();
entity.HasIndex(e => e.Decision).HasDatabaseName("idx_verdict_ledger_decision");
entity.Property(e => e.LedgerId).HasColumnName("ledger_id");
entity.Property(e => e.BomRef).HasColumnName("bom_ref").HasMaxLength(2048);
entity.Property(e => e.CycloneDxSerial).HasColumnName("cyclonedx_serial").HasMaxLength(512);
entity.Property(e => e.RekorUuid).HasColumnName("rekor_uuid").HasMaxLength(128);
entity.Property(e => e.Decision)
.HasColumnName("decision")
.HasConversion<string>();
entity.Property(e => e.Reason).HasColumnName("reason");
entity.Property(e => e.PolicyBundleId).HasColumnName("policy_bundle_id").HasMaxLength(256);
entity.Property(e => e.PolicyBundleHash).HasColumnName("policy_bundle_hash").HasMaxLength(64);
entity.Property(e => e.VerifierImageDigest).HasColumnName("verifier_image_digest").HasMaxLength(256);
entity.Property(e => e.SignerKeyId).HasColumnName("signer_keyid").HasMaxLength(512);
entity.Property(e => e.PrevHash).HasColumnName("prev_hash").HasMaxLength(64);
entity.Property(e => e.VerdictHash).HasColumnName("verdict_hash").HasMaxLength(64);
entity.Property(e => e.CreatedAt)
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
});
// PredicateTypeRegistryEntry configuration
modelBuilder.Entity<PredicateTypeRegistryEntry>(entity =>
{
entity.HasKey(e => e.RegistryId).HasName("predicate_type_registry_pkey");
entity.ToTable("predicate_type_registry", schemaName);
entity.HasIndex(e => new { e.PredicateTypeUri, e.Version })
.HasDatabaseName("uq_predicate_type_version")
.IsUnique();
entity.HasIndex(e => e.PredicateTypeUri)
.HasDatabaseName("idx_predicate_registry_uri");
entity.HasIndex(e => e.Category)
.HasDatabaseName("idx_predicate_registry_category");
entity.HasIndex(e => e.IsActive)
.HasDatabaseName("idx_predicate_registry_active")
.HasFilter("is_active = TRUE");
entity.Property(e => e.RegistryId)
.HasColumnName("registry_id")
.HasDefaultValueSql("gen_random_uuid()");
entity.Property(e => e.PredicateTypeUri)
.HasColumnName("predicate_type_uri")
.IsRequired();
entity.Property(e => e.DisplayName)
.HasColumnName("display_name")
.IsRequired();
entity.Property(e => e.Version)
.HasColumnName("version")
.HasDefaultValue("1.0.0");
entity.Property(e => e.Category)
.HasColumnName("category")
.HasDefaultValue("stella-core");
entity.Property(e => e.JsonSchema)
.HasColumnName("json_schema")
.HasColumnType("jsonb");
entity.Property(e => e.Description)
.HasColumnName("description");
entity.Property(e => e.IsActive)
.HasColumnName("is_active")
.HasDefaultValue(true);
entity.Property(e => e.ValidationMode)
.HasColumnName("validation_mode")
.HasDefaultValue("log-only");
entity.Property(e => e.CreatedAt)
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
entity.Property(e => e.UpdatedAt)
.HasColumnName("updated_at")
.HasDefaultValueSql("NOW()");
});
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
public override int SaveChanges()
{
NormalizeTrackedArrays();

View File

@@ -1,28 +1,33 @@
// -----------------------------------------------------------------------------
// PostgresPredicateTypeRegistryRepository.cs
// Sprint: SPRINT_20260219_010 (PSR-02)
// Task: PSR-02 - Create Predicate Schema Registry endpoints and repository
// Description: PostgreSQL implementation of predicate type registry repository
// Sprint: SPRINT_20260222_092_Attestor_dal_to_efcore
// Task: ATTEST-EF-03 - Convert DAL repositories to EF Core
// Description: EF Core implementation of predicate type registry repository
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Attestor.Persistence.Postgres;
namespace StellaOps.Attestor.Persistence.Repositories;
/// <summary>
/// PostgreSQL-backed predicate type registry repository.
/// Sprint: SPRINT_20260219_010 (PSR-02)
/// EF Core implementation of the predicate type registry repository.
/// Preserves idempotent registration via ON CONFLICT DO NOTHING semantics.
/// </summary>
public sealed class PostgresPredicateTypeRegistryRepository : IPredicateTypeRegistryRepository
{
private readonly string _connectionString;
private readonly string _schemaName;
private const int DefaultCommandTimeoutSeconds = 30;
/// <summary>
/// Creates a new PostgreSQL predicate type registry repository.
/// Creates a new EF Core predicate type registry repository.
/// </summary>
public PostgresPredicateTypeRegistryRepository(string connectionString)
public PostgresPredicateTypeRegistryRepository(string connectionString, string? schemaName = null)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_schemaName = schemaName ?? AttestorDbContextFactory.DefaultSchemaName;
}
/// <inheritdoc />
@@ -35,31 +40,26 @@ public sealed class PostgresPredicateTypeRegistryRepository : IPredicateTypeRegi
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
const string sql = @"
SELECT registry_id, predicate_type_uri, display_name, version, category,
json_schema, description, is_active, validation_mode, created_at, updated_at
FROM proofchain.predicate_type_registry
WHERE (@category::text IS NULL OR category = @category)
AND (@is_active::boolean IS NULL OR is_active = @is_active)
ORDER BY category, predicate_type_uri
OFFSET @offset LIMIT @limit";
var query = dbContext.PredicateTypeRegistry.AsNoTracking().AsQueryable();
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("category", (object?)category ?? DBNull.Value);
cmd.Parameters.AddWithValue("is_active", isActive.HasValue ? isActive.Value : DBNull.Value);
cmd.Parameters.AddWithValue("offset", offset);
cmd.Parameters.AddWithValue("limit", limit);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var results = new List<PredicateTypeRegistryEntry>();
while (await reader.ReadAsync(ct))
if (category is not null)
{
results.Add(MapEntry(reader));
query = query.Where(e => e.Category == category);
}
return results;
if (isActive.HasValue)
{
query = query.Where(e => e.IsActive == isActive.Value);
}
return await query
.OrderBy(e => e.Category)
.ThenBy(e => e.PredicateTypeUri)
.Skip(offset)
.Take(limit)
.ToListAsync(ct);
}
/// <inheritdoc />
@@ -69,25 +69,13 @@ public sealed class PostgresPredicateTypeRegistryRepository : IPredicateTypeRegi
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
const string sql = @"
SELECT registry_id, predicate_type_uri, display_name, version, category,
json_schema, description, is_active, validation_mode, created_at, updated_at
FROM proofchain.predicate_type_registry
WHERE predicate_type_uri = @predicate_type_uri
ORDER BY version DESC
LIMIT 1";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("predicate_type_uri", predicateTypeUri);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return MapEntry(reader);
}
return null;
return await dbContext.PredicateTypeRegistry
.AsNoTracking()
.Where(e => e.PredicateTypeUri == predicateTypeUri)
.OrderByDescending(e => e.Version)
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
@@ -99,55 +87,32 @@ public sealed class PostgresPredicateTypeRegistryRepository : IPredicateTypeRegi
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
const string sql = @"
INSERT INTO proofchain.predicate_type_registry
(predicate_type_uri, display_name, version, category, json_schema, description, is_active, validation_mode)
VALUES (@predicate_type_uri, @display_name, @version, @category, @json_schema::jsonb, @description, @is_active, @validation_mode)
ON CONFLICT (predicate_type_uri, version) DO NOTHING
RETURNING registry_id, created_at, updated_at";
dbContext.PredicateTypeRegistry.Add(entry);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("predicate_type_uri", entry.PredicateTypeUri);
cmd.Parameters.AddWithValue("display_name", entry.DisplayName);
cmd.Parameters.AddWithValue("version", entry.Version);
cmd.Parameters.AddWithValue("category", entry.Category);
cmd.Parameters.AddWithValue("json_schema", (object?)entry.JsonSchema ?? DBNull.Value);
cmd.Parameters.AddWithValue("description", (object?)entry.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("is_active", entry.IsActive);
cmd.Parameters.AddWithValue("validation_mode", entry.ValidationMode);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
try
{
return entry with
{
RegistryId = reader.GetGuid(0),
CreatedAt = reader.GetDateTime(1),
UpdatedAt = reader.GetDateTime(2),
};
await dbContext.SaveChangesAsync(ct);
return entry;
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// ON CONFLICT DO NOTHING semantics: return existing entry
var existing = await GetByUriAsync(entry.PredicateTypeUri, ct);
return existing ?? entry;
}
// Conflict (already exists) - return existing
var existing = await GetByUriAsync(entry.PredicateTypeUri, ct);
return existing ?? entry;
}
private static PredicateTypeRegistryEntry MapEntry(NpgsqlDataReader reader)
private static bool IsUniqueViolation(DbUpdateException exception)
{
return new PredicateTypeRegistryEntry
Exception? current = exception;
while (current is not null)
{
RegistryId = reader.GetGuid(0),
PredicateTypeUri = reader.GetString(1),
DisplayName = reader.GetString(2),
Version = reader.GetString(3),
Category = reader.GetString(4),
JsonSchema = reader.IsDBNull(5) ? null : reader.GetString(5),
Description = reader.IsDBNull(6) ? null : reader.GetString(6),
IsActive = reader.GetBoolean(7),
ValidationMode = reader.GetString(8),
CreatedAt = reader.GetDateTime(9),
UpdatedAt = reader.GetDateTime(10),
};
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
return true;
current = current.InnerException;
}
return false;
}
}

View File

@@ -1,29 +1,34 @@
// -----------------------------------------------------------------------------
// PostgresVerdictLedgerRepository.cs
// Sprint: SPRINT_20260118_015_Attestor_verdict_ledger_foundation
// Task: VL-002 - Implement VerdictLedger entity and repository
// Description: PostgreSQL implementation of verdict ledger repository
// Sprint: SPRINT_20260222_092_Attestor_dal_to_efcore
// Task: ATTEST-EF-03 - Convert DAL repositories to EF Core
// Description: EF Core implementation of verdict ledger repository
// -----------------------------------------------------------------------------
using Microsoft.EntityFrameworkCore;
using Npgsql;
using StellaOps.Attestor.Persistence.Entities;
using StellaOps.Attestor.Persistence.Postgres;
namespace StellaOps.Attestor.Persistence.Repositories;
/// <summary>
/// PostgreSQL implementation of the verdict ledger repository.
/// EF Core implementation of the verdict ledger repository.
/// Enforces append-only semantics with hash chain validation.
/// </summary>
public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
{
private readonly string _connectionString;
private readonly string _schemaName;
private const int DefaultCommandTimeoutSeconds = 30;
/// <summary>
/// Creates a new PostgreSQL verdict ledger repository.
/// Creates a new EF Core verdict ledger repository.
/// </summary>
public PostgresVerdictLedgerRepository(string connectionString)
public PostgresVerdictLedgerRepository(string connectionString, string? schemaName = null)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_schemaName = schemaName ?? AttestorDbContextFactory.DefaultSchemaName;
}
/// <inheritdoc />
@@ -31,9 +36,6 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
{
ArgumentNullException.ThrowIfNull(entry);
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
// Validate chain integrity
var latest = await GetLatestAsync(entry.TenantId, ct);
var expectedPrevHash = latest?.VerdictHash;
@@ -43,46 +45,30 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
throw new ChainIntegrityException(expectedPrevHash, entry.PrevHash);
}
// Insert the new entry
const string sql = @"
INSERT INTO verdict_ledger (
ledger_id, bom_ref, cyclonedx_serial, rekor_uuid, decision, reason,
policy_bundle_id, policy_bundle_hash, verifier_image_digest, signer_keyid,
prev_hash, verdict_hash, created_at, tenant_id
) VALUES (
@ledger_id, @bom_ref, @cyclonedx_serial, @rekor_uuid, @decision::verdict_decision, @reason,
@policy_bundle_id, @policy_bundle_hash, @verifier_image_digest, @signer_keyid,
@prev_hash, @verdict_hash, @created_at, @tenant_id
)
RETURNING ledger_id, created_at";
// Use raw SQL for the INSERT with RETURNING and enum cast, which is cleaner
// for the verdict_decision custom enum type
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("ledger_id", entry.LedgerId);
cmd.Parameters.AddWithValue("bom_ref", entry.BomRef);
cmd.Parameters.AddWithValue("cyclonedx_serial", (object?)entry.CycloneDxSerial ?? DBNull.Value);
cmd.Parameters.AddWithValue("rekor_uuid", (object?)entry.RekorUuid ?? DBNull.Value);
cmd.Parameters.AddWithValue("decision", entry.Decision.ToString().ToLowerInvariant());
cmd.Parameters.AddWithValue("reason", (object?)entry.Reason ?? DBNull.Value);
cmd.Parameters.AddWithValue("policy_bundle_id", entry.PolicyBundleId);
cmd.Parameters.AddWithValue("policy_bundle_hash", entry.PolicyBundleHash);
cmd.Parameters.AddWithValue("verifier_image_digest", entry.VerifierImageDigest);
cmd.Parameters.AddWithValue("signer_keyid", entry.SignerKeyId);
cmd.Parameters.AddWithValue("prev_hash", (object?)entry.PrevHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("verdict_hash", entry.VerdictHash);
cmd.Parameters.AddWithValue("created_at", entry.CreatedAt);
cmd.Parameters.AddWithValue("tenant_id", entry.TenantId);
dbContext.VerdictLedger.Add(entry);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
try
{
return entry with
await dbContext.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Idempotent: entry already exists
var existing = await GetByHashAsync(entry.VerdictHash, ct);
if (existing != null)
{
LedgerId = reader.GetGuid(0),
CreatedAt = reader.GetDateTime(1)
};
return existing;
}
throw;
}
throw new InvalidOperationException("Insert failed to return ledger_id");
return entry;
}
/// <inheritdoc />
@@ -90,24 +76,11 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
const string sql = @"
SELECT ledger_id, bom_ref, cyclonedx_serial, rekor_uuid, decision, reason,
policy_bundle_id, policy_bundle_hash, verifier_image_digest, signer_keyid,
prev_hash, verdict_hash, created_at, tenant_id
FROM verdict_ledger
WHERE verdict_hash = @verdict_hash";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("verdict_hash", verdictHash);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return MapToEntry(reader);
}
return null;
return await dbContext.VerdictLedger
.AsNoTracking()
.FirstOrDefaultAsync(e => e.VerdictHash == verdictHash, ct);
}
/// <inheritdoc />
@@ -118,27 +91,13 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
const string sql = @"
SELECT ledger_id, bom_ref, cyclonedx_serial, rekor_uuid, decision, reason,
policy_bundle_id, policy_bundle_hash, verifier_image_digest, signer_keyid,
prev_hash, verdict_hash, created_at, tenant_id
FROM verdict_ledger
WHERE bom_ref = @bom_ref AND tenant_id = @tenant_id
ORDER BY created_at ASC";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("bom_ref", bomRef);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
var results = new List<VerdictLedgerEntry>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapToEntry(reader));
}
return results;
return await dbContext.VerdictLedger
.AsNoTracking()
.Where(e => e.BomRef == bomRef && e.TenantId == tenantId)
.OrderBy(e => e.CreatedAt)
.ToListAsync(ct);
}
/// <inheritdoc />
@@ -146,26 +105,13 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
const string sql = @"
SELECT ledger_id, bom_ref, cyclonedx_serial, rekor_uuid, decision, reason,
policy_bundle_id, policy_bundle_hash, verifier_image_digest, signer_keyid,
prev_hash, verdict_hash, created_at, tenant_id
FROM verdict_ledger
WHERE tenant_id = @tenant_id
ORDER BY created_at DESC
LIMIT 1";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return MapToEntry(reader);
}
return null;
return await dbContext.VerdictLedger
.AsNoTracking()
.Where(e => e.TenantId == tenantId)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync(ct);
}
/// <inheritdoc />
@@ -207,34 +153,23 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
const string sql = "SELECT COUNT(*) FROM verdict_ledger WHERE tenant_id = @tenant_id";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
var result = await cmd.ExecuteScalarAsync(ct);
return Convert.ToInt64(result);
return await dbContext.VerdictLedger
.AsNoTracking()
.Where(e => e.TenantId == tenantId)
.LongCountAsync(ct);
}
private static VerdictLedgerEntry MapToEntry(NpgsqlDataReader reader)
private static bool IsUniqueViolation(DbUpdateException exception)
{
return new VerdictLedgerEntry
Exception? current = exception;
while (current is not null)
{
LedgerId = reader.GetGuid(0),
BomRef = reader.GetString(1),
CycloneDxSerial = reader.IsDBNull(2) ? null : reader.GetString(2),
RekorUuid = reader.IsDBNull(3) ? null : reader.GetString(3),
Decision = Enum.Parse<VerdictDecision>(reader.GetString(4), ignoreCase: true),
Reason = reader.IsDBNull(5) ? null : reader.GetString(5),
PolicyBundleId = reader.GetString(6),
PolicyBundleHash = reader.GetString(7),
VerifierImageDigest = reader.GetString(8),
SignerKeyId = reader.GetString(9),
PrevHash = reader.IsDBNull(10) ? null : reader.GetString(10),
VerdictHash = reader.GetString(11),
CreatedAt = reader.GetDateTime(12),
TenantId = reader.GetGuid(13)
};
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
return true;
current = current.InnerException;
}
return false;
}
}

View File

@@ -12,15 +12,26 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<None Include="Migrations\*.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
<Compile Remove="EfCore\CompiledModels\AttestorDbContextAssemblyAttributes.cs" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Tests\\**\\*.cs" />
</ItemGroup>

View File

@@ -1,7 +1,7 @@
# Attestor Persistence Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
Source of truth: `docs/implplan/SPRINT_20260222_092_Attestor_dal_to_efcore.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
@@ -9,3 +9,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0060-T | DONE | Revalidated 2026-01-06. |
| AUDIT-0060-A | TODO | Reopened after revalidation 2026-01-06. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| ATTEST-EF-01 | DONE | Migration registry plugin wired. 2026-02-23. |
| ATTEST-EF-02 | DONE | EF Core model baseline scaffolded with 8 entities. 2026-02-23. |
| ATTEST-EF-03 | DONE | VerdictLedger and PredicateTypeRegistry repos converted to EF Core. TrustVerdict/Infrastructure retain raw Npgsql. 2026-02-23. |
| ATTEST-EF-04 | DONE | Compiled model stubs + runtime factory with guard. 2026-02-23. |
| ATTEST-EF-05 | DONE | Sequential builds pass (0 errors). Tests: 73+806 pass. Docs updated. 2026-02-23. |

View File

@@ -36,6 +36,7 @@ public class AuthAbstractionsConstantsTests
{
[nameof(StellaOpsClaimTypes.Subject)] = "sub",
[nameof(StellaOpsClaimTypes.Tenant)] = "stellaops:tenant",
[nameof(StellaOpsClaimTypes.AllowedTenants)] = "stellaops:allowed_tenants",
[nameof(StellaOpsClaimTypes.Project)] = "stellaops:project",
[nameof(StellaOpsClaimTypes.ClientId)] = "client_id",
[nameof(StellaOpsClaimTypes.ServiceAccount)] = "stellaops:service_account",
@@ -67,6 +68,7 @@ public class AuthAbstractionsConstantsTests
Assert.Equal(StellaOpsClaimTypes.Subject, expected[nameof(StellaOpsClaimTypes.Subject)]);
Assert.Equal(StellaOpsClaimTypes.Tenant, expected[nameof(StellaOpsClaimTypes.Tenant)]);
Assert.Equal(StellaOpsClaimTypes.AllowedTenants, expected[nameof(StellaOpsClaimTypes.AllowedTenants)]);
Assert.Equal(StellaOpsClaimTypes.Project, expected[nameof(StellaOpsClaimTypes.Project)]);
Assert.Equal(StellaOpsClaimTypes.ClientId, expected[nameof(StellaOpsClaimTypes.ClientId)]);
Assert.Equal(StellaOpsClaimTypes.ServiceAccount, expected[nameof(StellaOpsClaimTypes.ServiceAccount)]);

View File

@@ -15,6 +15,11 @@ public static class StellaOpsClaimTypes
/// </summary>
public const string Tenant = "stellaops:tenant";
/// <summary>
/// Space-separated set of tenant identifiers assigned to the token subject/client.
/// </summary>
public const string AllowedTenants = "stellaops:allowed_tenants";
/// <summary>
/// StellaOps project identifier claim (optional project scoping within a tenant).
/// </summary>

View File

@@ -607,6 +607,60 @@ public static class StellaOpsScopes
public const string DoctorExport = "doctor:export";
public const string DoctorAdmin = "doctor:admin";
// Doctor Scheduler scopes
public const string DoctorSchedulerRead = "doctor-scheduler:read";
public const string DoctorSchedulerWrite = "doctor-scheduler:write";
// OpsMemory scopes
public const string OpsMemoryRead = "ops-memory:read";
public const string OpsMemoryWrite = "ops-memory:write";
// Unknowns scopes
public const string UnknownsRead = "unknowns:read";
public const string UnknownsWrite = "unknowns:write";
public const string UnknownsAdmin = "unknowns:admin";
// Replay scopes
public const string ReplayRead = "replay:read";
public const string ReplayWrite = "replay:write";
// Symbols scopes
public const string SymbolsRead = "symbols:read";
public const string SymbolsWrite = "symbols:write";
// VexHub scopes
public const string VexHubRead = "vexhub:read";
public const string VexHubAdmin = "vexhub:admin";
// RiskEngine scopes
public const string RiskEngineRead = "risk-engine:read";
public const string RiskEngineOperate = "risk-engine:operate";
// SmRemote (SM cryptography service) scopes
public const string SmRemoteSign = "sm-remote:sign";
public const string SmRemoteVerify = "sm-remote:verify";
// TaskRunner scopes
public const string TaskRunnerRead = "taskrunner:read";
public const string TaskRunnerOperate = "taskrunner:operate";
public const string TaskRunnerAdmin = "taskrunner:admin";
// Integration catalog scopes
/// <summary>
/// Scope granting read-only access to integration catalog entries and health status.
/// </summary>
public const string IntegrationRead = "integration:read";
/// <summary>
/// Scope granting permission to create, update, and delete integration catalog entries.
/// </summary>
public const string IntegrationWrite = "integration:write";
/// <summary>
/// Scope granting permission to execute integration operations (test connections, run AI Code Guard).
/// </summary>
public const string IntegrationOperate = "integration:operate";
private static readonly IReadOnlyList<string> AllScopes = BuildAllScopes();
private static readonly HashSet<string> KnownScopes = new(AllScopes, StringComparer.OrdinalIgnoreCase);

View File

@@ -72,6 +72,14 @@ public sealed class StellaOpsAuthClientOptions
/// </summary>
public TimeSpan ExpirationSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Default tenant identifier included in token requests when callers do not provide
/// an explicit <c>tenant</c> additional parameter. When set, the token client will
/// automatically add <c>tenant=&lt;value&gt;</c> to <see cref="StellaOpsTokenClient"/>
/// token requests so the issued token carries the correct <c>stellaops:tenant</c> claim.
/// </summary>
public string? DefaultTenant { get; set; }
/// <summary>
/// Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable.
/// </summary>
@@ -137,6 +145,7 @@ public sealed class StellaOpsAuthClientOptions
throw new InvalidOperationException("Offline cache tolerance must be greater than or equal to zero.");
}
DefaultTenant = string.IsNullOrWhiteSpace(DefaultTenant) ? null : DefaultTenant.Trim().ToLowerInvariant();
AuthorityUri = authorityUri;
NormalizedScopes = NormalizeScopes(scopes);
NormalizedRetryDelays = EnableRetries ? NormalizeRetryDelays(retryDelays) : Array.Empty<TimeSpan>();

View File

@@ -108,17 +108,19 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
await tokenClient.ClearCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false);
}
var tenantParameters = BuildTenantParameters(options);
StellaOpsTokenResult result = options.Mode switch
{
StellaOpsApiAuthMode.ClientCredentials => await tokenClient.RequestClientCredentialsTokenAsync(
options.Scope,
null,
tenantParameters,
cancellationToken).ConfigureAwait(false),
StellaOpsApiAuthMode.Password => await tokenClient.RequestPasswordTokenAsync(
options.Username!,
options.Password!,
options.Scope,
null,
tenantParameters,
cancellationToken).ConfigureAwait(false),
_ => throw new InvalidOperationException($"Unsupported authentication mode '{options.Mode}'.")
};
@@ -135,6 +137,19 @@ internal sealed class StellaOpsBearerTokenHandler : DelegatingHandler
}
}
private static IReadOnlyDictionary<string, string>? BuildTenantParameters(StellaOpsApiAuthenticationOptions options)
{
if (string.IsNullOrWhiteSpace(options.Tenant))
{
return null;
}
return new Dictionary<string, string>(1, StringComparer.Ordinal)
{
["tenant"] = options.Tenant.Trim().ToLowerInvariant()
};
}
private TimeSpan GetRefreshBuffer(StellaOpsApiAuthenticationOptions options)
{
var authOptions = authClientOptions.CurrentValue;

View File

@@ -89,6 +89,8 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
}
}
AppendDefaultTenant(parameters, options);
return RequestTokenAsync(parameters, cancellationToken);
}
@@ -126,6 +128,8 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
}
}
AppendDefaultTenant(parameters, options);
return RequestTokenAsync(parameters, cancellationToken);
}
@@ -186,6 +190,24 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
return result;
}
/// <summary>
/// Injects the configured default tenant into the token request when callers have not
/// provided an explicit <c>tenant</c> parameter. This ensures the issued token carries
/// the correct <c>stellaops:tenant</c> claim for multi-tenant deployments.
/// </summary>
private static void AppendDefaultTenant(IDictionary<string, string> parameters, StellaOpsAuthClientOptions options)
{
if (parameters.ContainsKey("tenant"))
{
return;
}
if (!string.IsNullOrWhiteSpace(options.DefaultTenant))
{
parameters["tenant"] = options.DefaultTenant;
}
}
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
{
var resolvedScope = scope;

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Provides access to the resolved <see cref="StellaOpsTenantContext"/> for the current request.
/// Injected via DI; populated by <see cref="StellaOpsTenantMiddleware"/>.
/// </summary>
public interface IStellaOpsTenantAccessor
{
/// <summary>
/// The resolved tenant context, or <c>null</c> if tenant was not resolved
/// (e.g. for system/global endpoints that do not require a tenant).
/// </summary>
StellaOpsTenantContext? TenantContext { get; set; }
/// <summary>
/// Shortcut to <see cref="StellaOpsTenantContext.TenantId"/> or <c>null</c>.
/// </summary>
string? TenantId => TenantContext?.TenantId;
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Default AsyncLocal-based implementation of <see cref="IStellaOpsTenantAccessor"/>.
/// Safe across async boundaries within a single request.
/// </summary>
internal sealed class StellaOpsTenantAccessor : IStellaOpsTenantAccessor
{
private static readonly AsyncLocal<StellaOpsTenantContext?> _current = new();
/// <inheritdoc />
public StellaOpsTenantContext? TenantContext
{
get => _current.Value;
set => _current.Value = value;
}
}

View File

@@ -0,0 +1,47 @@
using System;
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Immutable resolved tenant context for an HTTP request.
/// </summary>
public sealed record StellaOpsTenantContext
{
/// <summary>
/// The resolved tenant identifier (normalised, lower-case).
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// The actor who made the request (sub, client_id, or anonymous).
/// </summary>
public string ActorId { get; init; } = "anonymous";
/// <summary>
/// Optional project scope within the tenant.
/// </summary>
public string? ProjectId { get; init; }
/// <summary>
/// Where the tenant was resolved from (for diagnostics).
/// </summary>
public TenantSource Source { get; init; } = TenantSource.Unknown;
}
/// <summary>
/// Identifies the source that provided the tenant identifier.
/// </summary>
public enum TenantSource
{
/// <summary>Source unknown or not set.</summary>
Unknown = 0,
/// <summary>Resolved from the canonical <c>stellaops:tenant</c> JWT claim.</summary>
Claim = 1,
/// <summary>Resolved from the <c>X-StellaOps-Tenant</c> header.</summary>
CanonicalHeader = 2,
/// <summary>Resolved from a legacy header (<c>X-Stella-Tenant</c>, <c>X-Tenant-Id</c>).</summary>
LegacyHeader = 3,
}

View File

@@ -0,0 +1,92 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Text.Json;
using System.Threading.Tasks;
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// ASP.NET Core middleware that resolves tenant context from every request and
/// populates <see cref="IStellaOpsTenantAccessor"/> for downstream handlers.
/// <para>
/// Endpoints that require tenant context should use the <c>RequireTenant()</c> endpoint filter
/// rather than relying on this middleware to reject tenantless requests — this middleware
/// is intentionally permissive so that global/system endpoints can proceed without a tenant.
/// </para>
/// </summary>
internal sealed class StellaOpsTenantMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<StellaOpsTenantMiddleware> _logger;
public StellaOpsTenantMiddleware(RequestDelegate next, ILogger<StellaOpsTenantMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task InvokeAsync(HttpContext context, IStellaOpsTenantAccessor accessor)
{
try
{
if (StellaOpsTenantResolver.TryResolve(context, out var tenantContext, out var error))
{
accessor.TenantContext = tenantContext;
_logger.LogDebug("Tenant resolved: {TenantId} from {Source}", tenantContext!.TenantId, tenantContext.Source);
}
else
{
_logger.LogDebug("Tenant not resolved: {Error}", error);
}
await _next(context);
}
finally
{
accessor.TenantContext = null;
}
}
}
/// <summary>
/// Endpoint filter that rejects requests without a resolved tenant context with HTTP 400.
/// Apply to route groups or individual endpoints via <c>.RequireTenant()</c>.
/// </summary>
internal sealed class StellaOpsTenantEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var accessor = context.HttpContext.RequestServices.GetService(typeof(IStellaOpsTenantAccessor)) as IStellaOpsTenantAccessor;
if (accessor?.TenantContext is not null)
{
return await next(context);
}
// Tenant middleware ran but couldn't resolve — try to get the error reason.
// Return an IResult instead of writing directly to the response to avoid
// "response headers cannot be modified" when the framework also tries to
// serialize the filter's return value.
if (!StellaOpsTenantResolver.TryResolveTenantId(context.HttpContext, out _, out var error))
{
return Results.Json(new
{
type = "https://stellaops.org/errors/tenant-required",
title = "Tenant context is required",
status = 400,
detail = error switch
{
"tenant_missing" => "A valid tenant identifier must be provided via the stellaops:tenant claim or X-StellaOps-Tenant header.",
"tenant_conflict" => "Conflicting tenant identifiers detected across claims and headers.",
"tenant_invalid_format" => "Tenant identifier is not in the expected format.",
_ => $"Tenant resolution failed: {error}",
},
error_code = error,
}, statusCode: StatusCodes.Status400BadRequest, contentType: "application/problem+json");
}
// Should not happen (accessor is null but resolver succeeds) — internal error
return Results.StatusCode(StatusCodes.Status500InternalServerError);
}
}

View File

@@ -0,0 +1,266 @@
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using System;
using System.Security.Claims;
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Unified tenant resolver for all StellaOps backend services.
/// Resolves tenant identity from JWT claims and HTTP headers using a deterministic priority order:
/// <list type="number">
/// <item>Canonical claim: <c>stellaops:tenant</c></item>
/// <item>Legacy claim: <c>tid</c></item>
/// <item>Canonical header: <c>X-StellaOps-Tenant</c></item>
/// <item>Legacy header: <c>X-Stella-Tenant</c></item>
/// <item>Alternate header: <c>X-Tenant-Id</c></item>
/// </list>
/// Claims always win over headers. Conflicting headers or claim-header mismatches return an error.
/// </summary>
public static class StellaOpsTenantResolver
{
private const string LegacyTenantClaim = "tid";
private const string LegacyTenantHeader = "X-Stella-Tenant";
private const string AlternateTenantHeader = "X-Tenant-Id";
private const string ActorHeader = "X-StellaOps-Actor";
private const string ProjectHeader = "X-Stella-Project";
/// <summary>
/// Attempts to resolve a full <see cref="StellaOpsTenantContext"/> from the request.
/// </summary>
/// <param name="context">The HTTP context.</param>
/// <param name="tenantContext">The resolved tenant context on success.</param>
/// <param name="error">A machine-readable error code on failure (e.g. <c>tenant_missing</c>, <c>tenant_conflict</c>).</param>
/// <returns><c>true</c> if the tenant was resolved; <c>false</c> otherwise.</returns>
public static bool TryResolve(
HttpContext context,
out StellaOpsTenantContext? tenantContext,
out string? error)
{
ArgumentNullException.ThrowIfNull(context);
tenantContext = null;
error = null;
if (!TryResolveTenant(context, out var tenantId, out var source, out error))
{
return false;
}
var actorId = ResolveActor(context);
var projectId = ResolveProject(context);
tenantContext = new StellaOpsTenantContext
{
TenantId = tenantId,
ActorId = actorId,
ProjectId = projectId,
Source = source,
};
return true;
}
/// <summary>
/// Resolves only the tenant identifier (lightweight; no actor/project resolution).
/// </summary>
/// <param name="context">The HTTP context.</param>
/// <param name="tenantId">The resolved tenant identifier (normalised, lower-case).</param>
/// <param name="error">A machine-readable error code on failure.</param>
/// <returns><c>true</c> if the tenant was resolved; <c>false</c> otherwise.</returns>
public static bool TryResolveTenantId(
HttpContext context,
out string tenantId,
out string? error)
{
return TryResolveTenant(context, out tenantId, out _, out error);
}
/// <summary>
/// Resolves tenant ID or returns a default value if tenant is not available.
/// Useful for endpoints that support optional tenancy (e.g. system-scoped with optional tenant).
/// </summary>
public static string ResolveTenantIdOrDefault(HttpContext context, string defaultTenant = "default")
{
if (TryResolveTenantId(context, out var tenantId, out _))
{
return tenantId;
}
return NormalizeTenant(defaultTenant) ?? "default";
}
/// <summary>
/// Resolves the actor identifier from claims/headers. Falls back to <c>anonymous</c>.
/// </summary>
public static string ResolveActor(HttpContext context, string fallback = "anonymous")
{
ArgumentNullException.ThrowIfNull(context);
var subject = context.User.FindFirstValue(StellaOpsClaimTypes.Subject);
if (!string.IsNullOrWhiteSpace(subject))
return subject.Trim();
var clientId = context.User.FindFirstValue(StellaOpsClaimTypes.ClientId);
if (!string.IsNullOrWhiteSpace(clientId))
return clientId.Trim();
if (TryReadHeader(context, ActorHeader, out var actor))
return actor;
var identityName = context.User.Identity?.Name;
if (!string.IsNullOrWhiteSpace(identityName))
return identityName.Trim();
return fallback;
}
/// <summary>
/// Resolves the optional project scope from claims/headers.
/// </summary>
public static string? ResolveProject(HttpContext context)
{
ArgumentNullException.ThrowIfNull(context);
var projectClaim = context.User.FindFirstValue(StellaOpsClaimTypes.Project);
if (!string.IsNullOrWhiteSpace(projectClaim))
return projectClaim.Trim();
if (TryReadHeader(context, ProjectHeader, out var project))
return project;
return null;
}
/// <summary>
/// Attempts to parse the resolved tenant ID as a <see cref="Guid"/>.
/// Useful for modules that use GUID-typed tenant identifiers in their repositories.
/// </summary>
public static bool TryResolveTenantGuid(
HttpContext context,
out Guid tenantGuid,
out string? error)
{
tenantGuid = Guid.Empty;
if (!TryResolveTenantId(context, out var tenantId, out error))
return false;
if (!Guid.TryParse(tenantId, out tenantGuid))
{
error = "tenant_invalid_format";
return false;
}
return true;
}
// ── Core resolution ───────────────────────────────────────────────
private static bool TryResolveTenant(
HttpContext context,
out string tenantId,
out TenantSource source,
out string? error)
{
tenantId = string.Empty;
source = TenantSource.Unknown;
error = null;
// 1. Claims (highest priority)
var claimTenant = NormalizeTenant(
context.User.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? context.User.FindFirstValue(LegacyTenantClaim));
// 2. Headers (fallback)
var canonicalHeader = ReadTenantHeader(context, StellaOpsHttpHeaderNames.Tenant);
var legacyHeader = ReadTenantHeader(context, LegacyTenantHeader);
var alternateHeader = ReadTenantHeader(context, AlternateTenantHeader);
// Detect header conflicts
if (HasConflicts(canonicalHeader, legacyHeader, alternateHeader))
{
error = "tenant_conflict";
return false;
}
var headerTenant = canonicalHeader ?? legacyHeader ?? alternateHeader;
var headerSource = canonicalHeader is not null ? TenantSource.CanonicalHeader
: legacyHeader is not null ? TenantSource.LegacyHeader
: alternateHeader is not null ? TenantSource.LegacyHeader
: TenantSource.Unknown;
// Claim wins if available
if (!string.IsNullOrWhiteSpace(claimTenant))
{
// Detect claim-header mismatch
if (!string.IsNullOrWhiteSpace(headerTenant)
&& !string.Equals(claimTenant, headerTenant, StringComparison.Ordinal))
{
error = "tenant_conflict";
return false;
}
tenantId = claimTenant;
source = TenantSource.Claim;
return true;
}
// Header fallback
if (!string.IsNullOrWhiteSpace(headerTenant))
{
tenantId = headerTenant;
source = headerSource;
return true;
}
error = "tenant_missing";
return false;
}
private static bool HasConflicts(params string?[] candidates)
{
string? baseline = null;
foreach (var candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
continue;
if (baseline is null)
{
baseline = candidate;
continue;
}
if (!string.Equals(baseline, candidate, StringComparison.Ordinal))
return true;
}
return false;
}
private static string? ReadTenantHeader(HttpContext context, string headerName)
{
return TryReadHeader(context, headerName, out var value)
? NormalizeTenant(value)
: null;
}
private static bool TryReadHeader(HttpContext context, string headerName, out string value)
{
value = string.Empty;
if (!context.Request.Headers.TryGetValue(headerName, out var values))
return false;
var raw = values.ToString();
if (string.IsNullOrWhiteSpace(raw))
return false;
value = raw.Trim();
return true;
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}

View File

@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Auth.ServerIntegration.Tenancy;
/// <summary>
/// Extension methods for registering the unified StellaOps tenant infrastructure.
/// </summary>
public static class StellaOpsTenantServiceCollectionExtensions
{
/// <summary>
/// Registers the <see cref="IStellaOpsTenantAccessor"/> in the DI container.
/// Call <see cref="UseStellaOpsTenantMiddleware"/> to activate the middleware.
/// </summary>
public static IServiceCollection AddStellaOpsTenantServices(this IServiceCollection services)
{
services.TryAddSingleton<IStellaOpsTenantAccessor, StellaOpsTenantAccessor>();
return services;
}
/// <summary>
/// Adds the <see cref="StellaOpsTenantMiddleware"/> to the pipeline.
/// Must be placed after authentication/authorization middleware.
/// </summary>
public static IApplicationBuilder UseStellaOpsTenantMiddleware(this IApplicationBuilder app)
{
return app.UseMiddleware<StellaOpsTenantMiddleware>();
}
/// <summary>
/// Adds a <see cref="StellaOpsTenantEndpointFilter"/> that rejects requests without
/// a resolved tenant context with HTTP 400.
/// Apply to route groups that require tenant scoping.
/// </summary>
/// <example>
/// <code>
/// var group = app.MapGroup("/api/profiles").RequireTenant();
/// </code>
/// </example>
public static RouteGroupBuilder RequireTenant(this RouteGroupBuilder builder)
{
builder.AddEndpointFilter<StellaOpsTenantEndpointFilter>();
return builder;
}
/// <summary>
/// Adds a <see cref="StellaOpsTenantEndpointFilter"/> that rejects requests without
/// a resolved tenant context with HTTP 400.
/// Apply to individual route handlers.
/// </summary>
public static RouteHandlerBuilder RequireTenant(this RouteHandlerBuilder builder)
{
builder.AddEndpointFilter<StellaOpsTenantEndpointFilter>();
return builder;
}
}

View File

@@ -58,6 +58,47 @@ public sealed class LdapClientProvisioningStoreTests
Assert.Single(auditStore.Records);
}
[Fact]
public async Task CreateOrUpdateAsync_NormalizesTenantAssignments()
{
var clientStore = new TrackingClientStore();
var revocationStore = new TrackingRevocationStore();
var fakeConnection = new FakeLdapConnection();
var options = CreateOptions();
var optionsMonitor = new TestOptionsMonitor<LdapPluginOptions>(options);
var auditStore = new TestAirgapAuditStore();
var store = new LdapClientProvisioningStore(
"ldap",
clientStore,
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
auditStore,
timeProvider,
NullLogger<LdapClientProvisioningStore>.Instance);
var registration = new AuthorityClientRegistration(
clientId: "svc-tenant-multi",
confidential: false,
displayName: "Tenant Multi Client",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "jobs:read" },
tenant: null,
properties: new Dictionary<string, string?>
{
[AuthorityClientMetadataKeys.Tenants] = " tenant-bravo tenant-alpha tenant-bravo "
});
var result = await store.CreateOrUpdateAsync(registration, TestContext.Current.CancellationToken);
Assert.True(result.Succeeded);
Assert.True(clientStore.Documents.TryGetValue("svc-tenant-multi", out var document));
Assert.NotNull(document);
Assert.Equal("tenant-alpha tenant-bravo", document!.Properties[AuthorityClientMetadataKeys.Tenants]);
Assert.False(document.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant));
}
[Fact]
public async Task DeleteAsync_RemovesClientAndLogsRevocation()
{
@@ -171,6 +212,19 @@ public sealed class LdapClientProvisioningStoreTests
return ValueTask.FromResult(document);
}
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
var take = limit <= 0 ? 500 : limit;
var skip = offset < 0 ? 0 : offset;
var page = Documents.Values
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.Skip(skip)
.Take(take)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(page);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;

View File

@@ -275,11 +275,36 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
}
var tenant = NormalizeTenant(registration.Tenant);
var normalizedTenants = NormalizeTenants(
registration.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenants, out var tenantAssignments) ? tenantAssignments : null,
tenant);
if (normalizedTenants.Count > 0)
{
document.Properties[AuthorityClientMetadataKeys.Tenants] = string.Join(" ", normalizedTenants);
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenants);
}
if (!string.IsNullOrWhiteSpace(tenant))
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = tenant;
}
else if (normalizedTenants.Count == 1)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenants[0];
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
}
document.Properties[AuthorityClientMetadataKeys.Project] = registration.Project ?? StellaOpsTenancyDefaults.AnyProject;
@@ -362,6 +387,34 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static IReadOnlyList<string> NormalizeTenants(string? rawTenants, string? scalarTenant)
{
var values = new List<string>();
if (!string.IsNullOrWhiteSpace(rawTenants))
{
values.AddRange(rawTenants.Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
if (!string.IsNullOrWhiteSpace(scalarTenant))
{
values.Add(scalarTenant);
}
if (values.Count == 0)
{
return Array.Empty<string>();
}
return values
.Select(NormalizeTenant)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
}
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)

View File

@@ -104,6 +104,35 @@ public class StandardClientProvisioningStoreTests
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateOrUpdateAsync_NormalizesTenantAssignments()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "tenant-multi-client",
confidential: false,
displayName: "Tenant Multi Client",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "jobs:read" },
tenant: null,
properties: new Dictionary<string, string?>
{
[AuthorityClientMetadataKeys.Tenants] = " tenant-bravo tenant-alpha tenant-bravo "
});
await provisioning.CreateOrUpdateAsync(registration, TestContext.Current.CancellationToken);
Assert.True(store.Documents.TryGetValue("tenant-multi-client", out var document));
Assert.NotNull(document);
Assert.Equal("tenant-alpha tenant-bravo", document!.Properties[AuthorityClientMetadataKeys.Tenants]);
Assert.False(document.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CreateOrUpdateAsync_MapsCertificateBindings()
@@ -191,6 +220,19 @@ public class StandardClientProvisioningStoreTests
return ValueTask.FromResult(document);
}
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
var take = limit <= 0 ? 500 : limit;
var skip = offset < 0 ? 0 : offset;
var page = Documents.Values
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.Skip(skip)
.Take(take)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(page);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;

View File

@@ -322,6 +322,20 @@ internal sealed class InMemoryClientStore : IAuthorityClientStore
return ValueTask.FromResult(document);
}
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
var take = limit <= 0 ? 500 : limit;
var skip = offset < 0 ? 0 : offset;
var page = clients.Values
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.Skip(skip)
.Take(take)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(page);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;

View File

@@ -72,10 +72,27 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
}
var normalizedTenant = NormalizeTenant(registration.Tenant);
var normalizedTenants = NormalizeTenants(
registration.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenants, out var tenantAssignments) ? tenantAssignments : null,
normalizedTenant);
if (normalizedTenants.Count > 0)
{
document.Properties[AuthorityClientMetadataKeys.Tenants] = string.Join(" ", normalizedTenants);
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenants);
}
if (normalizedTenant is not null)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant;
}
else if (normalizedTenants.Count == 1)
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenants[0];
}
else
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
@@ -205,6 +222,34 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static IReadOnlyList<string> NormalizeTenants(string? rawTenants, string? scalarTenant)
{
var values = new List<string>();
if (!string.IsNullOrWhiteSpace(rawTenants))
{
values.AddRange(rawTenants.Split([' ', ',', ';', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
if (!string.IsNullOrWhiteSpace(scalarTenant))
{
values.Add(scalarTenant);
}
if (values.Count == 0)
{
return Array.Empty<string>();
}
return values
.Select(NormalizeTenant)
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
}
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)

View File

@@ -12,6 +12,7 @@ public static class AuthorityClientMetadataKeys
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";
public const string Tenant = "tenant";
public const string Tenants = "tenants";
public const string Project = "project";
public const string ServiceIdentity = "serviceIdentity";
public const string RequiresAirGapSealConfirmation = "requiresAirgapSealConfirmation";

View File

@@ -19,8 +19,11 @@ using OpenIddict.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.Console.Admin;
using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Persistence.Sessions;
using StellaOps.Cryptography.Audit;
using Xunit;
@@ -158,10 +161,256 @@ public sealed class ConsoleAdminEndpointsTests
Assert.Contains(payload!.Users, static user => user.Username == "legacy-api-user");
}
[Fact]
public async Task CreateClient_WithMultiTenantAssignments_PersistsNormalizedAssignments()
{
var now = new DateTimeOffset(2026, 2, 20, 15, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var createResponse = await client.PostAsJsonAsync(
"/console/admin/clients",
new
{
clientId = "svc-alpha",
displayName = "Service Alpha",
grantTypes = new[] { "client_credentials", "client_credentials" },
scopes = new[] { "platform:read", "scanner:read" },
tenant = "tenant-alpha",
tenants = new[] { "tenant-bravo", "tenant-alpha" },
requireClientSecret = false
});
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<ClientSummary>();
Assert.NotNull(created);
Assert.Equal("svc-alpha", created!.ClientId);
Assert.Equal("tenant-alpha", created.DefaultTenant);
Assert.Equal(new[] { "tenant-alpha", "tenant-bravo" }, created.Tenants);
Assert.Equal(new[] { "client_credentials" }, created.AllowedGrantTypes);
var createEvent = Assert.Single(sink.Events.Where(record => record.EventType == "authority.admin.clients.create"));
Assert.Equal("tenant-alpha tenant-bravo", GetPropertyValue(createEvent, "client.tenants.after"));
var listResponse = await client.GetAsync("/console/admin/clients");
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listed = await listResponse.Content.ReadFromJsonAsync<ClientListPayload>();
Assert.NotNull(listed);
Assert.Contains(listed!.Clients, static result => result.ClientId == "svc-alpha");
}
[Fact]
public async Task UpdateClient_UpdatesTenantAssignmentsAndDefaultTenant()
{
var now = new DateTimeOffset(2026, 2, 20, 16, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await clients.UpsertAsync(
new AuthorityClientDocument
{
ClientId = "svc-update",
DisplayName = "Original",
Enabled = true,
AllowedGrantTypes = new List<string> { "client_credentials" },
AllowedScopes = new List<string> { "platform:read" },
Properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["tenant"] = "tenant-alpha",
["tenants"] = "tenant-alpha tenant-bravo",
["allowed_grant_types"] = "client_credentials",
["allowed_scopes"] = "platform:read"
},
CreatedAt = now.AddHours(-1),
UpdatedAt = now.AddHours(-1)
},
CancellationToken.None);
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var updateResponse = await client.PatchAsJsonAsync(
"/console/admin/clients/svc-update",
new
{
displayName = "Updated Name",
tenants = new[] { "tenant-bravo", "tenant-charlie" },
tenant = "tenant-bravo"
});
Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
var updated = await updateResponse.Content.ReadFromJsonAsync<ClientSummary>();
Assert.NotNull(updated);
Assert.Equal("Updated Name", updated!.DisplayName);
Assert.Equal("tenant-bravo", updated.DefaultTenant);
Assert.Equal(new[] { "tenant-bravo", "tenant-charlie" }, updated.Tenants);
var updateEvent = Assert.Single(sink.Events.Where(record => record.EventType == "authority.admin.clients.update"));
Assert.Equal("tenant-alpha tenant-bravo", GetPropertyValue(updateEvent, "client.tenants.before"));
Assert.Equal("tenant-bravo tenant-charlie", GetPropertyValue(updateEvent, "client.tenants.after"));
}
[Fact]
public async Task CreateClient_RejectsDuplicateTenantAssignments()
{
var now = new DateTimeOffset(2026, 2, 20, 17, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var createResponse = await client.PostAsJsonAsync(
"/console/admin/clients",
new
{
clientId = "svc-duplicate",
grantTypes = new[] { "client_credentials" },
scopes = new[] { "platform:read" },
tenants = new[] { "tenant-alpha", "TENANT-ALPHA" },
requireClientSecret = false
});
Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
var payload = await createResponse.Content.ReadFromJsonAsync<ErrorPayload>();
Assert.NotNull(payload);
Assert.Equal("duplicate_tenant_assignment", payload!.Error);
}
[Fact]
public async Task CreateClient_RejectsMissingTenantAssignments()
{
var now = new DateTimeOffset(2026, 2, 20, 17, 30, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var createResponse = await client.PostAsJsonAsync(
"/console/admin/clients",
new
{
clientId = "svc-missing-tenants",
grantTypes = new[] { "client_credentials" },
scopes = new[] { "platform:read" },
tenants = Array.Empty<string>(),
requireClientSecret = false
});
Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
var payload = await createResponse.Content.ReadFromJsonAsync<ErrorPayload>();
Assert.NotNull(payload);
Assert.Equal("tenant_assignment_required", payload!.Error);
}
[Fact]
public async Task UpdateClient_RejectsInvalidTenantIdentifier()
{
var now = new DateTimeOffset(2026, 2, 20, 18, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(now);
var sink = new RecordingAuthEventSink();
var users = new InMemoryUserRepository();
var clients = new InMemoryClientStore();
await clients.UpsertAsync(
new AuthorityClientDocument
{
ClientId = "svc-invalid-update",
DisplayName = "Original",
Enabled = true,
AllowedGrantTypes = new List<string> { "client_credentials" },
AllowedScopes = new List<string> { "platform:read" },
Properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["tenant"] = "tenant-alpha",
["tenants"] = "tenant-alpha tenant-bravo",
["allowed_grant_types"] = "client_credentials",
["allowed_scopes"] = "platform:read"
},
CreatedAt = now.AddHours(-1),
UpdatedAt = now.AddHours(-1)
},
CancellationToken.None);
await using var app = await CreateApplicationAsync(timeProvider, sink, users, clients);
var principalAccessor = app.Services.GetRequiredService<AdminTestPrincipalAccessor>();
principalAccessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiAdmin, StellaOpsScopes.AuthorityClientsRead, StellaOpsScopes.AuthorityClientsWrite },
expiresAt: now.AddMinutes(10));
using var client = CreateTestClient(app);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(AdminAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var updateResponse = await client.PatchAsJsonAsync(
"/console/admin/clients/svc-invalid-update",
new
{
tenants = new[] { "tenant-alpha", "Tenant Invalid" },
tenant = "tenant-alpha"
});
Assert.Equal(HttpStatusCode.BadRequest, updateResponse.StatusCode);
var payload = await updateResponse.Content.ReadFromJsonAsync<ErrorPayload>();
Assert.NotNull(payload);
Assert.Equal("invalid_tenant_assignment", payload!.Error);
}
private static async Task<WebApplication> CreateApplicationAsync(
FakeTimeProvider timeProvider,
RecordingAuthEventSink sink,
IUserRepository userRepository)
IUserRepository userRepository,
IAuthorityClientStore? clientStore = null)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
@@ -173,6 +422,7 @@ public sealed class ConsoleAdminEndpointsTests
builder.Services.AddSingleton<TimeProvider>(timeProvider);
builder.Services.AddSingleton<IAuthEventSink>(sink);
builder.Services.AddSingleton(userRepository);
builder.Services.AddSingleton<IAuthorityClientStore>(clientStore ?? new InMemoryClientStore());
builder.Services.AddSingleton<AdminTestPrincipalAccessor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
@@ -232,10 +482,28 @@ public sealed class ConsoleAdminEndpointsTests
return server.CreateClient();
}
private static string? GetPropertyValue(AuthEventRecord record, string propertyName)
{
return record.Properties
.FirstOrDefault(property => string.Equals(property.Name, propertyName, StringComparison.Ordinal))
?.Value.Value;
}
private sealed class RecordingAuthEventSink : IAuthEventSink
{
private readonly List<AuthEventRecord> events = new();
public IReadOnlyList<AuthEventRecord> Events => events;
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
{
lock (events)
{
events.Add(record);
}
return ValueTask.CompletedTask;
}
}
private sealed class AdminTestPrincipalAccessor
@@ -424,6 +692,17 @@ public sealed class ConsoleAdminEndpointsTests
}
private sealed record UserListPayload(IReadOnlyList<UserSummary> Users, int Count);
private sealed record ClientListPayload(IReadOnlyList<ClientSummary> Clients, int Count, string SelectedTenant);
private sealed record ClientSummary(
string ClientId,
string DisplayName,
bool Enabled,
string? DefaultTenant,
IReadOnlyList<string> Tenants,
IReadOnlyList<string> AllowedGrantTypes,
IReadOnlyList<string> AllowedScopes,
DateTimeOffset UpdatedAt);
private sealed record ErrorPayload(string Error, string? Message);
private sealed record UserSummary(
string Id,
@@ -434,4 +713,53 @@ public sealed class ConsoleAdminEndpointsTests
string Status,
DateTimeOffset CreatedAt,
DateTimeOffset? LastLoginAt);
private sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly object sync = new();
private readonly Dictionary<string, AuthorityClientDocument> documents = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
lock (sync)
{
documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult<AuthorityClientDocument?>(document);
}
}
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
lock (sync)
{
var take = limit <= 0 ? 500 : limit;
var skip = offset < 0 ? 0 : offset;
var results = documents.Values
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.Skip(skip)
.Take(take)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(results);
}
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
lock (sync)
{
documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
lock (sync)
{
var removed = documents.Remove(clientId);
return ValueTask.FromResult(removed);
}
}
}
}

View File

@@ -53,6 +53,7 @@ public sealed class ConsoleEndpointsTests
var tenants = json.RootElement.GetProperty("tenants");
Assert.Equal(1, tenants.GetArrayLength());
Assert.Equal("tenant-default", tenants[0].GetProperty("id").GetString());
Assert.Equal("tenant-default", json.RootElement.GetProperty("selectedTenant").GetString());
var events = sink.Events;
var authorizeEvent = Assert.Single(events, evt => evt.EventType == "authority.resource.authorize");
@@ -60,12 +61,12 @@ public sealed class ConsoleEndpointsTests
var consoleEvent = Assert.Single(events, evt => evt.EventType == "authority.console.tenants.read");
Assert.Equal(AuthEventOutcome.Success, consoleEvent.Outcome);
Assert.Contains("tenant.resolved", consoleEvent.Properties.Select(property => property.Name));
Assert.Contains("tenant.selected", consoleEvent.Properties.Select(property => property.Name));
Assert.Equal(2, events.Count);
}
[Fact]
public async Task Tenants_ReturnsBadRequest_WhenHeaderMissing()
public async Task Tenants_UsesClaimTenant_WhenHeaderMissing()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
@@ -81,11 +82,50 @@ public sealed class ConsoleEndpointsTests
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
var response = await client.GetAsync("/console/tenants");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var authEvent = Assert.Single(sink.Events);
Assert.Equal("authority.resource.authorize", authEvent.EventType);
Assert.Equal(AuthEventOutcome.Success, authEvent.Outcome);
Assert.DoesNotContain(sink.Events, evt => evt.EventType.StartsWith("authority.console.", System.StringComparison.Ordinal));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("tenant-default", json.RootElement.GetProperty("selectedTenant").GetString());
Assert.Equal(1, json.RootElement.GetProperty("tenants").GetArrayLength());
var events = sink.Events;
Assert.Contains(events, evt => evt.EventType == "authority.resource.authorize" && evt.Outcome == AuthEventOutcome.Success);
Assert.Contains(events, evt => evt.EventType == "authority.console.tenants.read" && evt.Outcome == AuthEventOutcome.Success);
Assert.Equal(2, events.Count);
}
[Fact]
public async Task Tenants_ReturnsAllowedTenantAssignments_WithSelectedMarker()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(
timeProvider,
sink,
new AuthorityTenantView("tenant-alpha", "Tenant Alpha", "active", "shared", Array.Empty<string>(), Array.Empty<string>()),
new AuthorityTenantView("tenant-bravo", "Tenant Bravo", "active", "shared", Array.Empty<string>(), Array.Empty<string>()),
new AuthorityTenantView("tenant-charlie", "Tenant Charlie", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-alpha",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(5),
allowedTenants: new[] { "tenant-alpha", "tenant-bravo" });
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-alpha");
var response = await client.GetAsync("/console/tenants");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var tenants = json.RootElement.GetProperty("tenants");
Assert.Equal(2, tenants.GetArrayLength());
Assert.Equal("tenant-alpha", tenants[0].GetProperty("id").GetString());
Assert.Equal("tenant-bravo", tenants[1].GetProperty("id").GetString());
Assert.Equal("tenant-alpha", json.RootElement.GetProperty("selectedTenant").GetString());
}
[Fact]
@@ -530,7 +570,8 @@ public sealed class ConsoleEndpointsTests
string? subject = null,
string? username = null,
string? displayName = null,
string? tokenId = null)
string? tokenId = null,
IReadOnlyCollection<string>? allowedTenants = null)
{
var claims = new List<Claim>
{
@@ -570,6 +611,11 @@ public sealed class ConsoleEndpointsTests
claims.Add(new Claim(StellaOpsClaimTypes.TokenId, tokenId));
}
if (allowedTenants is { Count: > 0 })
{
claims.Add(new Claim(StellaOpsClaimTypes.AllowedTenants, string.Join(' ', allowedTenants)));
}
var identity = new ClaimsIdentity(claims, TestAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(identity);
}

View File

@@ -149,6 +149,118 @@ public class ClientCredentialsHandlersTests
Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]);
}
[Fact]
public async Task ValidateClientCredentials_SelectsRequestedTenant_WhenAssigned()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "Tenant-Bravo");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var selectedTenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-bravo", selectedTenant);
var allowedTenants = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientAllowedTenantsProperty]);
Assert.Equal(new[] { "tenant-alpha", "tenant-bravo" }, allowedTenants);
}
[Fact]
public async Task ValidateClientCredentials_Rejects_WhenRequestedTenantNotAssigned()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "tenant-charlie");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Requested tenant is not assigned to this client.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_Rejects_WhenTenantSelectionIsAmbiguous()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Tenant selection is required for this client.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_Allows_NewIngestionScopes()
{
@@ -3221,6 +3333,60 @@ public class ClientCredentialsHandlersTests
Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope);
}
[Fact]
public async Task HandleClientCredentials_EmitsAllowedTenantsClaim()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read",
tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var descriptor = CreateDescriptor(clientDocument);
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor);
var tokenStore = new TestTokenStore();
var sessionAccessor = new NullSessionAccessor();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var validateHandler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
metadataAccessor,
new TestServiceAccountStore(),
tokenStore,
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
TestHelpers.CreateAuthorityOptions(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "tenant-alpha");
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validateHandler.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected, $"Rejected: {validateContext.Error} - {validateContext.ErrorDescription}");
var handler = new HandleClientCredentialsHandler(
registry,
tokenStore,
sessionAccessor,
metadataAccessor,
TimeProvider.System,
TestInstruments.ActivitySource,
NullLogger<HandleClientCredentialsHandler>.Instance);
var context = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handler.HandleAsync(context);
var principal = context.Principal ?? throw new InvalidOperationException("Principal missing");
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("tenant-alpha tenant-bravo", principal.FindFirstValue(StellaOpsClaimTypes.AllowedTenants));
}
[Fact]
public async Task HandleClientCredentials_PersistsServiceAccountMetadata()
{
@@ -3736,7 +3902,61 @@ public class TokenValidationHandlersTests
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
Assert.Equal("The token tenant does not match the registered client tenant.", context.ErrorDescription);
Assert.Equal("The token tenant does not match the registered client tenant assignments.", context.ErrorDescription);
}
[Fact]
public async Task ValidateAccessTokenHandler_Rejects_WhenTenantOutsideMultiTenantAssignments()
{
var clientDocument = CreateClient(tenant: null);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var tokenStore = new TestTokenStore
{
Inserted = new AuthorityTokenDocument
{
TokenId = "token-tenant",
Status = "valid",
ClientId = clientDocument.ClientId
}
};
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var auditSink = new TestAuthEventSink();
var sessionAccessor = new NullSessionAccessor();
var handler = new ValidateAccessTokenHandler(
tokenStore,
sessionAccessor,
new TestClientStore(clientDocument),
CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)),
metadataAccessor,
auditSink,
TimeProvider.System,
TestInstruments.ActivitySource,
TestInstruments.Meter,
NullLogger<ValidateAccessTokenHandler>.Instance);
var transaction = new OpenIddictServerTransaction
{
Options = new OpenIddictServerOptions(),
EndpointType = OpenIddictServerEndpointType.Token,
Request = new OpenIddictRequest()
};
var principal = CreatePrincipal(clientDocument.ClientId, "token-tenant", ResolveProvider(clientDocument));
principal.Identities.First().AddClaim(new Claim(StellaOpsClaimTypes.Tenant, "tenant-charlie"));
var context = new OpenIddictServerEvents.ValidateTokenContext(transaction)
{
Principal = principal,
TokenId = "token-tenant"
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error);
Assert.Equal("The token tenant does not match the registered client tenant assignments.", context.ErrorDescription);
}
[Fact]
@@ -4109,6 +4329,19 @@ internal sealed class TestClientStore : IAuthorityClientStore
return ValueTask.FromResult(document);
}
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
var take = limit <= 0 ? 500 : limit;
var skip = offset < 0 ? 0 : offset;
var page = clients.Values
.OrderBy(client => client.ClientId, StringComparer.Ordinal)
.Skip(skip)
.Take(take)
.ToList();
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(page);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients[document.ClientId] = document;

View File

@@ -62,6 +62,110 @@ public class PasswordGrantHandlersTests
Assert.Equal("tenant-alpha", metadata?.Tenant);
}
[Fact]
public async Task ValidatePasswordGrant_SelectsRequestedTenant_WhenAssigned()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument("jobs:trigger");
clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
auditContextAccessor: auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "tenant-bravo");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var selectedTenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-bravo", selectedTenant);
var allowedTenants = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientAllowedTenantsProperty]);
Assert.Equal(new[] { "tenant-alpha", "tenant-bravo" }, allowedTenants);
}
[Fact]
public async Task ValidatePasswordGrant_Rejects_WhenTenantSelectionIsAmbiguous()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument("jobs:trigger");
clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
auditContextAccessor: auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Tenant selection is required for this client.", context.ErrorDescription);
}
[Fact]
public async Task HandlePasswordGrant_EmitsAllowedTenantsClaim()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument("jobs:trigger");
clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
clientDocument.Properties[AuthorityClientMetadataKeys.Tenants] = "tenant-alpha tenant-bravo";
var clientStore = new StubClientStore(clientDocument);
var validate = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
auditContextAccessor: auditContextAccessor);
var handle = new HandlePasswordGrantHandler(
registry,
clientStore,
TestActivitySource,
sink,
metadataAccessor,
TimeProvider.System,
NullLogger<HandlePasswordGrantHandler>.Instance,
auditContextAccessor);
var transaction = CreatePasswordTransaction("alice", "Password1!", "jobs:trigger");
SetParameter(transaction, AuthorityOpenIddictConstants.TenantParameterName, "tenant-alpha");
await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction));
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handle.HandleAsync(handleContext);
var principal = handleContext.Principal ?? throw new InvalidOperationException("Principal missing.");
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("tenant-alpha tenant-bravo", principal.FindFirstValue(StellaOpsClaimTypes.AllowedTenants));
}
[Fact]
public async Task ValidatePasswordGrant_Rejects_WhenSealedEvidenceMissing()
{
@@ -948,6 +1052,16 @@ public class PasswordGrantHandlersTests
return ValueTask.FromResult(result);
}
public ValueTask<IReadOnlyList<AuthorityClientDocument>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default, IClientSessionHandle? session = null)
{
if (document is null)
{
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(Array.Empty<AuthorityClientDocument>());
}
return ValueTask.FromResult<IReadOnlyList<AuthorityClientDocument>>(new[] { document });
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
this.document = document ?? throw new ArgumentNullException(nameof(document));

View File

@@ -205,6 +205,16 @@ public sealed class PostgresAdapterTests
public Task<ClientEntity?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken = default)
=> Task.FromResult<ClientEntity?>(LastUpsert);
public Task<IReadOnlyList<ClientEntity>> ListAsync(int limit = 500, int offset = 0, CancellationToken cancellationToken = default)
{
if (LastUpsert is null)
{
return Task.FromResult<IReadOnlyList<ClientEntity>>(Array.Empty<ClientEntity>());
}
return Task.FromResult<IReadOnlyList<ClientEntity>>(new[] { LastUpsert });
}
public Task UpsertAsync(ClientEntity entity, CancellationToken cancellationToken = default)
{
LastUpsert = entity;

View File

@@ -38,7 +38,7 @@ internal static class AirgapAuditEndpointExtensions
ArgumentNullException.ThrowIfNull(app);
var group = app.MapGroup("/authority/audit/airgap")
.RequireAuthorization()
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AirgapStatusRead))
.WithTags("AuthorityAirgapAudit");
group.AddEndpointFilter(new TenantHeaderFilter());

Some files were not shown because too many files have changed in this diff Show More