up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-24 07:52:25 +02:00
parent 5970f0d9bd
commit 150b3730ef
215 changed files with 8119 additions and 740 deletions

View File

@@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Notifier.WebService.Contracts;
public sealed class PackApprovalAckRequest
{
[Required]
public string AckToken { get; init; } = string.Empty;
}

View File

@@ -118,6 +118,50 @@ app.MapPost("/api/v1/notify/pack-approvals", async (
return Results.Accepted();
});
app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
HttpContext context,
string packId,
PackApprovalAckRequest request,
INotifyLockRepository locks,
INotifyAuditRepository audit,
TimeProvider timeProvider) =>
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
if (string.IsNullOrWhiteSpace(tenantId))
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
if (string.IsNullOrWhiteSpace(request.AckToken))
{
return Results.BadRequest(Error("ack_token_missing", "AckToken is required.", context));
}
var lockKey = $"pack-approvals-ack|{tenantId}|{packId}|{request.AckToken}";
var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals-ack", TimeSpan.FromMinutes(10), context.RequestAborted)
.ConfigureAwait(false);
if (!reserved)
{
return Results.StatusCode(StatusCodes.Status200OK);
}
var auditEntry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = "pack-approvals-ack",
Action = "pack.approval.acknowledged",
EntityId = packId,
EntityType = "pack-approval",
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(request))
};
await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
return Results.NoContent();
});
app.MapGet("/.well-known/openapi", (HttpContext context, OpenApiDocumentCache cache) =>
{
context.Response.Headers.CacheControl = "public, max-age=300";

View File

@@ -12,7 +12,9 @@ public sealed class OpenApiDocumentCache
var path = Path.Combine(environment.ContentRootPath, "openapi", "notify-openapi.yaml");
if (!File.Exists(path))
{
throw new FileNotFoundException("OpenAPI document not found.", path);
_document = string.Empty;
_hash = string.Empty;
return;
}
_document = File.ReadAllText(path, Encoding.UTF8);

View File

@@ -317,6 +317,75 @@ paths:
default:
$ref: '#/components/responses/Error'
/api/v1/notify/pack-approvals:
post:
summary: Ingest pack approval decision
tags: [PackApprovals]
operationId: ingestPackApproval
security:
- oauth2: [notify.operator]
- hmac: []
parameters:
- $ref: '#/components/parameters/Tenant'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/PackApprovalEvent' }
examples:
approval-granted:
value:
eventId: "20e4e5fe-3d4a-4f57-9f9b-b1a1c1111111"
issuedAt: "2025-11-17T16:00:00Z"
kind: "pack.approval.granted"
packId: "offline-kit-2025-11"
policy:
id: "policy-123"
version: "v5"
decision: "approved"
actor: "task-runner"
resumeToken: "rt-abc123"
summary: "All required attestations verified."
labels:
environment: "prod"
approver: "ops"
responses:
'202':
description: Accepted; durable write queued for processing.
headers:
X-Resume-After:
description: Resume token echo or replacement
schema: { type: string }
default:
$ref: '#/components/responses/Error'
/api/v1/notify/pack-approvals/{packId}/ack:
post:
summary: Acknowledge a pack approval notification
tags: [PackApprovals]
operationId: ackPackApproval
parameters:
- $ref: '#/components/parameters/Tenant'
- name: packId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ackToken: { type: string }
required: [ackToken]
responses:
'204':
description: Acknowledged
default:
$ref: '#/components/responses/Error'
components:
securitySchemes:
oauth2:
@@ -328,6 +397,10 @@ components:
notify.viewer: Read-only Notifier access
notify.operator: Manage rules/templates/incidents within tenant
notify.admin: Tenant-scoped administration
hmac:
type: http
scheme: bearer
description: Pre-shared HMAC token (air-gap friendly) referenced by secretRef.
parameters:
Tenant:
name: X-StellaOps-Tenant
@@ -335,6 +408,12 @@ components:
required: true
description: Tenant slug
schema: { type: string }
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: Stable UUID to dedupe retries.
schema: { type: string, format: uuid }
PageSize:
name: pageSize
in: query
@@ -468,6 +547,39 @@ components:
type: object
additionalProperties: { type: string }
PackApprovalEvent:
type: object
required:
- eventId
- issuedAt
- kind
- packId
- decision
- actor
properties:
eventId: { type: string, format: uuid }
issuedAt: { type: string, format: date-time }
kind:
type: string
enum: [pack.approval.granted, pack.approval.denied, pack.policy.override]
packId: { type: string }
policy:
type: object
properties:
id: { type: string }
version: { type: string }
decision:
type: string
enum: [approved, denied, overridden]
actor: { type: string }
resumeToken:
type: string
description: Opaque token for at-least-once resume.
summary: { type: string }
labels:
type: object
additionalProperties: { type: string }
QuietHours:
type: object
required: [quietHoursId, windows]