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

@@ -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);
}
}

View File

@@ -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" />

View File

@@ -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);
});
}
}

View File

@@ -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" }

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]

View File

@@ -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"
]
}
}