The Problem
Security is the operational concern most likely to be "planned for later" and never implemented.
RBAC drift. The authorization rules start in a spreadsheet titled "Role Permissions Matrix v3 FINAL (2).xlsx." A developer implements them in code. Six months later, the spreadsheet and the code disagree. A new role was added to the spreadsheet but not to the code. A permission was removed from the code but not from the spreadsheet. The penetration test finds that the Viewer role can delete orders because the [Authorize(Roles = "Admin")] attribute was copied from a different controller and nobody changed the role.
Audit logging gaps. The compliance team asks: "Can you show me who accessed patient records in the last 90 days?" The answer is no. The audit logging was supposed to be added in sprint 14. It is now sprint 38. The // TODO: add audit logging comment is still there.
Secret rotation. The database connection string in production uses a password that was set when the service was first deployed. That was two years ago. The password is Prod2024! and it is in three places: Azure Key Vault, an environment variable on the App Service, and a sticky note on the DevOps engineer's monitor. Nobody has rotated it because the rotation process is manual and undocumented.
Vulnerability scanning. The container image has 47 critical CVEs. The team knows because someone ran Trivy once, four months ago. There is no recurring scan. The DAST scan was run during the initial security review and never again.
What is missing:
- RBAC rules in code. Every resource should declare who can access it. The compiler should verify that every public endpoint has an authorization rule.
- Audit policies attached to entities. If an entity contains sensitive data, the audit policy should be declared on the entity, not in a separate middleware configuration file.
- Secret rotation as a first-class concept. Rotation period, vault path, and expiry notification should be declared and enforced at build time.
- Vulnerability scanning schedules in the same codebase. Scan type, severity threshold, and schedule should be attributes, not CI pipeline YAML that drifts from the code.
Attribute Definitions
// =================================================================
// Ops.Security.Lib -- Security DSL Attributes
// =================================================================
/// Security domain classification.
public enum SecurityDomain
{
Auth, // authentication and authorization
DataProtection, // encryption, PII handling
Network, // TLS, firewall, CORS
Audit, // logging, compliance trails
Secrets // vault, rotation, injection
}
/// Top-level security policy container.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class SecurityPolicyAttribute : Attribute
{
public string Name { get; }
public SecurityDomain Domain { get; }
public string Description { get; init; } = "";
public string Owner { get; init; } = "";
public string ComplianceFramework { get; init; } = "";
public SecurityPolicyAttribute(string name, SecurityDomain domain)
{
Name = name;
Domain = domain;
}
}
/// Per-resource authorization rule. Maps to an ASP.NET AuthorizationPolicy.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class RbacRuleAttribute : Attribute
{
public string Role { get; }
public string[] Permissions { get; }
public string Resource { get; init; } = "";
public string Condition { get; init; } = "";
public bool DenyByDefault { get; init; } = true;
public RbacRuleAttribute(string role, params string[] permissions)
{
Role = role;
Permissions = permissions;
}
}
/// Audit level for logging operations.
public enum AuditLevel
{
Read, // log read operations
Write, // log create/update/delete
Admin, // log administrative actions
All // log everything
}
/// Declares an audit policy for an entity or endpoint.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public sealed class AuditPolicyAttribute : Attribute
{
public AuditLevel Level { get; }
public int RetentionDays { get; init; } = 365;
public string[] SensitiveFields { get; init; } = [];
public bool IncludePayload { get; init; } = false;
public string Destination { get; init; } = "default";
public AuditPolicyAttribute(AuditLevel level) => Level = level;
}
/// Secret rotation policy.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class SecretRotationAttribute : Attribute
{
public string Name { get; }
public string VaultPath { get; }
public int RotationPeriodDays { get; init; } = 90;
public int NotifyBeforeDays { get; init; } = 14;
public string[] NotifyChannels { get; init; } = [];
public bool AutoRotate { get; init; } = false;
public SecretRotationAttribute(string name, string vaultPath)
{
Name = name;
VaultPath = vaultPath;
}
}
/// Vulnerability scan kind.
public enum ScanKind
{
SAST, // static application security testing (source code)
DAST, // dynamic application security testing (running app)
SCA, // software composition analysis (dependencies)
ContainerScan // container image CVE scanning
}
/// Vulnerability scan schedule and thresholds.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class VulnerabilityScanAttribute : Attribute
{
public ScanKind Kind { get; }
public string MaxSeverity { get; init; } = "HIGH";
public string Schedule { get; init; } = "0 2 * * *";
public string[] ExcludePatterns { get; init; } = [];
public bool FailOnViolation { get; init; } = true;
public VulnerabilityScanAttribute(ScanKind kind) => Kind = kind;
}
/// Penetration test scope definition.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class PenetrationTestScopeAttribute : Attribute
{
public string[] Endpoints { get; }
public string Schedule { get; init; } = "quarterly";
public string Methodology { get; init; } = "OWASP";
public string[] ExcludeEndpoints { get; init; } = [];
public string ReportFormat { get; init; } = "PDF";
public PenetrationTestScopeAttribute(params string[] endpoints)
{
Endpoints = endpoints;
}
}// =================================================================
// Ops.Security.Lib -- Security DSL Attributes
// =================================================================
/// Security domain classification.
public enum SecurityDomain
{
Auth, // authentication and authorization
DataProtection, // encryption, PII handling
Network, // TLS, firewall, CORS
Audit, // logging, compliance trails
Secrets // vault, rotation, injection
}
/// Top-level security policy container.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class SecurityPolicyAttribute : Attribute
{
public string Name { get; }
public SecurityDomain Domain { get; }
public string Description { get; init; } = "";
public string Owner { get; init; } = "";
public string ComplianceFramework { get; init; } = "";
public SecurityPolicyAttribute(string name, SecurityDomain domain)
{
Name = name;
Domain = domain;
}
}
/// Per-resource authorization rule. Maps to an ASP.NET AuthorizationPolicy.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class RbacRuleAttribute : Attribute
{
public string Role { get; }
public string[] Permissions { get; }
public string Resource { get; init; } = "";
public string Condition { get; init; } = "";
public bool DenyByDefault { get; init; } = true;
public RbacRuleAttribute(string role, params string[] permissions)
{
Role = role;
Permissions = permissions;
}
}
/// Audit level for logging operations.
public enum AuditLevel
{
Read, // log read operations
Write, // log create/update/delete
Admin, // log administrative actions
All // log everything
}
/// Declares an audit policy for an entity or endpoint.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public sealed class AuditPolicyAttribute : Attribute
{
public AuditLevel Level { get; }
public int RetentionDays { get; init; } = 365;
public string[] SensitiveFields { get; init; } = [];
public bool IncludePayload { get; init; } = false;
public string Destination { get; init; } = "default";
public AuditPolicyAttribute(AuditLevel level) => Level = level;
}
/// Secret rotation policy.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class SecretRotationAttribute : Attribute
{
public string Name { get; }
public string VaultPath { get; }
public int RotationPeriodDays { get; init; } = 90;
public int NotifyBeforeDays { get; init; } = 14;
public string[] NotifyChannels { get; init; } = [];
public bool AutoRotate { get; init; } = false;
public SecretRotationAttribute(string name, string vaultPath)
{
Name = name;
VaultPath = vaultPath;
}
}
/// Vulnerability scan kind.
public enum ScanKind
{
SAST, // static application security testing (source code)
DAST, // dynamic application security testing (running app)
SCA, // software composition analysis (dependencies)
ContainerScan // container image CVE scanning
}
/// Vulnerability scan schedule and thresholds.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class VulnerabilityScanAttribute : Attribute
{
public ScanKind Kind { get; }
public string MaxSeverity { get; init; } = "HIGH";
public string Schedule { get; init; } = "0 2 * * *";
public string[] ExcludePatterns { get; init; } = [];
public bool FailOnViolation { get; init; } = true;
public VulnerabilityScanAttribute(ScanKind kind) => Kind = kind;
}
/// Penetration test scope definition.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class PenetrationTestScopeAttribute : Attribute
{
public string[] Endpoints { get; }
public string Schedule { get; init; } = "quarterly";
public string Methodology { get; init; } = "OWASP";
public string[] ExcludeEndpoints { get; init; } = [];
public string ReportFormat { get; init; } = "PDF";
public PenetrationTestScopeAttribute(params string[] endpoints)
{
Endpoints = endpoints;
}
}Usage: Order Service Security Contract
[OpsTarget("order-service")]
// ── Security Policies ─────────────────────────────────────────
[SecurityPolicy("order-auth", SecurityDomain.Auth,
Description = "Authorization rules for order management",
ComplianceFramework = "SOC2")]
[SecurityPolicy("order-audit", SecurityDomain.Audit,
Description = "Audit trail for order operations",
Owner = "compliance-team")]
[SecurityPolicy("order-secrets", SecurityDomain.Secrets,
Description = "Secret rotation for order service dependencies")]
// ── Secret Rotation ───────────────────────────────────────────
[SecretRotation("order-db-password", "kv/order-service/db-password",
RotationPeriodDays = 90,
NotifyBeforeDays = 14,
NotifyChannels = ["#order-oncall", "security@company.com"],
AutoRotate = true)]
[SecretRotation("payment-api-key", "kv/order-service/payment-api-key",
RotationPeriodDays = 60,
NotifyBeforeDays = 7,
NotifyChannels = ["#order-oncall"])]
[SecretRotation("jwt-signing-key", "kv/order-service/jwt-signing-key",
RotationPeriodDays = 180,
NotifyBeforeDays = 30)]
// ── Vulnerability Scanning ────────────────────────────────────
[VulnerabilityScan(ScanKind.ContainerScan,
MaxSeverity = "HIGH",
Schedule = "0 2 * * *",
FailOnViolation = true)]
[VulnerabilityScan(ScanKind.SCA,
MaxSeverity = "CRITICAL",
Schedule = "0 3 * * 1",
ExcludePatterns = ["test/**"])]
[VulnerabilityScan(ScanKind.DAST,
MaxSeverity = "MEDIUM",
Schedule = "0 4 * * 0")]
// ── Penetration Test Scope ────────────────────────────────────
[PenetrationTestScope(
"POST /api/orders", "GET /api/orders/{id}", "PUT /api/orders/{id}/status",
"POST /api/orders/{id}/payment", "DELETE /api/orders/{id}",
Schedule = "quarterly",
Methodology = "OWASP",
ExcludeEndpoints = ["/health", "/ready"])]
public partial class OrderSecurityContract { }[OpsTarget("order-service")]
// ── Security Policies ─────────────────────────────────────────
[SecurityPolicy("order-auth", SecurityDomain.Auth,
Description = "Authorization rules for order management",
ComplianceFramework = "SOC2")]
[SecurityPolicy("order-audit", SecurityDomain.Audit,
Description = "Audit trail for order operations",
Owner = "compliance-team")]
[SecurityPolicy("order-secrets", SecurityDomain.Secrets,
Description = "Secret rotation for order service dependencies")]
// ── Secret Rotation ───────────────────────────────────────────
[SecretRotation("order-db-password", "kv/order-service/db-password",
RotationPeriodDays = 90,
NotifyBeforeDays = 14,
NotifyChannels = ["#order-oncall", "security@company.com"],
AutoRotate = true)]
[SecretRotation("payment-api-key", "kv/order-service/payment-api-key",
RotationPeriodDays = 60,
NotifyBeforeDays = 7,
NotifyChannels = ["#order-oncall"])]
[SecretRotation("jwt-signing-key", "kv/order-service/jwt-signing-key",
RotationPeriodDays = 180,
NotifyBeforeDays = 30)]
// ── Vulnerability Scanning ────────────────────────────────────
[VulnerabilityScan(ScanKind.ContainerScan,
MaxSeverity = "HIGH",
Schedule = "0 2 * * *",
FailOnViolation = true)]
[VulnerabilityScan(ScanKind.SCA,
MaxSeverity = "CRITICAL",
Schedule = "0 3 * * 1",
ExcludePatterns = ["test/**"])]
[VulnerabilityScan(ScanKind.DAST,
MaxSeverity = "MEDIUM",
Schedule = "0 4 * * 0")]
// ── Penetration Test Scope ────────────────────────────────────
[PenetrationTestScope(
"POST /api/orders", "GET /api/orders/{id}", "PUT /api/orders/{id}/status",
"POST /api/orders/{id}/payment", "DELETE /api/orders/{id}",
Schedule = "quarterly",
Methodology = "OWASP",
ExcludeEndpoints = ["/health", "/ready"])]
public partial class OrderSecurityContract { }Per-Endpoint RBAC
public partial class OrderSecurityContract
{
[RbacRule("Admin", "orders.create", "orders.read", "orders.update", "orders.delete",
Resource = "Order")]
[RbacRule("OrderManager", "orders.create", "orders.read", "orders.update",
Resource = "Order")]
[RbacRule("Viewer", "orders.read",
Resource = "Order")]
[AuditPolicy(AuditLevel.Write,
RetentionDays = 730,
SensitiveFields = ["PaymentCardLast4", "CustomerEmail"],
IncludePayload = true)]
public partial void OrderEndpoints();
[RbacRule("Admin", "orders.payment.process", "orders.payment.refund",
Resource = "Payment")]
[RbacRule("PaymentProcessor", "orders.payment.process",
Resource = "Payment",
Condition = "order.Status == OrderStatus.Confirmed")]
[AuditPolicy(AuditLevel.All,
RetentionDays = 2555, // 7 years for financial records
SensitiveFields = ["CardNumber", "CVV", "BillingAddress"],
IncludePayload = false)] // never log payment payloads
public partial void PaymentEndpoints();
}public partial class OrderSecurityContract
{
[RbacRule("Admin", "orders.create", "orders.read", "orders.update", "orders.delete",
Resource = "Order")]
[RbacRule("OrderManager", "orders.create", "orders.read", "orders.update",
Resource = "Order")]
[RbacRule("Viewer", "orders.read",
Resource = "Order")]
[AuditPolicy(AuditLevel.Write,
RetentionDays = 730,
SensitiveFields = ["PaymentCardLast4", "CustomerEmail"],
IncludePayload = true)]
public partial void OrderEndpoints();
[RbacRule("Admin", "orders.payment.process", "orders.payment.refund",
Resource = "Payment")]
[RbacRule("PaymentProcessor", "orders.payment.process",
Resource = "Payment",
Condition = "order.Status == OrderStatus.Confirmed")]
[AuditPolicy(AuditLevel.All,
RetentionDays = 2555, // 7 years for financial records
SensitiveFields = ["CardNumber", "CVV", "BillingAddress"],
IncludePayload = false)] // never log payment payloads
public partial void PaymentEndpoints();
}Three-tier projection
[SecurityPolicy], [RbacRule], and [AuditPolicy] declarations project across all three tiers, enforcing the same access rules at three different layers. Local runs get ASP.NET authorization policies and audit middleware (application-layer authz). Container runs add scanning (Trivy/ZAP) and a hardening overlay that locks down what the container process can do (capabilities, filesystem, syscalls). Cloud runs add Kubernetes RBAC that locks down what the workload identity can do via the K8s API.
The three layers are complementary, not duplicates. ASP.NET enforces "which user can call which endpoint." Container hardening enforces "what the running process can touch on the host." K8s RBAC enforces "which pods can kubectl get pods on the namespace." A defense-in-depth posture needs all three.
Local Tier: AuthorizationPolicies.g.cs
The generator produces ASP.NET authorization policy registration from [RbacRule] attributes.
// <auto-generated by Ops.Security.Generator />
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
public static class OrderAuthorizationPolicies
{
public static IServiceCollection AddOrderAuthorizationPolicies(
this IServiceCollection services)
{
services.AddAuthorizationBuilder()
// Order resource policies
.AddPolicy("Order.Create", policy =>
policy.RequireRole("Admin", "OrderManager")
.RequireClaim("permission", "orders.create"))
.AddPolicy("Order.Read", policy =>
policy.RequireRole("Admin", "OrderManager", "Viewer")
.RequireClaim("permission", "orders.read"))
.AddPolicy("Order.Update", policy =>
policy.RequireRole("Admin", "OrderManager")
.RequireClaim("permission", "orders.update"))
.AddPolicy("Order.Delete", policy =>
policy.RequireRole("Admin")
.RequireClaim("permission", "orders.delete"))
// Payment resource policies
.AddPolicy("Payment.Process", policy =>
policy.RequireRole("Admin", "PaymentProcessor")
.RequireClaim("permission", "orders.payment.process")
.AddRequirements(new OrderStatusRequirement(OrderStatus.Confirmed)))
.AddPolicy("Payment.Refund", policy =>
policy.RequireRole("Admin")
.RequireClaim("permission", "orders.payment.refund"));
// Register conditional requirement handler
services.AddSingleton<IAuthorizationHandler, OrderStatusRequirementHandler>();
return services;
}
}
/// Conditional authorization: PaymentProcessor can only process
/// payments for orders in Confirmed status.
public sealed class OrderStatusRequirement : IAuthorizationRequirement
{
public OrderStatus RequiredStatus { get; }
public OrderStatusRequirement(OrderStatus status) => RequiredStatus = status;
}
public sealed class OrderStatusRequirementHandler
: AuthorizationHandler<OrderStatusRequirement>
{
private readonly IOrderRepository _orders;
public OrderStatusRequirementHandler(IOrderRepository orders)
=> _orders = orders;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OrderStatusRequirement requirement)
{
if (context.Resource is HttpContext httpContext
&& httpContext.GetRouteValue("id") is string orderId)
{
var order = await _orders.GetByIdAsync(orderId);
if (order?.Status == requirement.RequiredStatus)
{
context.Succeed(requirement);
}
}
}
}// <auto-generated by Ops.Security.Generator />
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
public static class OrderAuthorizationPolicies
{
public static IServiceCollection AddOrderAuthorizationPolicies(
this IServiceCollection services)
{
services.AddAuthorizationBuilder()
// Order resource policies
.AddPolicy("Order.Create", policy =>
policy.RequireRole("Admin", "OrderManager")
.RequireClaim("permission", "orders.create"))
.AddPolicy("Order.Read", policy =>
policy.RequireRole("Admin", "OrderManager", "Viewer")
.RequireClaim("permission", "orders.read"))
.AddPolicy("Order.Update", policy =>
policy.RequireRole("Admin", "OrderManager")
.RequireClaim("permission", "orders.update"))
.AddPolicy("Order.Delete", policy =>
policy.RequireRole("Admin")
.RequireClaim("permission", "orders.delete"))
// Payment resource policies
.AddPolicy("Payment.Process", policy =>
policy.RequireRole("Admin", "PaymentProcessor")
.RequireClaim("permission", "orders.payment.process")
.AddRequirements(new OrderStatusRequirement(OrderStatus.Confirmed)))
.AddPolicy("Payment.Refund", policy =>
policy.RequireRole("Admin")
.RequireClaim("permission", "orders.payment.refund"));
// Register conditional requirement handler
services.AddSingleton<IAuthorizationHandler, OrderStatusRequirementHandler>();
return services;
}
}
/// Conditional authorization: PaymentProcessor can only process
/// payments for orders in Confirmed status.
public sealed class OrderStatusRequirement : IAuthorizationRequirement
{
public OrderStatus RequiredStatus { get; }
public OrderStatusRequirement(OrderStatus status) => RequiredStatus = status;
}
public sealed class OrderStatusRequirementHandler
: AuthorizationHandler<OrderStatusRequirement>
{
private readonly IOrderRepository _orders;
public OrderStatusRequirementHandler(IOrderRepository orders)
=> _orders = orders;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OrderStatusRequirement requirement)
{
if (context.Resource is HttpContext httpContext
&& httpContext.GetRouteValue("id") is string orderId)
{
var order = await _orders.GetByIdAsync(orderId);
if (order?.Status == requirement.RequiredStatus)
{
context.Succeed(requirement);
}
}
}
}AuditMiddleware.g.cs
Structured audit logging middleware generated from [AuditPolicy] attributes. Sensitive fields are automatically redacted.
// <auto-generated by Ops.Security.Generator />
using System.Text.Json;
using Microsoft.AspNetCore.Http;
public sealed class OrderAuditMiddleware : IMiddleware
{
private readonly IAuditLogger _auditLogger;
private readonly TimeProvider _timeProvider;
private static readonly Dictionary<string, AuditConfig> s_auditConfigs = new()
{
["/api/orders"] = new(
Level: AuditLevel.Write,
RetentionDays: 730,
SensitiveFields: ["PaymentCardLast4", "CustomerEmail"],
IncludePayload: true),
["/api/orders/{id}/payment"] = new(
Level: AuditLevel.All,
RetentionDays: 2555,
SensitiveFields: ["CardNumber", "CVV", "BillingAddress"],
IncludePayload: false),
};
public OrderAuditMiddleware(IAuditLogger auditLogger, TimeProvider timeProvider)
{
_auditLogger = auditLogger;
_timeProvider = timeProvider;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var path = context.Request.Path.Value ?? "";
var config = ResolveConfig(path);
if (config is null || !ShouldAudit(config, context.Request.Method))
{
await next(context);
return;
}
var entry = new AuditEntry
{
Timestamp = _timeProvider.GetUtcNow(),
UserId = context.User.FindFirst("sub")?.Value ?? "anonymous",
UserName = context.User.Identity?.Name ?? "unknown",
Action = $"{context.Request.Method} {path}",
Resource = ExtractResource(path),
IpAddress = context.Connection.RemoteIpAddress?.ToString() ?? "",
UserAgent = context.Request.Headers.UserAgent.ToString(),
CorrelationId = context.TraceIdentifier,
};
// Capture request payload (if configured and not sensitive)
if (config.IncludePayload)
{
context.Request.EnableBuffering();
using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
context.Request.Body.Position = 0;
entry.RequestPayload = RedactSensitiveFields(body, config.SensitiveFields);
}
await next(context);
entry.StatusCode = context.Response.StatusCode;
entry.Success = context.Response.StatusCode < 400;
entry.RetentionDays = config.RetentionDays;
await _auditLogger.LogAsync(entry);
}
private static string RedactSensitiveFields(string json, string[] sensitiveFields)
{
if (sensitiveFields.Length == 0) return json;
try
{
var doc = JsonDocument.Parse(json);
var redacted = new Dictionary<string, object?>();
foreach (var prop in doc.RootElement.EnumerateObject())
{
redacted[prop.Name] = sensitiveFields.Contains(prop.Name, StringComparer.OrdinalIgnoreCase)
? "***REDACTED***"
: prop.Value.Clone();
}
return JsonSerializer.Serialize(redacted);
}
catch
{
return "***UNPARSEABLE***";
}
}
private static bool ShouldAudit(AuditConfig config, string method) => config.Level switch
{
AuditLevel.All => true,
AuditLevel.Write => method is "POST" or "PUT" or "PATCH" or "DELETE",
AuditLevel.Read => method is "GET",
AuditLevel.Admin => false, // resolved via role check
_ => false,
};
private static AuditConfig? ResolveConfig(string path)
{
// Exact match first, then pattern match for path parameters
foreach (var (pattern, config) in s_auditConfigs)
{
if (PathMatches(path, pattern)) return config;
}
return null;
}
private static bool PathMatches(string path, string pattern)
{
// Simplified route matching — real implementation uses RoutePattern
var patternSegments = pattern.Split('/');
var pathSegments = path.Split('/');
if (patternSegments.Length != pathSegments.Length) return false;
for (int i = 0; i < patternSegments.Length; i++)
{
if (patternSegments[i].StartsWith('{')) continue; // path parameter
if (patternSegments[i] != pathSegments[i]) return false;
}
return true;
}
private static string ExtractResource(string path)
{
var segments = path.Trim('/').Split('/');
return segments.Length >= 2 ? segments[1] : "unknown";
}
}
public sealed record AuditConfig(
AuditLevel Level,
int RetentionDays,
string[] SensitiveFields,
bool IncludePayload);
public sealed class AuditEntry
{
public DateTimeOffset Timestamp { get; set; }
public string UserId { get; set; } = "";
public string UserName { get; set; } = "";
public string Action { get; set; } = "";
public string Resource { get; set; } = "";
public string IpAddress { get; set; } = "";
public string UserAgent { get; set; } = "";
public string CorrelationId { get; set; } = "";
public string? RequestPayload { get; set; }
public int StatusCode { get; set; }
public bool Success { get; set; }
public int RetentionDays { get; set; }
}// <auto-generated by Ops.Security.Generator />
using System.Text.Json;
using Microsoft.AspNetCore.Http;
public sealed class OrderAuditMiddleware : IMiddleware
{
private readonly IAuditLogger _auditLogger;
private readonly TimeProvider _timeProvider;
private static readonly Dictionary<string, AuditConfig> s_auditConfigs = new()
{
["/api/orders"] = new(
Level: AuditLevel.Write,
RetentionDays: 730,
SensitiveFields: ["PaymentCardLast4", "CustomerEmail"],
IncludePayload: true),
["/api/orders/{id}/payment"] = new(
Level: AuditLevel.All,
RetentionDays: 2555,
SensitiveFields: ["CardNumber", "CVV", "BillingAddress"],
IncludePayload: false),
};
public OrderAuditMiddleware(IAuditLogger auditLogger, TimeProvider timeProvider)
{
_auditLogger = auditLogger;
_timeProvider = timeProvider;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var path = context.Request.Path.Value ?? "";
var config = ResolveConfig(path);
if (config is null || !ShouldAudit(config, context.Request.Method))
{
await next(context);
return;
}
var entry = new AuditEntry
{
Timestamp = _timeProvider.GetUtcNow(),
UserId = context.User.FindFirst("sub")?.Value ?? "anonymous",
UserName = context.User.Identity?.Name ?? "unknown",
Action = $"{context.Request.Method} {path}",
Resource = ExtractResource(path),
IpAddress = context.Connection.RemoteIpAddress?.ToString() ?? "",
UserAgent = context.Request.Headers.UserAgent.ToString(),
CorrelationId = context.TraceIdentifier,
};
// Capture request payload (if configured and not sensitive)
if (config.IncludePayload)
{
context.Request.EnableBuffering();
using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
var body = await reader.ReadToEndAsync();
context.Request.Body.Position = 0;
entry.RequestPayload = RedactSensitiveFields(body, config.SensitiveFields);
}
await next(context);
entry.StatusCode = context.Response.StatusCode;
entry.Success = context.Response.StatusCode < 400;
entry.RetentionDays = config.RetentionDays;
await _auditLogger.LogAsync(entry);
}
private static string RedactSensitiveFields(string json, string[] sensitiveFields)
{
if (sensitiveFields.Length == 0) return json;
try
{
var doc = JsonDocument.Parse(json);
var redacted = new Dictionary<string, object?>();
foreach (var prop in doc.RootElement.EnumerateObject())
{
redacted[prop.Name] = sensitiveFields.Contains(prop.Name, StringComparer.OrdinalIgnoreCase)
? "***REDACTED***"
: prop.Value.Clone();
}
return JsonSerializer.Serialize(redacted);
}
catch
{
return "***UNPARSEABLE***";
}
}
private static bool ShouldAudit(AuditConfig config, string method) => config.Level switch
{
AuditLevel.All => true,
AuditLevel.Write => method is "POST" or "PUT" or "PATCH" or "DELETE",
AuditLevel.Read => method is "GET",
AuditLevel.Admin => false, // resolved via role check
_ => false,
};
private static AuditConfig? ResolveConfig(string path)
{
// Exact match first, then pattern match for path parameters
foreach (var (pattern, config) in s_auditConfigs)
{
if (PathMatches(path, pattern)) return config;
}
return null;
}
private static bool PathMatches(string path, string pattern)
{
// Simplified route matching — real implementation uses RoutePattern
var patternSegments = pattern.Split('/');
var pathSegments = path.Split('/');
if (patternSegments.Length != pathSegments.Length) return false;
for (int i = 0; i < patternSegments.Length; i++)
{
if (patternSegments[i].StartsWith('{')) continue; // path parameter
if (patternSegments[i] != pathSegments[i]) return false;
}
return true;
}
private static string ExtractResource(string path)
{
var segments = path.Trim('/').Split('/');
return segments.Length >= 2 ? segments[1] : "unknown";
}
}
public sealed record AuditConfig(
AuditLevel Level,
int RetentionDays,
string[] SensitiveFields,
bool IncludePayload);
public sealed class AuditEntry
{
public DateTimeOffset Timestamp { get; set; }
public string UserId { get; set; } = "";
public string UserName { get; set; } = "";
public string Action { get; set; } = "";
public string Resource { get; set; } = "";
public string IpAddress { get; set; } = "";
public string UserAgent { get; set; } = "";
public string CorrelationId { get; set; } = "";
public string? RequestPayload { get; set; }
public int StatusCode { get; set; }
public bool Success { get; set; }
public int RetentionDays { get; set; }
}SecretRotationService.g.cs
An IHostedService that monitors secret expiry and sends notifications before rotation is due.
// <auto-generated by Ops.Security.Generator />
using Microsoft.Extensions.Hosting;
public sealed class OrderSecretRotationService : BackgroundService
{
private readonly ISecretVaultClient _vault;
private readonly INotificationService _notifications;
private readonly ILogger<OrderSecretRotationService> _logger;
private readonly TimeProvider _timeProvider;
private static readonly SecretRotationConfig[] s_secrets =
[
new("order-db-password", "kv/order-service/db-password",
RotationPeriodDays: 90, NotifyBeforeDays: 14,
Channels: ["#order-oncall", "security@company.com"],
AutoRotate: true),
new("payment-api-key", "kv/order-service/payment-api-key",
RotationPeriodDays: 60, NotifyBeforeDays: 7,
Channels: ["#order-oncall"],
AutoRotate: false),
new("jwt-signing-key", "kv/order-service/jwt-signing-key",
RotationPeriodDays: 180, NotifyBeforeDays: 30,
Channels: [],
AutoRotate: false),
];
public OrderSecretRotationService(
ISecretVaultClient vault,
INotificationService notifications,
ILogger<OrderSecretRotationService> logger,
TimeProvider timeProvider)
{
_vault = vault;
_notifications = notifications;
_logger = logger;
_timeProvider = timeProvider;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
foreach (var secret in s_secrets)
{
try
{
var metadata = await _vault.GetSecretMetadataAsync(secret.VaultPath, ct);
var age = _timeProvider.GetUtcNow() - metadata.CreatedAt;
var daysUntilRotation = secret.RotationPeriodDays - age.Days;
if (daysUntilRotation <= 0)
{
_logger.LogCritical(
"Secret {Name} is OVERDUE for rotation by {Days} days",
secret.Name, Math.Abs(daysUntilRotation));
if (secret.AutoRotate)
{
await _vault.RotateSecretAsync(secret.VaultPath, ct);
_logger.LogInformation("Secret {Name} auto-rotated", secret.Name);
}
await NotifyAsync(secret,
$"Secret '{secret.Name}' is overdue for rotation by " +
$"{Math.Abs(daysUntilRotation)} days", ct);
}
else if (daysUntilRotation <= secret.NotifyBeforeDays)
{
_logger.LogWarning(
"Secret {Name} rotation due in {Days} days",
secret.Name, daysUntilRotation);
await NotifyAsync(secret,
$"Secret '{secret.Name}' rotation due in {daysUntilRotation} days", ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check secret {Name}", secret.Name);
}
}
await Task.Delay(TimeSpan.FromHours(6), ct);
}
}
private async Task NotifyAsync(SecretRotationConfig secret, string message, CancellationToken ct)
{
foreach (var channel in secret.Channels)
{
await _notifications.SendAsync(channel, message, ct);
}
}
}
public sealed record SecretRotationConfig(
string Name,
string VaultPath,
int RotationPeriodDays,
int NotifyBeforeDays,
string[] Channels,
bool AutoRotate);// <auto-generated by Ops.Security.Generator />
using Microsoft.Extensions.Hosting;
public sealed class OrderSecretRotationService : BackgroundService
{
private readonly ISecretVaultClient _vault;
private readonly INotificationService _notifications;
private readonly ILogger<OrderSecretRotationService> _logger;
private readonly TimeProvider _timeProvider;
private static readonly SecretRotationConfig[] s_secrets =
[
new("order-db-password", "kv/order-service/db-password",
RotationPeriodDays: 90, NotifyBeforeDays: 14,
Channels: ["#order-oncall", "security@company.com"],
AutoRotate: true),
new("payment-api-key", "kv/order-service/payment-api-key",
RotationPeriodDays: 60, NotifyBeforeDays: 7,
Channels: ["#order-oncall"],
AutoRotate: false),
new("jwt-signing-key", "kv/order-service/jwt-signing-key",
RotationPeriodDays: 180, NotifyBeforeDays: 30,
Channels: [],
AutoRotate: false),
];
public OrderSecretRotationService(
ISecretVaultClient vault,
INotificationService notifications,
ILogger<OrderSecretRotationService> logger,
TimeProvider timeProvider)
{
_vault = vault;
_notifications = notifications;
_logger = logger;
_timeProvider = timeProvider;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
foreach (var secret in s_secrets)
{
try
{
var metadata = await _vault.GetSecretMetadataAsync(secret.VaultPath, ct);
var age = _timeProvider.GetUtcNow() - metadata.CreatedAt;
var daysUntilRotation = secret.RotationPeriodDays - age.Days;
if (daysUntilRotation <= 0)
{
_logger.LogCritical(
"Secret {Name} is OVERDUE for rotation by {Days} days",
secret.Name, Math.Abs(daysUntilRotation));
if (secret.AutoRotate)
{
await _vault.RotateSecretAsync(secret.VaultPath, ct);
_logger.LogInformation("Secret {Name} auto-rotated", secret.Name);
}
await NotifyAsync(secret,
$"Secret '{secret.Name}' is overdue for rotation by " +
$"{Math.Abs(daysUntilRotation)} days", ct);
}
else if (daysUntilRotation <= secret.NotifyBeforeDays)
{
_logger.LogWarning(
"Secret {Name} rotation due in {Days} days",
secret.Name, daysUntilRotation);
await NotifyAsync(secret,
$"Secret '{secret.Name}' rotation due in {daysUntilRotation} days", ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to check secret {Name}", secret.Name);
}
}
await Task.Delay(TimeSpan.FromHours(6), ct);
}
}
private async Task NotifyAsync(SecretRotationConfig secret, string message, CancellationToken ct)
{
foreach (var channel in secret.Channels)
{
await _notifications.SendAsync(channel, message, ct);
}
}
}
public sealed record SecretRotationConfig(
string Name,
string VaultPath,
int RotationPeriodDays,
int NotifyBeforeDays,
string[] Channels,
bool AutoRotate);Container Tier: trivy-config.yaml
# Auto-generated by Ops.Security.Generator
# Source: OrderSecurityContract
# Trivy container scan configuration
severity:
- CRITICAL
- HIGH
exit-code: 1 # fail CI on violation
ignore-unfixed: true
timeout: 10m
# Scan schedule: daily at 02:00 UTC
# Implemented via CI pipeline cron trigger
vulnerability:
type:
- os
- library
secret:
config: trivy-secret.yaml# Auto-generated by Ops.Security.Generator
# Source: OrderSecurityContract
# Trivy container scan configuration
severity:
- CRITICAL
- HIGH
exit-code: 1 # fail CI on violation
ignore-unfixed: true
timeout: 10m
# Scan schedule: daily at 02:00 UTC
# Implemented via CI pipeline cron trigger
vulnerability:
type:
- os
- library
secret:
config: trivy-secret.yamlContainer Tier: zap-scan-config.yaml
# Auto-generated by Ops.Security.Generator
# Source: OrderSecurityContract (DAST scan)
env:
contexts:
- name: order-service
urls:
- http://order-api:5000
includePaths:
- "http://order-api:5000/api/orders.*"
excludePaths:
- "http://order-api:5000/health"
- "http://order-api:5000/ready"
authentication:
method: json
parameters:
loginUrl: "http://order-api:5000/api/auth/token"
loginRequestData: '{"username":"dast-scanner","password":"${DAST_PASSWORD}"}'
parameters:
failOnRisk: Medium
progressToStdout: true
jobs:
- type: spider
parameters:
context: order-service
maxDuration: 5
- type: activeScan
parameters:
context: order-service
maxRuleDurationInMins: 10
policy: Default Policy
- type: report
parameters:
template: modern
reportDir: /zap/reports
reportFile: order-service-dast.html
risks:
- High
- Medium
- Low# Auto-generated by Ops.Security.Generator
# Source: OrderSecurityContract (DAST scan)
env:
contexts:
- name: order-service
urls:
- http://order-api:5000
includePaths:
- "http://order-api:5000/api/orders.*"
excludePaths:
- "http://order-api:5000/health"
- "http://order-api:5000/ready"
authentication:
method: json
parameters:
loginUrl: "http://order-api:5000/api/auth/token"
loginRequestData: '{"username":"dast-scanner","password":"${DAST_PASSWORD}"}'
parameters:
failOnRisk: Medium
progressToStdout: true
jobs:
- type: spider
parameters:
context: order-service
maxDuration: 5
- type: activeScan
parameters:
context: order-service
maxRuleDurationInMins: 10
policy: Default Policy
- type: report
parameters:
template: modern
reportDir: /zap/reports
reportFile: order-service-dast.html
risks:
- High
- Medium
- LowContainer Tier: docker-compose.security.yaml (hardening overlay)
The scanners above (trivy-config.yaml, zap-scan-config.yaml) detect vulnerabilities. They do not prevent a compromised process from doing damage. The hardening overlay does -- it drops every Linux capability the container doesn't strictly need, makes the root filesystem read-only, runs as a non-root user, and forbids privilege escalation. Generated from [SecurityPolicy] declarations on the Order service.
# <auto-generated by Ops.Security.Generator />
# docker-compose.security.yaml -- Hardening overlay for OrderSecurityContract
# Usage: docker compose -f docker-compose.ops.yaml -f docker-compose.security.yaml up
services:
order-api:
user: "1000:1000" # non-root, matches the Dockerfile USER directive
read_only: true # rootfs is immutable
tmpfs:
- /tmp:size=64M,mode=1777 # writable scratch dir for ASP.NET temp files
- /var/run:size=8M
cap_drop:
- ALL # drop every capability
cap_add: [] # add none back -- order-api doesn't need any
security_opt:
- no-new-privileges:true # forbid privilege escalation via setuid binaries
- seccomp=./seccomp-profile.json
- apparmor=order-service-profile
pids_limit: 200 # process explosion guard
ulimits:
nofile:
soft: 4096
hard: 8192
sysctls:
net.ipv4.ip_unprivileged_port_start: 0
labels:
ops.security/policy: order-service-strict
ops.security/source: "OrderSecurityContract.cs"# <auto-generated by Ops.Security.Generator />
# docker-compose.security.yaml -- Hardening overlay for OrderSecurityContract
# Usage: docker compose -f docker-compose.ops.yaml -f docker-compose.security.yaml up
services:
order-api:
user: "1000:1000" # non-root, matches the Dockerfile USER directive
read_only: true # rootfs is immutable
tmpfs:
- /tmp:size=64M,mode=1777 # writable scratch dir for ASP.NET temp files
- /var/run:size=8M
cap_drop:
- ALL # drop every capability
cap_add: [] # add none back -- order-api doesn't need any
security_opt:
- no-new-privileges:true # forbid privilege escalation via setuid binaries
- seccomp=./seccomp-profile.json
- apparmor=order-service-profile
pids_limit: 200 # process explosion guard
ulimits:
nofile:
soft: 4096
hard: 8192
sysctls:
net.ipv4.ip_unprivileged_port_start: 0
labels:
ops.security/policy: order-service-strict
ops.security/source: "OrderSecurityContract.cs"The same hardening flags translate 1:1 to the K8s Pod.spec.securityContext block in the Deployment manifest at the Cloud tier -- one source declaration, two enforcement formats.
Container Tier: seccomp-profile.json
Syscall allow-list generated from [SecurityPolicy]. The Order API needs only the syscalls a normal ASP.NET Core process makes (no mount, no ptrace, no kexec_load). Anything outside the allow-list returns EPERM.
{
"$comment": "Auto-generated by Ops.Security.Generator from [SecurityPolicy] on OrderSecurityContract",
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": 1,
"architectures": [
"SCMP_ARCH_X86_64",
"SCMP_ARCH_X86",
"SCMP_ARCH_X32"
],
"syscalls": [
{
"names": [
"accept", "accept4", "bind", "brk", "close", "connect", "dup", "dup2",
"epoll_create1", "epoll_ctl", "epoll_wait", "execve", "exit", "exit_group",
"fcntl", "fstat", "fsync", "futex", "getcwd", "getdents64", "getpid",
"getppid", "getrandom", "gettid", "ioctl", "listen", "lseek", "madvise",
"mmap", "mprotect", "munmap", "nanosleep", "open", "openat", "poll", "ppoll",
"pread64", "pwrite64", "read", "readv", "recvfrom", "recvmsg", "rt_sigaction",
"rt_sigprocmask", "rt_sigreturn", "sched_yield", "sendfile", "sendmsg",
"sendto", "set_robust_list", "setsockopt", "socket", "socketpair", "stat",
"statx", "sysinfo", "tgkill", "uname", "wait4", "write", "writev"
],
"action": "SCMP_ACT_ALLOW"
}
]
}{
"$comment": "Auto-generated by Ops.Security.Generator from [SecurityPolicy] on OrderSecurityContract",
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": 1,
"architectures": [
"SCMP_ARCH_X86_64",
"SCMP_ARCH_X86",
"SCMP_ARCH_X32"
],
"syscalls": [
{
"names": [
"accept", "accept4", "bind", "brk", "close", "connect", "dup", "dup2",
"epoll_create1", "epoll_ctl", "epoll_wait", "execve", "exit", "exit_group",
"fcntl", "fstat", "fsync", "futex", "getcwd", "getdents64", "getpid",
"getppid", "getrandom", "gettid", "ioctl", "listen", "lseek", "madvise",
"mmap", "mprotect", "munmap", "nanosleep", "open", "openat", "poll", "ppoll",
"pread64", "pwrite64", "read", "readv", "recvfrom", "recvmsg", "rt_sigaction",
"rt_sigprocmask", "rt_sigreturn", "sched_yield", "sendfile", "sendmsg",
"sendto", "set_robust_list", "setsockopt", "socket", "socketpair", "stat",
"statx", "sysinfo", "tgkill", "uname", "wait4", "write", "writev"
],
"action": "SCMP_ACT_ALLOW"
}
]
}A future analyzer (SEC005) will diff this list against new syscalls observed by strace during integration tests, alerting when the application starts needing capabilities the security model didn't grant.
Container Tier: apparmor-profile
# <auto-generated by Ops.Security.Generator />
# AppArmor profile for order-api container
# Loaded via the docker-compose.security.yaml overlay
#include <tunables/global>
profile order-service-profile flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
#include <abstractions/nameservice>
# Read-only access to the application binaries
/app/** r,
/app/OrderApi mr, # mmap+read for the .NET binary
# Writable scratch dirs (matches the tmpfs mounts in the compose overlay)
/tmp/** rwk,
/var/run/** rw,
# Network access (the API listens on a single port)
network inet stream,
network inet6 stream,
# Deny everything dangerous
deny /etc/shadow r,
deny /root/** rwklx,
deny /proc/sys/** w,
deny /sys/** w,
deny mount,
deny ptrace,
deny capability sys_admin,
deny capability sys_module,
deny capability sys_ptrace,
}# <auto-generated by Ops.Security.Generator />
# AppArmor profile for order-api container
# Loaded via the docker-compose.security.yaml overlay
#include <tunables/global>
profile order-service-profile flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
#include <abstractions/nameservice>
# Read-only access to the application binaries
/app/** r,
/app/OrderApi mr, # mmap+read for the .NET binary
# Writable scratch dirs (matches the tmpfs mounts in the compose overlay)
/tmp/** rwk,
/var/run/** rw,
# Network access (the API listens on a single port)
network inet stream,
network inet6 stream,
# Deny everything dangerous
deny /etc/shadow r,
deny /root/** rwklx,
deny /proc/sys/** w,
deny /sys/** w,
deny mount,
deny ptrace,
deny capability sys_admin,
deny capability sys_module,
deny capability sys_ptrace,
}Cloud Tier: Penetration Test Scope and Scan Schedules
# Auto-generated by Ops.Security.Generator
# terraform/security/order-scans/main.tf
# Container vulnerability scanning (daily)
resource "azurerm_container_registry_task" "trivy_scan" {
name = "order-service-trivy-daily"
container_registry_id = var.acr_id
platform {
os = "Linux"
}
timer_trigger {
name = "daily-scan"
schedule = "0 2 * * *"
}
docker_step {
dockerfile_path = "Dockerfile.trivy"
context_path = "https://github.com/org/order-service.git#main"
arguments = {
severity = "HIGH,CRITICAL"
exit_code = "1"
}
}
}
# Secret rotation monitoring
resource "azurerm_monitor_scheduled_query_rules_alert_v2" "secret_expiry" {
name = "order-secrets-expiry-warning"
resource_group_name = var.resource_group_name
location = var.location
scopes = [var.log_analytics_workspace_id]
criteria {
query = <<-QUERY
AppTraces
| where AppRoleName == "order-service"
| where Message contains "rotation due" or Message contains "OVERDUE"
| where TimeGenerated > ago(1h)
| summarize count() by Message
QUERY
time_aggregation_method = "Count"
operator = "GreaterThan"
threshold = 0
}
action {
action_groups = [var.security_action_group_id]
}
}# Auto-generated by Ops.Security.Generator
# terraform/security/order-scans/main.tf
# Container vulnerability scanning (daily)
resource "azurerm_container_registry_task" "trivy_scan" {
name = "order-service-trivy-daily"
container_registry_id = var.acr_id
platform {
os = "Linux"
}
timer_trigger {
name = "daily-scan"
schedule = "0 2 * * *"
}
docker_step {
dockerfile_path = "Dockerfile.trivy"
context_path = "https://github.com/org/order-service.git#main"
arguments = {
severity = "HIGH,CRITICAL"
exit_code = "1"
}
}
}
# Secret rotation monitoring
resource "azurerm_monitor_scheduled_query_rules_alert_v2" "secret_expiry" {
name = "order-secrets-expiry-warning"
resource_group_name = var.resource_group_name
location = var.location
scopes = [var.log_analytics_workspace_id]
criteria {
query = <<-QUERY
AppTraces
| where AppRoleName == "order-service"
| where Message contains "rotation due" or Message contains "OVERDUE"
| where TimeGenerated > ago(1h)
| summarize count() by Message
QUERY
time_aggregation_method = "Count"
operator = "GreaterThan"
threshold = 0
}
action {
action_groups = [var.security_action_group_id]
}
}Cloud Tier: k8s/serviceaccount.yaml
For Kubernetes the same [SecurityPolicy] principal becomes a ServiceAccount. One SA per principal, annotated with the audit-policy retention class so auditors can correlate cluster-API access with application audit logs.
# <auto-generated by Ops.Security.Generator />
# k8s/serviceaccount.yaml -- Workload identity for OrderSecurityContract
# Source: [SecurityPolicy] attributes on OrderSecurityContract
apiVersion: v1
kind: ServiceAccount
metadata:
name: order-service
namespace: orders
labels:
app.kubernetes.io/name: order-service
app.kubernetes.io/managed-by: ops.security.generator
annotations:
# Audit retention class -- correlates with [AuditPolicy(RetentionDays = 2555)]
ops.security/audit-retention-days: "2555"
ops.security/principal: order-service
ops.security/source: "OrderSecurityContract.cs"
# Azure Workload Identity binding (cluster-specific)
azure.workload.identity/client-id: "${ORDER_SERVICE_AZURE_CLIENT_ID}"
automountServiceAccountToken: true
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: order-payment-processor
namespace: orders
labels:
app.kubernetes.io/name: order-payment-processor
app.kubernetes.io/managed-by: ops.security.generator
annotations:
ops.security/audit-retention-days: "2555" # 7 years for financial records
ops.security/principal: payment-processor
ops.security/source: "OrderSecurityContract.cs"
automountServiceAccountToken: true# <auto-generated by Ops.Security.Generator />
# k8s/serviceaccount.yaml -- Workload identity for OrderSecurityContract
# Source: [SecurityPolicy] attributes on OrderSecurityContract
apiVersion: v1
kind: ServiceAccount
metadata:
name: order-service
namespace: orders
labels:
app.kubernetes.io/name: order-service
app.kubernetes.io/managed-by: ops.security.generator
annotations:
# Audit retention class -- correlates with [AuditPolicy(RetentionDays = 2555)]
ops.security/audit-retention-days: "2555"
ops.security/principal: order-service
ops.security/source: "OrderSecurityContract.cs"
# Azure Workload Identity binding (cluster-specific)
azure.workload.identity/client-id: "${ORDER_SERVICE_AZURE_CLIENT_ID}"
automountServiceAccountToken: true
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: order-payment-processor
namespace: orders
labels:
app.kubernetes.io/name: order-payment-processor
app.kubernetes.io/managed-by: ops.security.generator
annotations:
ops.security/audit-retention-days: "2555" # 7 years for financial records
ops.security/principal: payment-processor
ops.security/source: "OrderSecurityContract.cs"
automountServiceAccountToken: trueCloud Tier: k8s/role.yaml
A Role per [RbacRule]. The permission strings (Order.Create, Order.Read, Payment.Process) are the exact values from AuthorizationPolicies.g.cs above -- the C# layer and the K8s layer share one source of truth. Rename a permission in the DSL declaration and both layers update on the next build.
# <auto-generated by Ops.Security.Generator />
# k8s/role.yaml -- Cluster-API permissions for OrderSecurityContract
# Source: [RbacRule] attributes on OrderSecurityContract
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: order-service-role
namespace: orders
labels:
app.kubernetes.io/name: order-service
app.kubernetes.io/managed-by: ops.security.generator
annotations:
# Permission names mirror ASP.NET policy names from AuthorizationPolicies.g.cs
ops.security/policies: "Order.Create,Order.Read,Order.Update,Order.Delete"
rules:
# Read its own ConfigMap (the one Configuration DSL emits at the Cloud tier)
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["order-service-config"]
verbs: ["get", "watch"]
# Read its own ExternalSecret material (External Secrets Operator stores it as v1 Secret)
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["order-service-secrets"]
verbs: ["get", "watch"]
# Read its own Pods for self-discovery (used by Kestrel for graceful shutdown coordination)
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: order-payment-processor-role
namespace: orders
labels:
app.kubernetes.io/name: order-payment-processor
app.kubernetes.io/managed-by: ops.security.generator
annotations:
ops.security/policies: "Payment.Process,Payment.Refund"
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["payment-gateway-secrets"]
verbs: ["get", "watch"]# <auto-generated by Ops.Security.Generator />
# k8s/role.yaml -- Cluster-API permissions for OrderSecurityContract
# Source: [RbacRule] attributes on OrderSecurityContract
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: order-service-role
namespace: orders
labels:
app.kubernetes.io/name: order-service
app.kubernetes.io/managed-by: ops.security.generator
annotations:
# Permission names mirror ASP.NET policy names from AuthorizationPolicies.g.cs
ops.security/policies: "Order.Create,Order.Read,Order.Update,Order.Delete"
rules:
# Read its own ConfigMap (the one Configuration DSL emits at the Cloud tier)
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["order-service-config"]
verbs: ["get", "watch"]
# Read its own ExternalSecret material (External Secrets Operator stores it as v1 Secret)
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["order-service-secrets"]
verbs: ["get", "watch"]
# Read its own Pods for self-discovery (used by Kestrel for graceful shutdown coordination)
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: order-payment-processor-role
namespace: orders
labels:
app.kubernetes.io/name: order-payment-processor
app.kubernetes.io/managed-by: ops.security.generator
annotations:
ops.security/policies: "Payment.Process,Payment.Refund"
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["payment-gateway-secrets"]
verbs: ["get", "watch"]Cloud Tier: k8s/rolebinding.yaml
RoleBinding wires the ServiceAccounts to the Roles. The group names (Admin, OrderManager, PaymentProcessor) are the exact same identifiers used in the RequireRole(...) calls in AuthorizationPolicies.g.cs. The application-layer authz and the cluster-layer authz speak the same vocabulary.
# <auto-generated by Ops.Security.Generator />
# k8s/rolebinding.yaml -- ServiceAccount → Role bindings
# Source: [SecurityPolicy] + [RbacRule] on OrderSecurityContract
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: order-service-binding
namespace: orders
labels:
app.kubernetes.io/name: order-service
app.kubernetes.io/managed-by: ops.security.generator
annotations:
# Subject role names mirror RequireRole(...) calls in AuthorizationPolicies.g.cs
ops.security/aspnet-roles: "Admin,OrderManager,Viewer"
subjects:
- kind: ServiceAccount
name: order-service
namespace: orders
roleRef:
kind: Role
name: order-service-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: order-payment-processor-binding
namespace: orders
labels:
app.kubernetes.io/name: order-payment-processor
app.kubernetes.io/managed-by: ops.security.generator
annotations:
ops.security/aspnet-roles: "Admin,PaymentProcessor"
subjects:
- kind: ServiceAccount
name: order-payment-processor
namespace: orders
roleRef:
kind: Role
name: order-payment-processor-role
apiGroup: rbac.authorization.k8s.io# <auto-generated by Ops.Security.Generator />
# k8s/rolebinding.yaml -- ServiceAccount → Role bindings
# Source: [SecurityPolicy] + [RbacRule] on OrderSecurityContract
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: order-service-binding
namespace: orders
labels:
app.kubernetes.io/name: order-service
app.kubernetes.io/managed-by: ops.security.generator
annotations:
# Subject role names mirror RequireRole(...) calls in AuthorizationPolicies.g.cs
ops.security/aspnet-roles: "Admin,OrderManager,Viewer"
subjects:
- kind: ServiceAccount
name: order-service
namespace: orders
roleRef:
kind: Role
name: order-service-role
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: order-payment-processor-binding
namespace: orders
labels:
app.kubernetes.io/name: order-payment-processor
app.kubernetes.io/managed-by: ops.security.generator
annotations:
ops.security/aspnet-roles: "Admin,PaymentProcessor"
subjects:
- kind: ServiceAccount
name: order-payment-processor
namespace: orders
roleRef:
kind: Role
name: order-payment-processor-role
apiGroup: rbac.authorization.k8s.ioWhy both layers? ASP.NET policies enforce application-layer authz: which authenticated user can call
POST /api/orders. Kubernetes RBAC enforces cluster-layer authz: which workload identity cankubectl get configmapsin theordersnamespace. They are complementary, not duplicates. A defense-in-depth posture needs both: a compromised application token shouldn't unlock the cluster API, and a compromised cluster credential shouldn't bypass the application's row-level security. Same applies to the Container hardening overlay vs the K8s RBAC: the overlay locks down what the container process can do at the OS level; the RBAC locks down what the workload identity can do at the K8s API level.
Generated RBAC Unit Tests
The generator also produces a test class that verifies every [RbacRule] is actually enforced:
// <auto-generated by Ops.Security.Generator />
public class OrderRbacTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public OrderRbacTests(WebApplicationFactory<Program> factory)
=> _factory = factory;
[Theory]
[InlineData("Admin", "POST", "/api/orders", 201)]
[InlineData("OrderManager", "POST", "/api/orders", 201)]
[InlineData("Viewer", "POST", "/api/orders", 403)]
[InlineData("Admin", "GET", "/api/orders/1", 200)]
[InlineData("Viewer", "GET", "/api/orders/1", 200)]
[InlineData("Admin", "DELETE", "/api/orders/1", 200)]
[InlineData("OrderManager", "DELETE", "/api/orders/1", 403)]
[InlineData("Viewer", "DELETE", "/api/orders/1", 403)]
public async Task Rbac_Enforcement(
string role, string method, string path, int expectedStatus)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokens.ForRole(role));
var request = new HttpRequestMessage(new HttpMethod(method), path);
if (method is "POST" or "PUT")
{
request.Content = JsonContent.Create(OrderTestData.MinimalPayload);
}
var response = await client.SendAsync(request);
Assert.Equal((HttpStatusCode)expectedStatus, response.StatusCode);
}
[Fact]
public async Task Anonymous_Access_Denied_On_All_Endpoints()
{
var client = _factory.CreateClient();
// No Authorization header
var endpoints = new[]
{
("POST", "/api/orders"),
("GET", "/api/orders/1"),
("PUT", "/api/orders/1/status"),
("DELETE", "/api/orders/1"),
("POST", "/api/orders/1/payment"),
};
foreach (var (method, path) in endpoints)
{
var request = new HttpRequestMessage(new HttpMethod(method), path);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
}// <auto-generated by Ops.Security.Generator />
public class OrderRbacTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public OrderRbacTests(WebApplicationFactory<Program> factory)
=> _factory = factory;
[Theory]
[InlineData("Admin", "POST", "/api/orders", 201)]
[InlineData("OrderManager", "POST", "/api/orders", 201)]
[InlineData("Viewer", "POST", "/api/orders", 403)]
[InlineData("Admin", "GET", "/api/orders/1", 200)]
[InlineData("Viewer", "GET", "/api/orders/1", 200)]
[InlineData("Admin", "DELETE", "/api/orders/1", 200)]
[InlineData("OrderManager", "DELETE", "/api/orders/1", 403)]
[InlineData("Viewer", "DELETE", "/api/orders/1", 403)]
public async Task Rbac_Enforcement(
string role, string method, string path, int expectedStatus)
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokens.ForRole(role));
var request = new HttpRequestMessage(new HttpMethod(method), path);
if (method is "POST" or "PUT")
{
request.Content = JsonContent.Create(OrderTestData.MinimalPayload);
}
var response = await client.SendAsync(request);
Assert.Equal((HttpStatusCode)expectedStatus, response.StatusCode);
}
[Fact]
public async Task Anonymous_Access_Denied_On_All_Endpoints()
{
var client = _factory.CreateClient();
// No Authorization header
var endpoints = new[]
{
("POST", "/api/orders"),
("GET", "/api/orders/1"),
("PUT", "/api/orders/1/status"),
("DELETE", "/api/orders/1"),
("POST", "/api/orders/1/payment"),
};
foreach (var (method, path) in endpoints)
{
var request = new HttpRequestMessage(new HttpMethod(method), path);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
}Analyzer Diagnostics
| ID | Severity | Rule | Example |
|---|---|---|---|
| SEC001 | Error | Entity with sensitive data but no AuditPolicy | Class Patient has [SensitiveData("SSN")] attribute but no [AuditPolicy]. All access to sensitive data must be audited. |
| SEC002 | Warning | Secret without rotation policy | [Secret("db-password")] in Configuration DSL has no matching [SecretRotation] in Security DSL. Secrets must have rotation schedules. |
| SEC003 | Error | Public endpoint without RbacRule | Controller action DeleteOrder has [HttpDelete] and is not marked [AllowAnonymous], but has no [RbacRule] or [Authorize] attribute. Default-deny means this endpoint is inaccessible — is that intentional? |
| SEC004 | Warning | DAST scan referencing nonexistent endpoint | [PenetrationTestScope] includes /api/products but no controller exposes that path. The scan will report false 404s. |
| SEC005 | Warning | Audit retention below compliance minimum | [AuditPolicy(RetentionDays = 90)] on a financial entity. SOC2 requires 365 days minimum for financial records. |
| SEC006 | Info | RbacRule role not found in identity provider | [RbacRule("SuperAdmin", ...)] but the identity provider configuration does not include role SuperAdmin. The policy will never match. |
SEC003 is the most important. It catches the "forgot to add authorization" bug at compile time. Every public endpoint must either have an [RbacRule], an [Authorize] attribute, or an explicit [AllowAnonymous]. No silent defaults.
Security to DataGovernance
Entities marked with [AuditPolicy(SensitiveFields = ["SSN", "Email"])] must have corresponding GDPR data maps in the DataGovernance DSL. The analyzer verifies:
// Security declares sensitive fields
[AuditPolicy(AuditLevel.All, SensitiveFields = ["SSN", "DateOfBirth"])]
public class PatientRecord { }
// DataGovernance must have a matching data map — SEC007 fires if missing
[DataMap(typeof(PatientRecord),
Classification = DataClassification.PII,
LawfulBasis = "Consent",
RetentionDays = 2555)]// Security declares sensitive fields
[AuditPolicy(AuditLevel.All, SensitiveFields = ["SSN", "DateOfBirth"])]
public class PatientRecord { }
// DataGovernance must have a matching data map — SEC007 fires if missing
[DataMap(typeof(PatientRecord),
Classification = DataClassification.PII,
LawfulBasis = "Consent",
RetentionDays = 2555)]Security to Configuration
Every [SecretRotation] references a vault path. The Configuration DSL's [Secret] attribute must declare the same vault path. The analyzer verifies they agree:
// Configuration DSL declares the secret exists
[Secret("order-db-password", Vault = "azure-keyvault",
VaultPath = "kv/order-service/db-password")]
// Security DSL declares the rotation policy
[SecretRotation("order-db-password", "kv/order-service/db-password",
RotationPeriodDays = 90)]// Configuration DSL declares the secret exists
[Secret("order-db-password", Vault = "azure-keyvault",
VaultPath = "kv/order-service/db-password")]
// Security DSL declares the rotation policy
[SecretRotation("order-db-password", "kv/order-service/db-password",
RotationPeriodDays = 90)]If the vault paths differ, SEC008 fires. If a [Secret] has no matching [SecretRotation], SEC002 fires.
Security to Workflow
The [RbacRule] Role values integrate with the Workflow DSL's RequiresRole concept. If a workflow transition requires the OrderManager role, the Security DSL verifies that the role exists in the RBAC configuration:
// Workflow DSL declares role requirement
[WorkflowTransition("Confirmed", "Shipped", RequiresRole = "OrderManager")]
// Security DSL has the role defined in [RbacRule]
[RbacRule("OrderManager", "orders.update", Resource = "Order")]// Workflow DSL declares role requirement
[WorkflowTransition("Confirmed", "Shipped", RequiresRole = "OrderManager")]
// Security DSL has the role defined in [RbacRule]
[RbacRule("OrderManager", "orders.update", Resource = "Order")]If the workflow references a role that no [RbacRule] defines, the analyzer flags the inconsistency: the workflow transition will always fail because the authorization policy does not exist.
Security to Chaos
Security policies should be chaos-tested. Can the system handle an authentication provider outage? Is the audit logger resilient to its own storage failing? The Chaos DSL can target security infrastructure:
[ChaosExperiment("auth-provider-timeout", Tier = OpsExecutionTier.Container,
Hypothesis = "When the identity provider times out, the service returns 503 " +
"and does not fall back to allowing unauthenticated access")]
[TargetService(typeof(IAuthenticationService))]
[FaultInjection(FaultKind.Timeout, DelayMs = 30_000)]
[SteadyStateProbe("unauthenticated_access_count", 0, Operator = "==")][ChaosExperiment("auth-provider-timeout", Tier = OpsExecutionTier.Container,
Hypothesis = "When the identity provider times out, the service returns 503 " +
"and does not fall back to allowing unauthenticated access")]
[TargetService(typeof(IAuthenticationService))]
[FaultInjection(FaultKind.Timeout, DelayMs = 30_000)]
[SteadyStateProbe("unauthenticated_access_count", 0, Operator = "==")]This tests the most dangerous security failure mode: a timeout that silently bypasses authentication. The steady-state probe verifies that zero unauthenticated requests succeed during the outage.