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
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:
@@ -10,26 +10,16 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class OpenApiEndpointTests : IClassFixture<WebApplicationFactory<WebServiceAssemblyMarker>>
|
||||
public sealed class OpenApiEndpointTests : IClassFixture<NotifierApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly InMemoryPackApprovalRepository _packRepo = new();
|
||||
private readonly InMemoryLockRepository _lockRepo = new();
|
||||
private readonly InMemoryAuditRepository _auditRepo = new();
|
||||
|
||||
public OpenApiEndpointTests(WebApplicationFactory<WebServiceAssemblyMarker> factory)
|
||||
public OpenApiEndpointTests(NotifierApplicationFactory factory)
|
||||
{
|
||||
_client = factory
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<INotifyPackApprovalRepository>(_packRepo);
|
||||
services.AddSingleton<INotifyLockRepository>(_lockRepo);
|
||||
services.AddSingleton<INotifyAuditRepository>(_auditRepo);
|
||||
});
|
||||
})
|
||||
.CreateClient();
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -89,4 +79,20 @@ public sealed class OpenApiEndpointTests : IClassFixture<WebApplicationFactory<W
|
||||
resumeValues.Contains("rt-ok"));
|
||||
Assert.True(_packRepo.Exists("tenant-a", Guid.Parse("00000000-0000-0000-0000-000000000002"), "offline-kit"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackApprovals_acknowledgement_requires_tenant_and_token()
|
||||
{
|
||||
var ackContent = new StringContent("""{"ackToken":"token-123"}""", Encoding.UTF8, "application/json");
|
||||
var ackResponse = await _client.PostAsync("/api/v1/notify/pack-approvals/offline-kit/ack", ackContent, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, ackResponse.StatusCode);
|
||||
|
||||
var ackReq = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/pack-approvals/offline-kit/ack")
|
||||
{
|
||||
Content = ackContent
|
||||
};
|
||||
ackReq.Headers.Add("X-StellaOps-Tenant", "tenant-a");
|
||||
var goodResponse = await _client.SendAsync(ackReq, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, goodResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="TestContent/**" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notifier.WebService;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Support;
|
||||
|
||||
internal sealed class NotifierApplicationFactory : WebApplicationFactory<WebServiceAssemblyMarker>
|
||||
{
|
||||
private readonly InMemoryPackApprovalRepository _packRepo;
|
||||
private readonly InMemoryLockRepository _lockRepo;
|
||||
private readonly InMemoryAuditRepository _auditRepo;
|
||||
|
||||
public NotifierApplicationFactory(
|
||||
InMemoryPackApprovalRepository packRepo,
|
||||
InMemoryLockRepository lockRepo,
|
||||
InMemoryAuditRepository auditRepo)
|
||||
{
|
||||
_packRepo = packRepo;
|
||||
_lockRepo = lockRepo;
|
||||
_auditRepo = auditRepo;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseContentRoot(Path.Combine(Directory.GetCurrentDirectory(), "TestContent"));
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<INotifyPackApprovalRepository>(_packRepo);
|
||||
services.AddSingleton<INotifyLockRepository>(_lockRepo);
|
||||
services.AddSingleton<INotifyAuditRepository>(_auditRepo);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
# OpenAPI 3.1 specification for StellaOps Notifier WebService (draft)
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Notifier API
|
||||
version: 0.6.0-draft
|
||||
description: |
|
||||
Contract for Notifications Studio (Notifier) covering rules, templates, incidents,
|
||||
and quiet hours. Uses the platform error envelope and tenant header `X-StellaOps-Tenant`.
|
||||
servers:
|
||||
- url: https://api.stellaops.example.com
|
||||
description: Production
|
||||
- url: https://api.dev.stellaops.example.com
|
||||
description: Development
|
||||
security:
|
||||
- oauth2: [notify.viewer]
|
||||
- oauth2: [notify.operator]
|
||||
- oauth2: [notify.admin]
|
||||
paths:
|
||||
/api/v1/notify/rules:
|
||||
get:
|
||||
summary: List notification rules
|
||||
tags: [Rules]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/PageSize'
|
||||
- $ref: '#/components/parameters/PageToken'
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated rule list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/NotifyRule' }
|
||||
nextPageToken:
|
||||
type: string
|
||||
examples:
|
||||
default:
|
||||
value:
|
||||
items:
|
||||
- ruleId: rule-critical
|
||||
tenantId: tenant-dev
|
||||
name: Critical scanner verdicts
|
||||
enabled: true
|
||||
match:
|
||||
eventKinds: [scanner.report.ready]
|
||||
minSeverity: critical
|
||||
actions:
|
||||
- actionId: act-slack-critical
|
||||
channel: chn-slack-soc
|
||||
template: tmpl-critical
|
||||
digest: instant
|
||||
nextPageToken: null
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
post:
|
||||
summary: Create a notification rule
|
||||
tags: [Rules]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyRule' }
|
||||
examples:
|
||||
create-rule:
|
||||
value:
|
||||
ruleId: rule-attest-fail
|
||||
tenantId: tenant-dev
|
||||
name: Attestation failures → SOC
|
||||
enabled: true
|
||||
match:
|
||||
eventKinds: [attestor.verification.failed]
|
||||
actions:
|
||||
- actionId: act-soc
|
||||
channel: chn-webhook-soc
|
||||
template: tmpl-attest-verify-fail
|
||||
responses:
|
||||
'201':
|
||||
description: Rule created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyRule' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/rules/{ruleId}:
|
||||
get:
|
||||
summary: Fetch a rule
|
||||
tags: [Rules]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/RuleId'
|
||||
responses:
|
||||
'200':
|
||||
description: Rule
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyRule' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
patch:
|
||||
summary: Update a rule (partial)
|
||||
tags: [Rules]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/RuleId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: JSON Merge Patch
|
||||
responses:
|
||||
'200':
|
||||
description: Updated rule
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyRule' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/templates:
|
||||
get:
|
||||
summary: List templates
|
||||
tags: [Templates]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- name: key
|
||||
in: query
|
||||
description: Filter by template key
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Templates
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
post:
|
||||
summary: Create a template
|
||||
tags: [Templates]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
responses:
|
||||
'201':
|
||||
description: Template created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/templates/{templateId}:
|
||||
get:
|
||||
summary: Fetch a template
|
||||
tags: [Templates]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/TemplateId'
|
||||
responses:
|
||||
'200':
|
||||
description: Template
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
patch:
|
||||
summary: Update a template (partial)
|
||||
tags: [Templates]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/TemplateId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: JSON Merge Patch
|
||||
responses:
|
||||
'200':
|
||||
description: Updated template
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/incidents:
|
||||
get:
|
||||
summary: List incidents (paged)
|
||||
tags: [Incidents]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/PageSize'
|
||||
- $ref: '#/components/parameters/PageToken'
|
||||
responses:
|
||||
'200':
|
||||
description: Incident page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Incident' }
|
||||
nextPageToken: { type: string }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
post:
|
||||
summary: Raise an incident (ops/toggle/override)
|
||||
tags: [Incidents]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Incident' }
|
||||
examples:
|
||||
start-incident:
|
||||
value:
|
||||
incidentId: inc-telemetry-outage
|
||||
kind: outage
|
||||
severity: major
|
||||
startedAt: 2025-11-17T04:02:00Z
|
||||
shortDescription: "Telemetry pipeline degraded; burn-rate breach"
|
||||
metadata:
|
||||
source: slo-evaluator
|
||||
responses:
|
||||
'202':
|
||||
description: Incident accepted
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/incidents/{incidentId}/ack:
|
||||
post:
|
||||
summary: Acknowledge an incident notification
|
||||
tags: [Incidents]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/IncidentId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ackToken:
|
||||
type: string
|
||||
description: DSSE-signed acknowledgement token
|
||||
responses:
|
||||
'204':
|
||||
description: Acknowledged
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/quiet-hours:
|
||||
get:
|
||||
summary: Get quiet-hours schedule
|
||||
tags: [QuietHours]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
responses:
|
||||
'200':
|
||||
description: Quiet hours schedule
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/QuietHours' }
|
||||
examples:
|
||||
current:
|
||||
value:
|
||||
quietHoursId: qh-default
|
||||
windows:
|
||||
- timezone: UTC
|
||||
days: [Mon, Tue, Wed, Thu, Fri]
|
||||
start: "22:00"
|
||||
end: "06:00"
|
||||
exemptions:
|
||||
- eventKinds: [attestor.verification.failed]
|
||||
reason: "Always alert for attestation failures"
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
post:
|
||||
summary: Set quiet-hours schedule
|
||||
tags: [QuietHours]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/QuietHours' }
|
||||
responses:
|
||||
'200':
|
||||
description: Updated quiet hours
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/QuietHours' }
|
||||
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:
|
||||
type: oauth2
|
||||
flows:
|
||||
clientCredentials:
|
||||
tokenUrl: https://auth.stellaops.example.com/oauth/token
|
||||
scopes:
|
||||
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
|
||||
in: header
|
||||
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
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
PageToken:
|
||||
name: pageToken
|
||||
in: query
|
||||
schema: { type: string }
|
||||
RuleId:
|
||||
name: ruleId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
TemplateId:
|
||||
name: templateId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
IncidentId:
|
||||
name: incidentId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
|
||||
responses:
|
||||
Error:
|
||||
description: Standard error envelope
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
|
||||
examples:
|
||||
validation:
|
||||
value:
|
||||
error:
|
||||
code: validation_failed
|
||||
message: "quietHours.windows[0].start must be HH:mm"
|
||||
traceId: "f62f3c2b9c8e4c53"
|
||||
|
||||
schemas:
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
required: [code, message, traceId]
|
||||
properties:
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
traceId: { type: string }
|
||||
|
||||
NotifyRule:
|
||||
type: object
|
||||
required: [ruleId, tenantId, name, match, actions]
|
||||
properties:
|
||||
ruleId: { type: string }
|
||||
tenantId: { type: string }
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
enabled: { type: boolean, default: true }
|
||||
match: { $ref: '#/components/schemas/RuleMatch' }
|
||||
actions:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/RuleAction' }
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
RuleMatch:
|
||||
type: object
|
||||
properties:
|
||||
eventKinds:
|
||||
type: array
|
||||
items: { type: string }
|
||||
minSeverity: { type: string, enum: [info, low, medium, high, critical] }
|
||||
verdicts:
|
||||
type: array
|
||||
items: { type: string }
|
||||
labels:
|
||||
type: array
|
||||
items: { type: string }
|
||||
kevOnly: { type: boolean }
|
||||
|
||||
RuleAction:
|
||||
type: object
|
||||
required: [actionId, channel]
|
||||
properties:
|
||||
actionId: { type: string }
|
||||
channel: { type: string }
|
||||
template: { type: string }
|
||||
digest: { type: string, description: "Digest window key e.g. instant|5m|15m|1h|1d" }
|
||||
throttle: { type: string, description: "ISO-8601 duration, e.g. PT5M" }
|
||||
locale: { type: string }
|
||||
enabled: { type: boolean, default: true }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
NotifyTemplate:
|
||||
type: object
|
||||
required: [templateId, tenantId, key, channelType, locale, body, renderMode, format]
|
||||
properties:
|
||||
templateId: { type: string }
|
||||
tenantId: { type: string }
|
||||
key: { type: string }
|
||||
channelType: { type: string, enum: [slack, teams, email, webhook, custom] }
|
||||
locale: { type: string, description: "BCP-47, lower-case" }
|
||||
renderMode: { type: string, enum: [Markdown, Html, AdaptiveCard, PlainText, Json] }
|
||||
format: { type: string, enum: [slack, teams, email, webhook, json] }
|
||||
description: { type: string }
|
||||
body: { type: string }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
Incident:
|
||||
type: object
|
||||
required: [incidentId, kind, severity, startedAt]
|
||||
properties:
|
||||
incidentId: { type: string }
|
||||
kind: { type: string, description: "outage|degradation|security|ops-drill" }
|
||||
severity: { type: string, enum: [minor, major, critical] }
|
||||
startedAt: { type: string, format: date-time }
|
||||
endedAt: { type: string, format: date-time }
|
||||
shortDescription: { type: string }
|
||||
description: { type: string }
|
||||
metadata:
|
||||
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]
|
||||
properties:
|
||||
quietHoursId: { type: string }
|
||||
windows:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/QuietHoursWindow' }
|
||||
exemptions:
|
||||
type: array
|
||||
description: Event kinds that bypass quiet hours
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
eventKinds:
|
||||
type: array
|
||||
items: { type: string }
|
||||
reason: { type: string }
|
||||
|
||||
QuietHoursWindow:
|
||||
type: object
|
||||
required: [timezone, days, start, end]
|
||||
properties:
|
||||
timezone: { type: string, description: "IANA TZ, e.g., UTC" }
|
||||
days:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
|
||||
start: { type: string, description: "HH:mm" }
|
||||
end: { type: string, description: "HH:mm" }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"templates": [
|
||||
{
|
||||
"templateId": "tmpl-pack-approval-slack-en",
|
||||
"tenantId": "tenant-sample",
|
||||
"key": "pack.approval.granted",
|
||||
"channelType": "slack",
|
||||
"locale": "en-US",
|
||||
"renderMode": "Markdown",
|
||||
"format": "slack",
|
||||
"description": "Pack approval granted (Slack, English)",
|
||||
"body": "*Pack approval granted*\nPack: {{packId}}\nPolicy: {{policy.id}} ({{policy.version}})\nDecision: {{decision}}\nResume: {{resumeToken}}\nSummary: {{summary}}\nLabels: {{#each labels}}{{@key}}={{this}} {{/each}}",
|
||||
"metadata": {
|
||||
"redaction": "safe",
|
||||
"throttle": "PT5M"
|
||||
}
|
||||
},
|
||||
{
|
||||
"templateId": "tmpl-pack-approval-email-en",
|
||||
"tenantId": "tenant-sample",
|
||||
"key": "pack.approval.granted",
|
||||
"channelType": "email",
|
||||
"locale": "en-US",
|
||||
"renderMode": "Html",
|
||||
"format": "email",
|
||||
"description": "Pack approval granted (Email, English)",
|
||||
"body": "<h3>Pack approval granted</h3><p><strong>Pack:</strong> {{packId}}<br/><strong>Policy:</strong> {{policy.id}} ({{policy.version}})<br/><strong>Decision:</strong> {{decision}}<br/><strong>Resume:</strong> {{resumeToken}}<br/><strong>Summary:</strong> {{summary}}</p><p><strong>Labels:</strong> {{#each labels}}{{@key}}={{this}} {{/each}}</p>",
|
||||
"metadata": {
|
||||
"redaction": "safe",
|
||||
"throttle": "PT5M",
|
||||
"subject": "[Notify] Pack approval granted: {{packId}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routingPredicates": [
|
||||
{
|
||||
"name": "pack-approval-default",
|
||||
"match": {
|
||||
"eventKinds": ["pack.approval.granted", "pack.approval.denied", "pack.policy.override"],
|
||||
"labels": ["environment=prod"]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"channel": "slack:sec-approvals",
|
||||
"template": "tmpl-pack-approval-slack-en",
|
||||
"digest": "instant"
|
||||
},
|
||||
{
|
||||
"channel": "email:ops-approvals",
|
||||
"template": "tmpl-pack-approval-email-en",
|
||||
"digest": "instant"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"redaction": {
|
||||
"allow": [
|
||||
"packId",
|
||||
"policy.id",
|
||||
"policy.version",
|
||||
"decision",
|
||||
"resumeToken",
|
||||
"summary",
|
||||
"labels.*"
|
||||
],
|
||||
"deny": [
|
||||
"secrets",
|
||||
"tokens"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user