consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -3,5 +3,5 @@
|
||||
|
||||
"advisoryai.validation.q_required": "q ist erforderlich.",
|
||||
"advisoryai.validation.q_max_512": "q darf maximal 512 Zeichen lang sein.",
|
||||
"advisoryai.validation.tenant_required": "Tenant-Kontext ist erforderlich."
|
||||
"advisoryai.validation.tenant_required": "Mandantenkontext ist erforderlich."
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -319,7 +319,7 @@
|
||||
"sha256": "1be0ec1ce56cd616259095ccbfce106121c75e4d40621fbe2b1f022ff76072fe"
|
||||
},
|
||||
{
|
||||
"path": "docs/modules/cli/guides/commands/orchestrator.md",
|
||||
"path": "docs/modules/cli/guides/commands/jobengine.md",
|
||||
"sha256": "5e74b92d1615f8300765ed156ed709c70645ad95f67b22f43bc47cc10589de30"
|
||||
},
|
||||
{
|
||||
@@ -1583,7 +1583,7 @@
|
||||
"sha256": "fa50d45dc2b02d2f89a12d801d38c83e3d89070caa318575123da54db9ea48c5"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/orchestrator-runbook.md",
|
||||
"path": "docs/operations/jobengine-runbook.md",
|
||||
"sha256": "64af4dd5bda8eebb2e9323e2bf7ef8308b0dd2e2ba33a11bae20222f4945c247"
|
||||
},
|
||||
{
|
||||
@@ -1715,23 +1715,23 @@
|
||||
"sha256": "a1a31a4c8baf091f67e3a5b043118ee93a05c5314fb3eb1c3b6fd14e53c19d96"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-evidence-missing.md",
|
||||
"path": "docs/operations/runbooks/jobengine-evidence-missing.md",
|
||||
"sha256": "a180683a2de5a3fe60ae6477c1de7c5b36e37ad189c06373109c4afebe58b6da"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-gate-timeout.md",
|
||||
"path": "docs/operations/runbooks/jobengine-gate-timeout.md",
|
||||
"sha256": "3695ac55be0a165a4ca2052fa328f0dc312e20194cafeda8b3a9bda7ac599e76"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-promotion-stuck.md",
|
||||
"path": "docs/operations/runbooks/jobengine-promotion-stuck.md",
|
||||
"sha256": "bd5f464a941808d9bdb9a80d488c2885b3a9282fe4f7b05402e2b1766c22d276"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-quota-exceeded.md",
|
||||
"path": "docs/operations/runbooks/jobengine-quota-exceeded.md",
|
||||
"sha256": "1cfcaef596e6d75e54545c1e41bc310f5858f67f0a0800a0087ba92a7471d567"
|
||||
},
|
||||
{
|
||||
"path": "docs/operations/runbooks/orchestrator-rollback-failed.md",
|
||||
"path": "docs/operations/runbooks/jobengine-rollback-failed.md",
|
||||
"sha256": "35abf5027af2e64060137fa9dbb23f500002c57ecea8ec762fa6fce1d4711073"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\OpsMemory\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
|
||||
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-006) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
|
||||
@@ -48,7 +48,7 @@ internal sealed class PlatformCatalogIngestionAdapter : ISearchIngestionAdapter
|
||||
EntityType: "pack",
|
||||
Title: "Pack: Offline Kit",
|
||||
Summary: "Offline kit export bundle",
|
||||
Source: "orchestrator",
|
||||
Source: "jobengine",
|
||||
Route: "/packs/offline-kit"),
|
||||
new PlatformCatalogEntry(
|
||||
EntityId: "tenant-acme",
|
||||
|
||||
Binary file not shown.
@@ -16,8 +16,7 @@ using StellaOps.OpsMemory.WebService.Security;
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add PostgreSQL data source
|
||||
var connectionString = builder.Configuration.GetConnectionString("OpsMemory")
|
||||
?? "Host=localhost;Port=5432;Database=stellaops;Username=stellaops;Password=stellaops";
|
||||
var connectionString = ResolveOpsMemoryConnectionString(builder);
|
||||
builder.Services.AddSingleton<NpgsqlDataSource>(_ => NpgsqlDataSource.Create(connectionString));
|
||||
|
||||
// Add determinism abstractions (TimeProvider + IGuidProvider for endpoint parameter binding)
|
||||
@@ -86,3 +85,26 @@ app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||
await app.LoadTranslationsAsync();
|
||||
app.Run();
|
||||
|
||||
static string ResolveOpsMemoryConnectionString(WebApplicationBuilder builder)
|
||||
{
|
||||
// Explicit service connection has priority; shared default is the compose-compatible fallback.
|
||||
var configuredConnectionString =
|
||||
builder.Configuration.GetConnectionString("OpsMemory")
|
||||
?? builder.Configuration["ConnectionStrings:OpsMemory"]
|
||||
?? builder.Configuration.GetConnectionString("Default")
|
||||
?? builder.Configuration["ConnectionStrings:Default"];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuredConnectionString))
|
||||
{
|
||||
return configuredConnectionString.Trim();
|
||||
}
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
return "Host=localhost;Port=5432;Database=stellaops;Username=stellaops;Password=stellaops";
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"OpsMemory database connection string is required in non-development environments. Configure ConnectionStrings:OpsMemory or ConnectionStrings:Default.");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| S312-OPSMEMORY-CONNECTION | DONE | Sprint `docs/implplan/SPRINT_20260305_312_DOCS_storage_policy_postgres_rustfs_alignment.md` TASK-312-007: aligned connection resolution with compose defaults (`ConnectionStrings:Default` fallback) and added fail-fast behavior for non-development when DB config is missing. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/StellaOps.OpsMemory.WebService/StellaOps.OpsMemory.WebService.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -14,6 +14,6 @@
|
||||
<PackageReference Include="Npgsql" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Findings\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
|
||||
<ProjectReference Include="..\..\..\Findings\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -24,7 +24,7 @@
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI.Worker\StellaOps.AdvisoryAI.Worker.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Plugin\StellaOps.Plugin.Testing\StellaOps.Plugin.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| S312-OPSMEMORY-VERIFY | DONE | Sprint `docs/implplan/SPRINT_20260305_312_DOCS_storage_policy_postgres_rustfs_alignment.md` verification for TASK-312-007: `dotnet test src/AdvisoryAI/__Tests/StellaOps.OpsMemory.Tests/StellaOps.OpsMemory.Tests.csproj -v minimal` passed (50 tests). |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/OpsMemory/__Tests/StellaOps.OpsMemory.Tests/StellaOps.OpsMemory.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scheduler\__Libraries\StellaOps.Scheduler.Models\StellaOps.Scheduler.Models.csproj" />
|
||||
<ProjectReference Include="..\..\..\JobEngine\StellaOps.Scheduler.__Libraries\StellaOps.Scheduler.Models\StellaOps.Scheduler.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -17,9 +17,9 @@ servers:
|
||||
- url: https://graph.stellaops.local
|
||||
description: Example Graph endpoint
|
||||
x-service: graph
|
||||
- url: https://orchestrator.stellaops.local
|
||||
- url: https://jobengine.stellaops.local
|
||||
description: Example Orchestrator endpoint
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
- url: https://policy.stellaops.local
|
||||
description: Example Policy Engine endpoint
|
||||
x-service: policy
|
||||
@@ -696,13 +696,13 @@ paths:
|
||||
traceId: "5"
|
||||
x-service: graph
|
||||
x-original-path: /healthz
|
||||
/orchestrator/health:
|
||||
/jobengine/health:
|
||||
get:
|
||||
tags:
|
||||
- Health
|
||||
summary: Liveness probe
|
||||
description: Returns OK when Orchestrator is reachable.
|
||||
operationId: orchestratorHealth
|
||||
operationId: jobengineHealth
|
||||
responses:
|
||||
"200":
|
||||
description: Service is up
|
||||
@@ -712,7 +712,7 @@ paths:
|
||||
ok:
|
||||
value:
|
||||
status: ok
|
||||
service: orchestrator
|
||||
service: jobengine
|
||||
timestamp: 2025-11-18T00:00:00Z
|
||||
"503":
|
||||
description: Service unhealthy or dependencies unavailable.
|
||||
@@ -722,18 +722,18 @@ paths:
|
||||
unhealthy:
|
||||
value:
|
||||
status: degraded
|
||||
service: orchestrator
|
||||
service: jobengine
|
||||
reason: scheduler queue unreachable
|
||||
timestamp: 2025-11-18T00:00:00Z
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
x-original-path: /health
|
||||
/orchestrator/healthz:
|
||||
/jobengine/healthz:
|
||||
get:
|
||||
summary: Service health
|
||||
tags:
|
||||
- Meta
|
||||
description: Readiness probe for orchestrator dependencies.
|
||||
operationId: orchestratorHealthz
|
||||
description: Readiness probe for jobengine dependencies.
|
||||
operationId: jobengineHealthz
|
||||
responses:
|
||||
"200":
|
||||
description: Service healthy
|
||||
@@ -746,7 +746,7 @@ paths:
|
||||
summary: Healthy response
|
||||
value:
|
||||
status: ok
|
||||
service: orchestrator
|
||||
service: jobengine
|
||||
"503":
|
||||
description: Service unavailable
|
||||
content:
|
||||
@@ -760,14 +760,14 @@ paths:
|
||||
code: service_unavailable
|
||||
message: outbound queue lag exceeds threshold
|
||||
traceId: "1"
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
x-original-path: /healthz
|
||||
/orchestrator/jobs:
|
||||
/jobengine/jobs:
|
||||
get:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: List jobs
|
||||
operationId: orchestratorListJobs
|
||||
operationId: jobengineListJobs
|
||||
description: Returns jobs for the tenant with optional status filter.
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -791,7 +791,7 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/orchestrator.JobSummary"
|
||||
$ref: "#/components/schemas/jobengine.JobSummary"
|
||||
examples:
|
||||
default:
|
||||
summary: Mixed queues
|
||||
@@ -833,13 +833,13 @@ paths:
|
||||
code: orch.invalid_request
|
||||
message: status must be one of queued,running,failed,completed.
|
||||
traceId: 01JF04ERR1
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
x-original-path: /jobs
|
||||
post:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: Submit a job to the orchestrator queue
|
||||
operationId: orchestratorSubmitJob
|
||||
summary: Submit a job to the jobengine queue
|
||||
operationId: jobengineSubmitJob
|
||||
description: Enqueue a job for asynchronous execution.
|
||||
parameters:
|
||||
- in: header
|
||||
@@ -854,7 +854,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/orchestrator.JobCreateRequest"
|
||||
$ref: "#/components/schemas/jobengine.JobCreateRequest"
|
||||
examples:
|
||||
scanJob:
|
||||
summary: Submit scan job
|
||||
@@ -874,7 +874,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/orchestrator.JobCreateResponse"
|
||||
$ref: "#/components/schemas/jobengine.JobCreateResponse"
|
||||
examples:
|
||||
accepted:
|
||||
summary: Job enqueued
|
||||
@@ -896,14 +896,14 @@ paths:
|
||||
code: orch.invalid_request
|
||||
message: jobType is required.
|
||||
traceId: 01JF04ERR1
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
x-original-path: /jobs
|
||||
/orchestrator/jobs/{jobId}:
|
||||
/jobengine/jobs/{jobId}:
|
||||
get:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: Get job status
|
||||
operationId: orchestratorGetJob
|
||||
operationId: jobengineGetJob
|
||||
description: Fetch the current status of a job by id.
|
||||
parameters:
|
||||
- name: jobId
|
||||
@@ -917,7 +917,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/orchestrator.JobSummary"
|
||||
$ref: "#/components/schemas/jobengine.JobSummary"
|
||||
examples:
|
||||
sample:
|
||||
value:
|
||||
@@ -930,8 +930,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/orchestrator.ErrorEnvelope"
|
||||
x-service: orchestrator
|
||||
$ref: "#/components/schemas/jobengine.ErrorEnvelope"
|
||||
x-service: jobengine
|
||||
x-original-path: /jobs/{jobId}
|
||||
/policy/evaluate:
|
||||
post:
|
||||
@@ -1670,7 +1670,7 @@ components:
|
||||
required:
|
||||
- status
|
||||
- service
|
||||
orchestrator.ErrorEnvelope:
|
||||
jobengine.ErrorEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
@@ -1682,7 +1682,7 @@ components:
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
orchestrator.JobCreateRequest:
|
||||
jobengine.JobCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
@@ -1702,7 +1702,7 @@ components:
|
||||
- high
|
||||
tenant:
|
||||
type: string
|
||||
orchestrator.JobCreateResponse:
|
||||
jobengine.JobCreateResponse:
|
||||
type: object
|
||||
required:
|
||||
- jobId
|
||||
@@ -1717,7 +1717,7 @@ components:
|
||||
enqueuedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
orchestrator.JobSummary:
|
||||
jobengine.JobSummary:
|
||||
type: object
|
||||
required:
|
||||
- jobId
|
||||
|
||||
@@ -15,7 +15,7 @@ tags:
|
||||
- name: Jobs
|
||||
description: Job submission and status APIs
|
||||
servers:
|
||||
- url: https://orchestrator.stellaops.local
|
||||
- url: https://jobengine.stellaops.local
|
||||
description: Example Orchestrator endpoint
|
||||
paths:
|
||||
/health:
|
||||
@@ -24,7 +24,7 @@ paths:
|
||||
- Health
|
||||
summary: Liveness probe
|
||||
description: Returns OK when Orchestrator is reachable.
|
||||
operationId: orchestratorHealth
|
||||
operationId: jobengineHealth
|
||||
responses:
|
||||
'200':
|
||||
description: Service is up
|
||||
@@ -34,7 +34,7 @@ paths:
|
||||
ok:
|
||||
value:
|
||||
status: ok
|
||||
service: orchestrator
|
||||
service: jobengine
|
||||
timestamp: '2025-11-18T00:00:00Z'
|
||||
'503':
|
||||
description: Service unhealthy or dependencies unavailable.
|
||||
@@ -44,7 +44,7 @@ paths:
|
||||
unhealthy:
|
||||
value:
|
||||
status: degraded
|
||||
service: orchestrator
|
||||
service: jobengine
|
||||
reason: scheduler queue unreachable
|
||||
timestamp: '2025-11-18T00:00:00Z'
|
||||
/healthz:
|
||||
@@ -52,8 +52,8 @@ paths:
|
||||
summary: Service health
|
||||
tags:
|
||||
- Meta
|
||||
description: Readiness probe for orchestrator dependencies.
|
||||
operationId: orchestratorHealthz
|
||||
description: Readiness probe for jobengine dependencies.
|
||||
operationId: jobengineHealthz
|
||||
responses:
|
||||
'200':
|
||||
description: Service healthy
|
||||
@@ -66,7 +66,7 @@ paths:
|
||||
summary: Healthy response
|
||||
value:
|
||||
status: ok
|
||||
service: orchestrator
|
||||
service: jobengine
|
||||
'503':
|
||||
description: Service unavailable
|
||||
content:
|
||||
@@ -84,8 +84,8 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: Submit a job to the orchestrator queue
|
||||
operationId: orchestratorSubmitJob
|
||||
summary: Submit a job to the jobengine queue
|
||||
operationId: jobengineSubmitJob
|
||||
description: Enqueue a job for asynchronous execution.
|
||||
parameters:
|
||||
- in: header
|
||||
@@ -146,7 +146,7 @@ paths:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: List jobs
|
||||
operationId: orchestratorListJobs
|
||||
operationId: jobengineListJobs
|
||||
description: Returns jobs for the tenant with optional status filter.
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -217,7 +217,7 @@ paths:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: Get job status
|
||||
operationId: orchestratorGetJob
|
||||
operationId: jobengineGetJob
|
||||
description: Fetch the current status of a job by id.
|
||||
parameters:
|
||||
- name: jobId
|
||||
@@ -17,9 +17,9 @@ servers:
|
||||
- url: https://graph.stellaops.local
|
||||
description: Example Graph endpoint
|
||||
x-service: graph
|
||||
- url: https://orchestrator.stellaops.local
|
||||
- url: https://jobengine.stellaops.local
|
||||
description: Example Orchestrator endpoint
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
- url: https://policy.stellaops.local
|
||||
description: Example Policy Engine endpoint
|
||||
x-service: policy
|
||||
@@ -711,13 +711,13 @@ paths:
|
||||
traceId: "5"
|
||||
x-service: graph
|
||||
x-original-path: /healthz
|
||||
/orchestrator/health:
|
||||
/jobengine/health:
|
||||
get:
|
||||
tags:
|
||||
- Health
|
||||
summary: Liveness probe
|
||||
description: Returns OK when Orchestrator is reachable.
|
||||
operationId: orchestratorHealth
|
||||
operationId: jobengineHealth
|
||||
responses:
|
||||
"200":
|
||||
description: Service is up
|
||||
@@ -727,7 +727,7 @@ paths:
|
||||
ok:
|
||||
value:
|
||||
status: ok
|
||||
service: orchestrator
|
||||
service: jobengine
|
||||
timestamp: 2025-11-18T00:00:00Z
|
||||
"503":
|
||||
description: Service unhealthy or dependencies unavailable.
|
||||
@@ -737,18 +737,18 @@ paths:
|
||||
unhealthy:
|
||||
value:
|
||||
status: degraded
|
||||
service: orchestrator
|
||||
service: jobengine
|
||||
reason: scheduler queue unreachable
|
||||
timestamp: 2025-11-18T00:00:00Z
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
x-original-path: /health
|
||||
/orchestrator/healthz:
|
||||
/jobengine/healthz:
|
||||
get:
|
||||
summary: Service health
|
||||
tags:
|
||||
- Meta
|
||||
description: Readiness probe for orchestrator dependencies.
|
||||
operationId: orchestratorHealthz
|
||||
description: Readiness probe for jobengine dependencies.
|
||||
operationId: jobengineHealthz
|
||||
responses:
|
||||
"200":
|
||||
description: Service healthy
|
||||
@@ -761,7 +761,7 @@ paths:
|
||||
summary: Healthy response
|
||||
value:
|
||||
status: ok
|
||||
service: orchestrator
|
||||
service: jobengine
|
||||
"503":
|
||||
description: Service unavailable
|
||||
content:
|
||||
@@ -775,14 +775,14 @@ paths:
|
||||
code: service_unavailable
|
||||
message: outbound queue lag exceeds threshold
|
||||
traceId: "1"
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
x-original-path: /healthz
|
||||
/orchestrator/jobs:
|
||||
/jobengine/jobs:
|
||||
get:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: List jobs
|
||||
operationId: orchestratorListJobs
|
||||
operationId: jobengineListJobs
|
||||
description: Returns jobs for the tenant with optional status filter.
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -806,7 +806,7 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/orchestrator.JobSummary"
|
||||
$ref: "#/components/schemas/jobengine.JobSummary"
|
||||
examples:
|
||||
default:
|
||||
summary: Mixed queues
|
||||
@@ -848,13 +848,13 @@ paths:
|
||||
code: orch.invalid_request
|
||||
message: status must be one of queued,running,failed,completed.
|
||||
traceId: 01JF04ERR1
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
x-original-path: /jobs
|
||||
post:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: Submit a job to the orchestrator queue
|
||||
operationId: orchestratorSubmitJob
|
||||
summary: Submit a job to the jobengine queue
|
||||
operationId: jobengineSubmitJob
|
||||
description: Enqueue a job for asynchronous execution.
|
||||
parameters:
|
||||
- in: header
|
||||
@@ -869,7 +869,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/orchestrator.JobCreateRequest"
|
||||
$ref: "#/components/schemas/jobengine.JobCreateRequest"
|
||||
examples:
|
||||
scanJob:
|
||||
summary: Submit scan job
|
||||
@@ -889,7 +889,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/orchestrator.JobCreateResponse"
|
||||
$ref: "#/components/schemas/jobengine.JobCreateResponse"
|
||||
examples:
|
||||
accepted:
|
||||
summary: Job enqueued
|
||||
@@ -911,14 +911,14 @@ paths:
|
||||
code: orch.invalid_request
|
||||
message: jobType is required.
|
||||
traceId: 01JF04ERR1
|
||||
x-service: orchestrator
|
||||
x-service: jobengine
|
||||
x-original-path: /jobs
|
||||
/orchestrator/jobs/{jobId}:
|
||||
/jobengine/jobs/{jobId}:
|
||||
get:
|
||||
tags:
|
||||
- Jobs
|
||||
summary: Get job status
|
||||
operationId: orchestratorGetJob
|
||||
operationId: jobengineGetJob
|
||||
description: Fetch the current status of a job by id.
|
||||
parameters:
|
||||
- name: jobId
|
||||
@@ -932,7 +932,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/orchestrator.JobSummary"
|
||||
$ref: "#/components/schemas/jobengine.JobSummary"
|
||||
examples:
|
||||
sample:
|
||||
value:
|
||||
@@ -945,8 +945,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/orchestrator.ErrorEnvelope"
|
||||
x-service: orchestrator
|
||||
$ref: "#/components/schemas/jobengine.ErrorEnvelope"
|
||||
x-service: jobengine
|
||||
x-original-path: /jobs/{jobId}
|
||||
/policy/evaluate:
|
||||
post:
|
||||
@@ -2252,7 +2252,7 @@ components:
|
||||
required:
|
||||
- status
|
||||
- service
|
||||
orchestrator.ErrorEnvelope:
|
||||
jobengine.ErrorEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
@@ -2264,7 +2264,7 @@ components:
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
orchestrator.JobCreateRequest:
|
||||
jobengine.JobCreateRequest:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
@@ -2284,7 +2284,7 @@ components:
|
||||
- high
|
||||
tenant:
|
||||
type: string
|
||||
orchestrator.JobCreateResponse:
|
||||
jobengine.JobCreateResponse:
|
||||
type: object
|
||||
required:
|
||||
- jobId
|
||||
@@ -2299,7 +2299,7 @@ components:
|
||||
enqueuedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
orchestrator.JobSummary:
|
||||
jobengine.JobSummary:
|
||||
type: object
|
||||
required:
|
||||
- jobId
|
||||
|
||||
@@ -16,11 +16,26 @@ Manage the attestation and proof chain infrastructure for StellaOps:
|
||||
- Keep proof chain storage schema current with migrations.
|
||||
|
||||
## Key Components
|
||||
|
||||
### Attestor (transparency logging and verification)
|
||||
- **StellaOps.Attestor**: Main attestation service and REST API endpoints
|
||||
- **StellaOps.Attestor.Envelope**: DSSE envelope handling and serialization
|
||||
- **StellaOps.Attestor.Types**: Core attestation models and schemas
|
||||
- **StellaOps.Attestor.Verify**: Verification engine for signatures and Rekor proofs
|
||||
- **__Libraries**: Shared attestation utilities and storage abstractions
|
||||
- **__Libraries/StellaOps.Attestor.***: Shared attestation utilities and storage abstractions
|
||||
|
||||
### Signer (cryptographic signing -- trust domain co-located, Sprint 204)
|
||||
- **StellaOps.Signer/StellaOps.Signer.Core**: Signing pipeline, predicate types, DSSE statement builder
|
||||
- **StellaOps.Signer/StellaOps.Signer.Infrastructure**: Redis/cache/HTTP infrastructure for signing
|
||||
- **StellaOps.Signer/StellaOps.Signer.WebService**: REST API (`/api/v1/signer/sign/dsse`)
|
||||
- **__Libraries/StellaOps.Signer.KeyManagement**: Key rotation, trust anchors, HSM/KMS bindings (separate DB schema)
|
||||
- **__Libraries/StellaOps.Signer.Keyless**: Fulcio/Sigstore keyless signing support
|
||||
|
||||
### Provenance (attestation library -- trust domain co-located, Sprint 204)
|
||||
- **StellaOps.Provenance.Attestation**: SLSA/DSSE attestation generation library
|
||||
- **StellaOps.Provenance.Attestation.Tool**: Forensic verification CLI tool
|
||||
|
||||
### Tests
|
||||
- **__Tests**: Integration tests with Testcontainers for PostgreSQL
|
||||
|
||||
## Required Reading
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.Security;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -133,24 +136,21 @@ public sealed class DsseVerifier : IDsseVerifier
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(sig.Sig);
|
||||
if (VerifySignature(pae, signatureBytes, publicKeyPem))
|
||||
var verification = VerifySignature(pae, signatureBytes, publicKeyPem);
|
||||
if (verification.IsValid)
|
||||
{
|
||||
verifiedKeyIds.Add(sig.KeyId ?? "unknown");
|
||||
_logger.LogDebug("DSSE signature verified for keyId: {KeyId}", sig.KeyId ?? "unknown");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add($"signature_invalid_{sig.KeyId ?? "unknown"}");
|
||||
issues.Add($"signature_invalid_{sig.KeyId ?? "unknown"}:{verification.ReasonCode}");
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
issues.Add($"signature_invalid_base64_{sig.KeyId ?? "unknown"}");
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
issues.Add($"signature_crypto_error_{sig.KeyId ?? "unknown"}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compute payload hash for result
|
||||
@@ -236,49 +236,164 @@ public sealed class DsseVerifier : IDsseVerifier
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signature against PAE using the provided public key.
|
||||
/// Supports ECDSA P-256 and RSA keys.
|
||||
/// Supports ECDSA, RSA, and Ed25519 keys.
|
||||
/// </summary>
|
||||
private bool VerifySignature(byte[] pae, byte[] signature, string publicKeyPem)
|
||||
private SignatureVerificationResult VerifySignature(byte[] pae, byte[] signature, string publicKeyPem)
|
||||
{
|
||||
if (!TryExtractPublicKeyDer(publicKeyPem, out var publicKeyDer))
|
||||
{
|
||||
return SignatureVerificationResult.Invalid("invalid_public_key_material");
|
||||
}
|
||||
|
||||
if (TryVerifyWithEcdsa(pae, signature, publicKeyDer, out var ecdsaResult))
|
||||
{
|
||||
return ecdsaResult;
|
||||
}
|
||||
|
||||
if (TryVerifyWithRsa(pae, signature, publicKeyDer, out var rsaResult))
|
||||
{
|
||||
return rsaResult;
|
||||
}
|
||||
|
||||
if (TryVerifyWithEd25519(pae, signature, publicKeyDer, out var ed25519Result))
|
||||
{
|
||||
return ed25519Result;
|
||||
}
|
||||
|
||||
return SignatureVerificationResult.Invalid("unsupported_public_key_type");
|
||||
}
|
||||
|
||||
private static bool TryVerifyWithEcdsa(
|
||||
byte[] pae,
|
||||
byte[] signature,
|
||||
byte[] publicKeyDer,
|
||||
out SignatureVerificationResult result)
|
||||
{
|
||||
// Try ECDSA first (most common for Sigstore/Fulcio)
|
||||
try
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportFromPem(publicKeyPem);
|
||||
return ecdsa.VerifyData(pae, signature, HashAlgorithmName.SHA256);
|
||||
ecdsa.ImportSubjectPublicKeyInfo(publicKeyDer, out _);
|
||||
var isValid = ecdsa.VerifyData(pae, signature, HashAlgorithmName.SHA256);
|
||||
result = isValid
|
||||
? SignatureVerificationResult.Valid
|
||||
: SignatureVerificationResult.Invalid("signature_mismatch");
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
// Not an ECDSA key, try RSA
|
||||
result = SignatureVerificationResult.NotApplicable;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Try RSA
|
||||
private static bool TryVerifyWithRsa(
|
||||
byte[] pae,
|
||||
byte[] signature,
|
||||
byte[] publicKeyDer,
|
||||
out SignatureVerificationResult result)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(publicKeyPem);
|
||||
return rsa.VerifyData(pae, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
rsa.ImportSubjectPublicKeyInfo(publicKeyDer, out _);
|
||||
var isValid = rsa.VerifyData(pae, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
result = isValid
|
||||
? SignatureVerificationResult.Valid
|
||||
: SignatureVerificationResult.Invalid("signature_mismatch");
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
// Not an RSA key either
|
||||
}
|
||||
|
||||
// Try Ed25519 if available (.NET 9+)
|
||||
try
|
||||
{
|
||||
// Ed25519 support via System.Security.Cryptography
|
||||
// Note: Ed25519 verification requires different handling
|
||||
// For now, we log and return false - can be extended later
|
||||
_logger.LogDebug("Ed25519 signature verification not yet implemented");
|
||||
result = SignatureVerificationResult.NotApplicable;
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
}
|
||||
|
||||
private static bool TryVerifyWithEd25519(
|
||||
byte[] pae,
|
||||
byte[] signature,
|
||||
byte[] publicKeyDer,
|
||||
out SignatureVerificationResult result)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Ed25519 not available
|
||||
var key = PublicKeyFactory.CreateKey(publicKeyDer);
|
||||
if (key is not Ed25519PublicKeyParameters ed25519PublicKey)
|
||||
{
|
||||
result = SignatureVerificationResult.NotApplicable;
|
||||
return false;
|
||||
}
|
||||
|
||||
var verifier = new Ed25519Signer();
|
||||
verifier.Init(false, ed25519PublicKey);
|
||||
verifier.BlockUpdate(pae, 0, pae.Length);
|
||||
|
||||
var isValid = verifier.VerifySignature(signature);
|
||||
result = isValid
|
||||
? SignatureVerificationResult.Valid
|
||||
: SignatureVerificationResult.Invalid("signature_mismatch");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
|
||||
{
|
||||
result = SignatureVerificationResult.Invalid("invalid_public_key_material");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractPublicKeyDer(string publicKeyPem, out byte[] publicKeyDer)
|
||||
{
|
||||
publicKeyDer = Array.Empty<byte>();
|
||||
if (string.IsNullOrWhiteSpace(publicKeyPem))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
var beginMarker = "-----BEGIN PUBLIC KEY-----";
|
||||
var endMarker = "-----END PUBLIC KEY-----";
|
||||
var beginIndex = publicKeyPem.IndexOf(beginMarker, StringComparison.Ordinal);
|
||||
var endIndex = publicKeyPem.IndexOf(endMarker, StringComparison.Ordinal);
|
||||
if (beginIndex < 0 || endIndex <= beginIndex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bodyStart = beginIndex + beginMarker.Length;
|
||||
var body = publicKeyPem[bodyStart..endIndex];
|
||||
var normalized = new string(body.Where(static ch => !char.IsWhiteSpace(ch)).ToArray());
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
publicKeyDer = Convert.FromBase64String(normalized);
|
||||
return publicKeyDer.Length > 0;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct SignatureVerificationResult
|
||||
{
|
||||
public static SignatureVerificationResult Valid => new(true, "none");
|
||||
public static SignatureVerificationResult NotApplicable => new(false, "not_applicable");
|
||||
|
||||
public bool IsValid { get; }
|
||||
|
||||
public string ReasonCode { get; }
|
||||
|
||||
private SignatureVerificationResult(bool isValid, string reasonCode)
|
||||
{
|
||||
IsValid = isValid;
|
||||
ReasonCode = reasonCode;
|
||||
}
|
||||
|
||||
public static SignatureVerificationResult Invalid(string reasonCode) => new(false, reasonCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| ATTESTOR-225-001 | DOING | Sprint 225: implement Ed25519 DSSE verification with deterministic failure reasons and vectors. |
|
||||
| AUDIT-0043-M | DONE | Revalidated maintainability for StellaOps.Attestation (2026-01-06). |
|
||||
| AUDIT-0043-T | DONE | Revalidated test coverage for StellaOps.Attestation (2026-01-06). |
|
||||
| AUDIT-0043-A | TODO | Open findings from revalidation (canonical JSON for DSSE payloads). |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography.Kms\StellaOps.Cryptography.Kms.csproj" />
|
||||
<ProjectReference Include="..\..\..\Signer\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| ATTESTOR-225-002 | DOING | Sprint 225 endpoint tests for trusted/revoked/unknown key scenarios. |
|
||||
| ATTESTOR-225-003 | DOING | Sprint 225 tenant isolation and claim-derived tenant tests. |
|
||||
| ATTESTOR-225-004 | DOING | Sprint 225 verdict-by-hash retrieval tests with authorization checks. |
|
||||
| AUDIT-0066-M | DONE | Revalidated 2026-01-06 (maintainability audit). |
|
||||
| AUDIT-0066-T | DONE | Revalidated 2026-01-06 (test coverage audit). |
|
||||
| AUDIT-0066-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
|
||||
@@ -11,6 +11,7 @@ using OpenTelemetry.Trace;
|
||||
using Serilog;
|
||||
using Serilog.Context;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Attestation;
|
||||
using StellaOps.Attestor.Core.Bulk;
|
||||
using StellaOps.Attestor.Core.Observability;
|
||||
using StellaOps.Attestor.Core.Options;
|
||||
@@ -130,6 +131,8 @@ internal static class AttestorWebServiceComposition
|
||||
builder.Services.AddOptions<AttestorWebServiceFeatures>()
|
||||
.Bind(builder.Configuration.GetSection($"{configurationSection}:features"))
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddOptions<VerdictAuthorityRosterOptions>()
|
||||
.Bind(builder.Configuration.GetSection($"{configurationSection}:verdictTrust"));
|
||||
|
||||
var featureOptions = builder.Configuration.GetSection($"{configurationSection}:features")
|
||||
.Get<AttestorWebServiceFeatures>() ?? new AttestorWebServiceFeatures();
|
||||
@@ -141,6 +144,7 @@ internal static class AttestorWebServiceComposition
|
||||
manager.FeatureProviders.Add(new AttestorWebServiceControllerFeatureProvider(featureOptions));
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSingleton<IDsseVerifier, DsseVerifier>();
|
||||
builder.Services.AddAttestorInfrastructure();
|
||||
builder.Services.AddProofChainServices();
|
||||
|
||||
|
||||
@@ -98,3 +98,27 @@ public sealed class VerdictAttestationResponseDto
|
||||
[JsonPropertyName("createdAt")]
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for verdict lookup by deterministic hash.
|
||||
/// </summary>
|
||||
public sealed class VerdictLookupResponseDto
|
||||
{
|
||||
[JsonPropertyName("verdictId")]
|
||||
public string VerdictId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("attestationUri")]
|
||||
public string AttestationUri { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("envelope")]
|
||||
public string Envelope { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("keyId")]
|
||||
public string KeyId { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestation;
|
||||
using StellaOps.Attestor.Core.Signing;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -31,21 +35,28 @@ public class VerdictController : ControllerBase
|
||||
{
|
||||
private readonly IAttestationSigningService _signingService;
|
||||
private readonly ILogger<VerdictController> _logger;
|
||||
private readonly IDsseVerifier _dsseVerifier;
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
private readonly AttestorWebServiceFeatures _features;
|
||||
private readonly VerdictAuthorityRosterOptions _verdictRosterOptions;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private static readonly ConcurrentDictionary<string, CachedVerdictRecord> VerdictCache = new(StringComparer.Ordinal);
|
||||
|
||||
public VerdictController(
|
||||
IAttestationSigningService signingService,
|
||||
ILogger<VerdictController> logger,
|
||||
IDsseVerifier dsseVerifier,
|
||||
IHttpClientFactory? httpClientFactory = null,
|
||||
IOptions<AttestorWebServiceFeatures>? features = null,
|
||||
IOptions<VerdictAuthorityRosterOptions>? verdictRosterOptions = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier));
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_features = features?.Value ?? new AttestorWebServiceFeatures();
|
||||
_verdictRosterOptions = verdictRosterOptions?.Value ?? new VerdictAuthorityRosterOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
@@ -75,6 +86,14 @@ public class VerdictController : ControllerBase
|
||||
"Creating verdict attestation for subject {SubjectName}",
|
||||
request.Subject.Name);
|
||||
|
||||
var tenantResolutionResult = ResolveTenantContext(User, Request.Headers);
|
||||
if (tenantResolutionResult.Error is not null)
|
||||
{
|
||||
return tenantResolutionResult.Error;
|
||||
}
|
||||
|
||||
var tenantId = tenantResolutionResult.TenantId!;
|
||||
|
||||
// Validate request
|
||||
if (string.IsNullOrWhiteSpace(request.PredicateType))
|
||||
{
|
||||
@@ -114,9 +133,17 @@ public class VerdictController : ControllerBase
|
||||
var predicateBase64 = Convert.ToBase64String(predicateBytes);
|
||||
|
||||
// Create signing request
|
||||
var requestedKeyId = string.IsNullOrWhiteSpace(request.KeyId) ? "default" : request.KeyId.Trim();
|
||||
var rosterResolution = ResolveRosterEntry(requestedKeyId);
|
||||
if (rosterResolution.Error is not null)
|
||||
{
|
||||
return rosterResolution.Error;
|
||||
}
|
||||
|
||||
var rosterEntry = rosterResolution.Entry!;
|
||||
var signingRequest = new AttestationSignRequest
|
||||
{
|
||||
KeyId = request.KeyId ?? "default",
|
||||
KeyId = requestedKeyId,
|
||||
PayloadType = request.PredicateType,
|
||||
PayloadBase64 = predicateBase64
|
||||
};
|
||||
@@ -127,7 +154,7 @@ public class VerdictController : ControllerBase
|
||||
CallerSubject = "system",
|
||||
CallerAudience = "policy-engine",
|
||||
CallerClientId = "policy-engine-verdict-attestor",
|
||||
CallerTenant = "default" // TODO: Extract from auth context
|
||||
CallerTenant = tenantId
|
||||
};
|
||||
|
||||
// Sign the predicate
|
||||
@@ -137,12 +164,37 @@ public class VerdictController : ControllerBase
|
||||
var envelope = signResult.Bundle.Dsse;
|
||||
var envelopeJson = SerializeEnvelope(envelope, signResult.KeyId);
|
||||
|
||||
if (!string.Equals(signResult.KeyId, rosterEntry.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
return StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
CreateProblem(
|
||||
title: "Signing key is not trusted by roster.",
|
||||
detail: $"Signed key '{signResult.KeyId}' does not match roster key '{rosterEntry.KeyId}'.",
|
||||
status: StatusCodes.Status403Forbidden,
|
||||
code: "authority_key_mismatch"));
|
||||
}
|
||||
|
||||
var signatureVerification = await _dsseVerifier.VerifyAsync(envelopeJson, rosterEntry.PublicKeyPem, ct).ConfigureAwait(false);
|
||||
if (!signatureVerification.IsValid)
|
||||
{
|
||||
return StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
CreateProblem(
|
||||
title: "Verdict signature is untrusted.",
|
||||
detail: "Signed verdict DSSE envelope failed authority roster verification.",
|
||||
status: StatusCodes.Status403Forbidden,
|
||||
code: "authority_signature_untrusted",
|
||||
issues: signatureVerification.Issues.ToArray()));
|
||||
}
|
||||
|
||||
// Rekor log index (not implemented in minimal handler)
|
||||
long? rekorLogIndex = null;
|
||||
|
||||
// Store in Evidence Locker (via HTTP call)
|
||||
await StoreVerdictInEvidenceLockerAsync(
|
||||
verdictId,
|
||||
tenantId,
|
||||
request.Subject.Name,
|
||||
envelopeJson,
|
||||
signResult,
|
||||
@@ -158,15 +210,13 @@ public class VerdictController : ControllerBase
|
||||
KeyId = signResult.KeyId ?? request.KeyId ?? "default",
|
||||
CreatedAt = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
VerdictCache[verdictId] = CachedVerdictRecord.From(response, tenantId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Verdict attestation created successfully: {VerdictId}",
|
||||
verdictId);
|
||||
|
||||
return CreatedAtRoute(
|
||||
routeName: null, // No route name needed for external link
|
||||
routeValues: null,
|
||||
value: response);
|
||||
return Created(attestationUri, response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -186,6 +236,60 @@ public class VerdictController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a verdict attestation by deterministic verdict hash.
|
||||
/// </summary>
|
||||
[HttpGet("~/api/v1/verdicts/{verdictId}")]
|
||||
[Authorize("attestor:read")]
|
||||
[EnableRateLimiting("attestor-reads")]
|
||||
[ProducesResponseType(typeof(VerdictLookupResponseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<VerdictLookupResponseDto>> GetVerdictByHashAsync(
|
||||
[FromRoute] string verdictId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_features.VerdictsEnabled)
|
||||
{
|
||||
return NotImplementedResult();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(verdictId))
|
||||
{
|
||||
return BadRequest(CreateProblem(
|
||||
title: "Invalid verdict identifier.",
|
||||
detail: "verdictId is required.",
|
||||
status: StatusCodes.Status400BadRequest,
|
||||
code: "invalid_verdict_id"));
|
||||
}
|
||||
|
||||
var tenantResolutionResult = ResolveTenantContext(User, Request.Headers);
|
||||
if (tenantResolutionResult.Error is not null)
|
||||
{
|
||||
return tenantResolutionResult.Error;
|
||||
}
|
||||
|
||||
var tenantId = tenantResolutionResult.TenantId!;
|
||||
if (VerdictCache.TryGetValue(verdictId, out var cached) &&
|
||||
string.Equals(cached.TenantId, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return Ok(cached.ToLookupResponse(verdictId));
|
||||
}
|
||||
|
||||
var lockerResult = await FetchVerdictFromEvidenceLockerAsync(verdictId, tenantId, ct).ConfigureAwait(false);
|
||||
if (lockerResult is not null)
|
||||
{
|
||||
VerdictCache[verdictId] = CachedVerdictRecord.From(lockerResult);
|
||||
return Ok(lockerResult);
|
||||
}
|
||||
|
||||
return NotFound(CreateProblem(
|
||||
title: "Verdict not found.",
|
||||
detail: $"No verdict exists for hash '{verdictId}' in tenant '{tenantId}'.",
|
||||
status: StatusCodes.Status404NotFound,
|
||||
code: "verdict_not_found"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic verdict ID from predicate content.
|
||||
/// </summary>
|
||||
@@ -227,6 +331,7 @@ public class VerdictController : ControllerBase
|
||||
/// </summary>
|
||||
private async Task StoreVerdictInEvidenceLockerAsync(
|
||||
string verdictId,
|
||||
string tenantId,
|
||||
string findingId,
|
||||
string envelopeJson,
|
||||
AttestationSignResult signResult,
|
||||
@@ -268,7 +373,7 @@ public class VerdictController : ControllerBase
|
||||
var storeRequest = new
|
||||
{
|
||||
verdict_id = verdictId,
|
||||
tenant_id = "default", // TODO: Extract from auth context (requires CallerTenant from SubmissionContext)
|
||||
tenant_id = tenantId,
|
||||
policy_run_id = policyRunId,
|
||||
policy_id = policyId,
|
||||
policy_version = policyVersion,
|
||||
@@ -310,6 +415,220 @@ public class VerdictController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
private (string? TenantId, ActionResult? Error) ResolveTenantContext(ClaimsPrincipal principal, IHeaderDictionary headers)
|
||||
{
|
||||
var tenantId = principal.FindFirst("tenant_id")?.Value
|
||||
?? principal.FindFirst("tenant")?.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return (null, StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
CreateProblem(
|
||||
title: "Tenant claim is required.",
|
||||
detail: "Authenticated principal does not contain tenant_id or tenant claim.",
|
||||
status: StatusCodes.Status403Forbidden,
|
||||
code: "tenant_claim_missing")));
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("X-Tenant-Id", out var headerTenant) &&
|
||||
headerTenant.Count > 0 &&
|
||||
!string.Equals(headerTenant[0], tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return (null, StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
CreateProblem(
|
||||
title: "Tenant mismatch detected.",
|
||||
detail: "Tenant header does not match authenticated tenant claim.",
|
||||
status: StatusCodes.Status403Forbidden,
|
||||
code: "tenant_mismatch")));
|
||||
}
|
||||
|
||||
return (tenantId, null);
|
||||
}
|
||||
|
||||
private (VerdictAuthorityKeyOptions? Entry, ActionResult? Error) ResolveRosterEntry(string keyId)
|
||||
{
|
||||
if (_verdictRosterOptions.Keys.Count == 0)
|
||||
{
|
||||
return (null, StatusCode(
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
CreateProblem(
|
||||
title: "Authority roster is unavailable.",
|
||||
detail: "attestor:verdictTrust:keys must include at least one trusted key.",
|
||||
status: StatusCodes.Status503ServiceUnavailable,
|
||||
code: "authority_roster_unavailable")));
|
||||
}
|
||||
|
||||
var entry = _verdictRosterOptions.Keys
|
||||
.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return (null, StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
CreateProblem(
|
||||
title: "Signing key is not in authority roster.",
|
||||
detail: $"Key '{keyId}' is not trusted for verdict creation.",
|
||||
status: StatusCodes.Status403Forbidden,
|
||||
code: "authority_key_unknown")));
|
||||
}
|
||||
|
||||
if (string.Equals(entry.Status, "revoked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (null, StatusCode(
|
||||
StatusCodes.Status403Forbidden,
|
||||
CreateProblem(
|
||||
title: "Signing key is revoked.",
|
||||
detail: $"Key '{entry.KeyId}' is revoked in authority roster.",
|
||||
status: StatusCodes.Status403Forbidden,
|
||||
code: "authority_key_revoked")));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.PublicKeyPem))
|
||||
{
|
||||
return (null, StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
CreateProblem(
|
||||
title: "Authority roster key is incomplete.",
|
||||
detail: $"Key '{entry.KeyId}' is missing public key material.",
|
||||
status: StatusCodes.Status500InternalServerError,
|
||||
code: "authority_key_missing_public_key")));
|
||||
}
|
||||
|
||||
return (entry, null);
|
||||
}
|
||||
|
||||
private async Task<VerdictLookupResponseDto?> FetchVerdictFromEvidenceLockerAsync(
|
||||
string verdictId,
|
||||
string tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (_httpClientFactory is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("EvidenceLocker");
|
||||
var response = await client.GetAsync($"/api/v1/verdicts/{Uri.EscapeDataString(verdictId)}", ct).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Evidence Locker verdict lookup failed for {VerdictId}: {StatusCode}",
|
||||
verdictId,
|
||||
response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct).ConfigureAwait(false);
|
||||
if (payload.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lockerTenant = GetOptionalString(payload, "tenant_id", "tenantId");
|
||||
if (!string.IsNullOrWhiteSpace(lockerTenant) &&
|
||||
!string.Equals(lockerTenant, tenantId, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var envelope = ExtractEnvelope(payload);
|
||||
if (string.IsNullOrWhiteSpace(envelope))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var keyId = GetOptionalString(payload, "key_id", "keyId") ?? "unknown";
|
||||
var createdAt = GetOptionalString(payload, "evaluated_at", "created_at", "createdAt")
|
||||
?? _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
return new VerdictLookupResponseDto
|
||||
{
|
||||
VerdictId = verdictId,
|
||||
AttestationUri = $"/api/v1/verdicts/{verdictId}",
|
||||
Envelope = envelope,
|
||||
KeyId = keyId,
|
||||
CreatedAt = createdAt,
|
||||
TenantId = tenantId
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Evidence Locker verdict lookup failed for {VerdictId}", verdictId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractEnvelope(JsonElement payload)
|
||||
{
|
||||
if (!payload.TryGetProperty("envelope", out var envelopeElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (envelopeElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return envelopeElement.GetString();
|
||||
}
|
||||
|
||||
if (envelopeElement.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
{
|
||||
var envelopeJson = envelopeElement.GetRawText();
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(envelopeJson));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetOptionalString(JsonElement payload, params string[] candidates)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (payload.TryGetProperty(candidate, out var value) &&
|
||||
value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblem(
|
||||
string title,
|
||||
string detail,
|
||||
int status,
|
||||
string code,
|
||||
string[]? issues = null)
|
||||
{
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
Status = status
|
||||
};
|
||||
|
||||
problem.Extensions["code"] = code;
|
||||
if (issues is not null && issues.Length > 0)
|
||||
{
|
||||
problem.Extensions["issues"] = issues;
|
||||
}
|
||||
|
||||
return problem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts verdict metadata from predicate JSON.
|
||||
/// </summary>
|
||||
@@ -418,4 +737,28 @@ public class VerdictController : ControllerBase
|
||||
StatusCode = StatusCodes.Status501NotImplemented
|
||||
};
|
||||
}
|
||||
|
||||
private sealed record CachedVerdictRecord(
|
||||
string TenantId,
|
||||
string Envelope,
|
||||
string KeyId,
|
||||
string CreatedAt)
|
||||
{
|
||||
public static CachedVerdictRecord From(VerdictAttestationResponseDto response, string tenantId)
|
||||
=> new(tenantId, response.Envelope, response.KeyId, response.CreatedAt);
|
||||
|
||||
public static CachedVerdictRecord From(VerdictLookupResponseDto response)
|
||||
=> new(response.TenantId, response.Envelope, response.KeyId, response.CreatedAt);
|
||||
|
||||
public VerdictLookupResponseDto ToLookupResponse(string verdictId)
|
||||
=> new()
|
||||
{
|
||||
VerdictId = verdictId,
|
||||
AttestationUri = $"/api/v1/verdicts/{verdictId}",
|
||||
Envelope = Envelope,
|
||||
KeyId = KeyId,
|
||||
CreatedAt = CreatedAt,
|
||||
TenantId = TenantId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Attestor.WebService.Options;
|
||||
|
||||
public sealed class VerdictAuthorityRosterOptions
|
||||
{
|
||||
public List<VerdictAuthorityKeyOptions> Keys { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class VerdictAuthorityKeyOptions
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "trusted";
|
||||
|
||||
public string PublicKeyPem { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Attestation/StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
|
||||
@@ -5,6 +5,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| ATTESTOR-225-002 | DOING | Sprint 225: enforce roster-based trust verification before verdict append. |
|
||||
| ATTESTOR-225-003 | DOING | Sprint 225: resolve tenant from authenticated claims and block spoofing. |
|
||||
| ATTESTOR-225-004 | DOING | Sprint 225: implement verdict-by-hash retrieval and tenant-scoped access checks. |
|
||||
| AUDIT-0072-M | DONE | Revalidated 2026-01-06 (maintainability audit). |
|
||||
| AUDIT-0072-T | DONE | Revalidated 2026-01-06 (test coverage audit). |
|
||||
| AUDIT-0072-A | DONE | Applied 2026-01-13 (feature gating, correlation ID provider, proof chain/verification summary updates, tests). |
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Provenance\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Provenance.Attestation\StellaOps.Provenance.Attestation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user