Refactor code structure and optimize performance across multiple modules

This commit is contained in:
StellaOps Bot
2025-12-26 20:03:22 +02:00
parent c786faae84
commit b4fc66feb6
3353 changed files with 88254 additions and 1590657 deletions

View File

@@ -0,0 +1,461 @@
-- Policy Schema Migration 013: Exception Approval Workflow
-- Implements role-based exception approval workflows
-- Sprint: SPRINT_20251226_003_BE_exception_approval
-- Category: A (safe, can run at startup)
--
-- Purpose: Add approval workflow infrastructure:
-- - Approval request entity with multi-approver support
-- - Gate-level approval requirements (G1=peer, G2=code owner, G3+=DM+PM)
-- - Time-limited overrides with TTL enforcement
-- - Comprehensive audit trail
BEGIN;
-- ============================================================================
-- Step 1: Create exception_approval_requests table
-- ============================================================================
CREATE TABLE IF NOT EXISTS policy.exception_approval_requests (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Request identifier for external reference (EAR-XXXXX)
request_id TEXT NOT NULL UNIQUE,
-- Multi-tenancy
tenant_id TEXT NOT NULL,
-- Reference to parent exception (can be NULL for new exception requests)
exception_id TEXT,
-- Who requested the exception
requestor_id TEXT NOT NULL,
-- Required approvers based on gate level
required_approver_ids TEXT[] NOT NULL DEFAULT '{}',
-- Approvers who have approved (subset of required)
approved_by_ids TEXT[] NOT NULL DEFAULT '{}',
-- Approvers who rejected
rejected_by_id TEXT,
-- Request status
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'partial', 'approved', 'rejected', 'expired', 'cancelled')),
-- Gate level determining approval requirements (0-4)
gate_level INTEGER NOT NULL DEFAULT 1
CHECK (gate_level >= 0 AND gate_level <= 4),
-- Justification for the exception
justification TEXT NOT NULL,
-- Detailed rationale (min 50 chars for G2+)
rationale TEXT,
-- Categorized reason code
reason_code TEXT NOT NULL DEFAULT 'other'
CHECK (reason_code IN (
'false_positive', 'accepted_risk', 'compensating_control',
'test_only', 'vendor_not_affected', 'scheduled_fix',
'deprecation_in_progress', 'runtime_mitigation',
'network_isolation', 'other'
)),
-- Content-addressed evidence references
evidence_refs JSONB NOT NULL DEFAULT '[]',
-- Compensating controls in place
compensating_controls JSONB NOT NULL DEFAULT '[]',
-- External ticket reference (e.g., JIRA-1234)
ticket_ref TEXT,
-- Scope: vulnerability ID (CVE-XXXX-XXXXX)
vulnerability_id TEXT,
-- Scope: PURL pattern
purl_pattern TEXT,
-- Scope: specific artifact digest
artifact_digest TEXT,
-- Scope: image reference pattern
image_pattern TEXT,
-- Scope: environments (empty = all)
environments TEXT[] NOT NULL DEFAULT '{}',
-- Requested TTL in days
requested_ttl_days INTEGER NOT NULL DEFAULT 30
CHECK (requested_ttl_days > 0 AND requested_ttl_days <= 365),
-- When the request was created
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- When the request expires (auto-reject after)
request_expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'),
-- When the exception would expire if approved
exception_expires_at TIMESTAMPTZ,
-- When the request was resolved (approved/rejected/cancelled)
resolved_at TIMESTAMPTZ,
-- Rejection reason (if rejected)
rejection_reason TEXT,
-- Additional metadata
metadata JSONB NOT NULL DEFAULT '{}',
-- Version for optimistic concurrency
version INTEGER NOT NULL DEFAULT 1,
-- Last update timestamp
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================================
-- Step 2: Create exception_approval_audit table
-- ============================================================================
CREATE TABLE IF NOT EXISTS policy.exception_approval_audit (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Reference to approval request
request_id TEXT NOT NULL,
-- Multi-tenancy
tenant_id TEXT NOT NULL,
-- Sequence number within this request's audit trail
sequence_number INTEGER NOT NULL,
-- Action type
action_type TEXT NOT NULL
CHECK (action_type IN (
'requested', 'approved', 'rejected', 'escalated',
'reminder_sent', 'expired', 'cancelled', 'evidence_added',
'approver_added', 'approver_removed', 'ttl_extended'
)),
-- Identity of the actor
actor_id TEXT NOT NULL,
-- When this action occurred
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Previous status
previous_status TEXT,
-- New status after action
new_status TEXT NOT NULL,
-- Human-readable description
description TEXT,
-- Additional structured details
details JSONB NOT NULL DEFAULT '{}',
-- Client info for audit (IP, user agent, correlation ID)
client_info JSONB NOT NULL DEFAULT '{}',
-- Unique sequence per request
UNIQUE (request_id, sequence_number)
);
-- ============================================================================
-- Step 3: Create approval_rules table for configurable requirements
-- ============================================================================
CREATE TABLE IF NOT EXISTS policy.exception_approval_rules (
-- Primary key
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Multi-tenancy
tenant_id TEXT NOT NULL,
-- Rule name
name TEXT NOT NULL,
-- Rule description
description TEXT,
-- Gate level this rule applies to
gate_level INTEGER NOT NULL
CHECK (gate_level >= 0 AND gate_level <= 4),
-- Minimum number of approvers required
min_approvers INTEGER NOT NULL DEFAULT 1
CHECK (min_approvers >= 0 AND min_approvers <= 10),
-- Required approver roles (e.g., 'code-owner', 'security-lead', 'pm')
required_roles TEXT[] NOT NULL DEFAULT '{}',
-- Max TTL allowed in days
max_ttl_days INTEGER NOT NULL DEFAULT 30
CHECK (max_ttl_days > 0 AND max_ttl_days <= 365),
-- Whether self-approval is allowed
allow_self_approval BOOLEAN NOT NULL DEFAULT false,
-- Whether evidence is required
require_evidence BOOLEAN NOT NULL DEFAULT false,
-- Whether compensating controls are required
require_compensating_controls BOOLEAN NOT NULL DEFAULT false,
-- Minimum rationale length
min_rationale_length INTEGER NOT NULL DEFAULT 0
CHECK (min_rationale_length >= 0 AND min_rationale_length <= 1000),
-- Rule priority (higher = more specific)
priority INTEGER NOT NULL DEFAULT 0,
-- Whether rule is active
enabled BOOLEAN NOT NULL DEFAULT true,
-- When the rule was created
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- When the rule was last updated
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Unique rule per tenant and gate level
UNIQUE (tenant_id, gate_level, name)
);
-- ============================================================================
-- Step 4: Create indexes
-- ============================================================================
-- Approval requests: tenant lookup
CREATE INDEX IF NOT EXISTS idx_approval_requests_tenant
ON policy.exception_approval_requests(tenant_id);
-- Approval requests: status filter
CREATE INDEX IF NOT EXISTS idx_approval_requests_status
ON policy.exception_approval_requests(tenant_id, status);
-- Approval requests: requestor lookup
CREATE INDEX IF NOT EXISTS idx_approval_requests_requestor
ON policy.exception_approval_requests(requestor_id);
-- Approval requests: pending approvals for approver
CREATE INDEX IF NOT EXISTS idx_approval_requests_pending
ON policy.exception_approval_requests(tenant_id, status)
WHERE status IN ('pending', 'partial');
-- Approval requests: expiry check
CREATE INDEX IF NOT EXISTS idx_approval_requests_expiry
ON policy.exception_approval_requests(request_expires_at)
WHERE status IN ('pending', 'partial');
-- Approval requests: vulnerability lookup
CREATE INDEX IF NOT EXISTS idx_approval_requests_vuln
ON policy.exception_approval_requests(vulnerability_id)
WHERE vulnerability_id IS NOT NULL;
-- Audit: request lookup
CREATE INDEX IF NOT EXISTS idx_approval_audit_request
ON policy.exception_approval_audit(request_id);
-- Audit: time-based queries (BRIN for append-only pattern)
CREATE INDEX IF NOT EXISTS idx_approval_audit_time
ON policy.exception_approval_audit USING BRIN (occurred_at);
-- Rules: tenant and gate level lookup
CREATE INDEX IF NOT EXISTS idx_approval_rules_lookup
ON policy.exception_approval_rules(tenant_id, gate_level, enabled);
-- ============================================================================
-- Step 5: Enable Row-Level Security
-- ============================================================================
ALTER TABLE policy.exception_approval_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.exception_approval_audit ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy.exception_approval_rules ENABLE ROW LEVEL SECURITY;
-- RLS policies for approval requests
DROP POLICY IF EXISTS approval_requests_tenant_isolation ON policy.exception_approval_requests;
CREATE POLICY approval_requests_tenant_isolation ON policy.exception_approval_requests
FOR ALL
USING (tenant_id = current_setting('app.current_tenant', true));
-- RLS policies for audit
DROP POLICY IF EXISTS approval_audit_tenant_isolation ON policy.exception_approval_audit;
CREATE POLICY approval_audit_tenant_isolation ON policy.exception_approval_audit
FOR ALL
USING (tenant_id = current_setting('app.current_tenant', true));
-- RLS policies for rules
DROP POLICY IF EXISTS approval_rules_tenant_isolation ON policy.exception_approval_rules;
CREATE POLICY approval_rules_tenant_isolation ON policy.exception_approval_rules
FOR ALL
USING (tenant_id = current_setting('app.current_tenant', true));
-- ============================================================================
-- Step 6: Insert default approval rules
-- ============================================================================
-- Default rules for common tenant (will be copied to new tenants)
INSERT INTO policy.exception_approval_rules
(id, tenant_id, name, description, gate_level, min_approvers, required_roles,
max_ttl_days, allow_self_approval, require_evidence, min_rationale_length, priority)
VALUES
-- G0: No approval needed (auto-approve)
(gen_random_uuid(), '__default__', 'g0_auto',
'Informational findings - auto-approved', 0, 0, '{}',
90, true, false, 0, 100),
-- G1: One peer approval
(gen_random_uuid(), '__default__', 'g1_peer',
'Low severity - peer review required', 1, 1, '{}',
60, true, false, 20, 100),
-- G2: Code owner approval
(gen_random_uuid(), '__default__', 'g2_owner',
'Medium severity - code owner approval required', 2, 1, ARRAY['code-owner'],
30, false, true, 50, 100),
-- G3: DM + PM approval
(gen_random_uuid(), '__default__', 'g3_leadership',
'High severity - leadership approval required', 3, 2, ARRAY['delivery-manager', 'product-manager'],
14, false, true, 100, 100),
-- G4: CISO + DM + PM approval
(gen_random_uuid(), '__default__', 'g4_executive',
'Critical severity - executive approval required', 4, 3, ARRAY['ciso', 'delivery-manager', 'product-manager'],
7, false, true, 200, 100)
ON CONFLICT DO NOTHING;
-- ============================================================================
-- Step 7: Create helper functions
-- ============================================================================
-- Function to expire pending approval requests
CREATE OR REPLACE FUNCTION policy.expire_pending_approval_requests()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
expired_count INTEGER;
BEGIN
WITH expired AS (
UPDATE policy.exception_approval_requests
SET
status = 'expired',
resolved_at = NOW(),
version = version + 1,
updated_at = NOW()
WHERE
status IN ('pending', 'partial')
AND request_expires_at <= NOW()
RETURNING request_id, tenant_id, version
),
audit_entries AS (
INSERT INTO policy.exception_approval_audit (
request_id,
tenant_id,
sequence_number,
action_type,
actor_id,
occurred_at,
previous_status,
new_status,
description
)
SELECT
e.request_id,
e.tenant_id,
COALESCE(
(SELECT MAX(sequence_number) + 1
FROM policy.exception_approval_audit
WHERE request_id = e.request_id),
1
),
'expired',
'system',
NOW(),
'pending',
'expired',
'Approval request expired without sufficient approvals'
FROM expired e
RETURNING request_id
)
SELECT COUNT(*) INTO expired_count FROM audit_entries;
RETURN expired_count;
END;
$$;
-- Function to get approval requirements for a gate level
CREATE OR REPLACE FUNCTION policy.get_approval_requirements(
p_tenant_id TEXT,
p_gate_level INTEGER
)
RETURNS TABLE (
min_approvers INTEGER,
required_roles TEXT[],
max_ttl_days INTEGER,
allow_self_approval BOOLEAN,
require_evidence BOOLEAN,
require_compensating_controls BOOLEAN,
min_rationale_length INTEGER
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
r.min_approvers,
r.required_roles,
r.max_ttl_days,
r.allow_self_approval,
r.require_evidence,
r.require_compensating_controls,
r.min_rationale_length
FROM policy.exception_approval_rules r
WHERE (r.tenant_id = p_tenant_id OR r.tenant_id = '__default__')
AND r.gate_level = p_gate_level
AND r.enabled = true
ORDER BY
CASE WHEN r.tenant_id = p_tenant_id THEN 0 ELSE 1 END,
r.priority DESC
LIMIT 1;
-- Return default if no rule found
IF NOT FOUND THEN
RETURN QUERY SELECT 1, ARRAY[]::TEXT[], 30, false, false, false, 0;
END IF;
END;
$$;
-- ============================================================================
-- Step 8: Add comments for documentation
-- ============================================================================
COMMENT ON TABLE policy.exception_approval_requests IS
'Approval workflow requests for policy exceptions';
COMMENT ON TABLE policy.exception_approval_audit IS
'Immutable audit trail of approval workflow actions';
COMMENT ON TABLE policy.exception_approval_rules IS
'Configurable approval requirements by gate level';
COMMENT ON COLUMN policy.exception_approval_requests.gate_level IS
'Gate level: 0=info, 1=low, 2=medium, 3=high, 4=critical';
COMMENT ON COLUMN policy.exception_approval_requests.status IS
'Workflow status: pending → partial → approved/rejected/expired/cancelled';
COMMENT ON FUNCTION policy.expire_pending_approval_requests() IS
'Marks pending approval requests as expired. Returns count of expired.';
COMMENT ON FUNCTION policy.get_approval_requirements(TEXT, INTEGER) IS
'Gets approval requirements for a tenant and gate level.';
COMMIT;

View File

@@ -0,0 +1,246 @@
namespace StellaOps.Policy.Storage.Postgres.Models;
/// <summary>
/// Approval request status enumeration.
/// </summary>
public enum ApprovalRequestStatus
{
/// <summary>Request pending approval.</summary>
Pending,
/// <summary>Request partially approved (needs more approvers).</summary>
Partial,
/// <summary>Request fully approved.</summary>
Approved,
/// <summary>Request rejected by an approver.</summary>
Rejected,
/// <summary>Request expired without resolution.</summary>
Expired,
/// <summary>Request cancelled by requestor.</summary>
Cancelled
}
/// <summary>
/// Gate level for determining approval requirements.
/// </summary>
public enum GateLevel
{
/// <summary>Informational - auto-approve.</summary>
G0 = 0,
/// <summary>Low severity - peer review.</summary>
G1 = 1,
/// <summary>Medium severity - code owner.</summary>
G2 = 2,
/// <summary>High severity - leadership.</summary>
G3 = 3,
/// <summary>Critical severity - executive.</summary>
G4 = 4
}
/// <summary>
/// Reason codes for exception requests.
/// </summary>
public enum ExceptionReasonCode
{
FalsePositive,
AcceptedRisk,
CompensatingControl,
TestOnly,
VendorNotAffected,
ScheduledFix,
DeprecationInProgress,
RuntimeMitigation,
NetworkIsolation,
Other
}
/// <summary>
/// Entity representing an exception approval request.
/// </summary>
public sealed class ExceptionApprovalRequestEntity
{
/// <summary>Unique identifier.</summary>
public required Guid Id { get; init; }
/// <summary>External request identifier (EAR-XXXXX).</summary>
public required string RequestId { get; init; }
/// <summary>Tenant identifier.</summary>
public required string TenantId { get; init; }
/// <summary>Reference to parent exception (null for new requests).</summary>
public string? ExceptionId { get; init; }
/// <summary>User who requested the exception.</summary>
public required string RequestorId { get; init; }
/// <summary>Required approvers based on gate level.</summary>
public required string[] RequiredApproverIds { get; init; }
/// <summary>Approvers who have approved.</summary>
public string[] ApprovedByIds { get; init; } = [];
/// <summary>Approver who rejected (if any).</summary>
public string? RejectedById { get; init; }
/// <summary>Request status.</summary>
public ApprovalRequestStatus Status { get; init; } = ApprovalRequestStatus.Pending;
/// <summary>Gate level determining approval requirements.</summary>
public GateLevel GateLevel { get; init; } = GateLevel.G1;
/// <summary>Justification for the exception.</summary>
public required string Justification { get; init; }
/// <summary>Detailed rationale.</summary>
public string? Rationale { get; init; }
/// <summary>Categorized reason code.</summary>
public ExceptionReasonCode ReasonCode { get; init; } = ExceptionReasonCode.Other;
/// <summary>Content-addressed evidence references as JSON.</summary>
public string EvidenceRefs { get; init; } = "[]";
/// <summary>Compensating controls as JSON.</summary>
public string CompensatingControls { get; init; } = "[]";
/// <summary>External ticket reference.</summary>
public string? TicketRef { get; init; }
/// <summary>Scope: vulnerability ID.</summary>
public string? VulnerabilityId { get; init; }
/// <summary>Scope: PURL pattern.</summary>
public string? PurlPattern { get; init; }
/// <summary>Scope: artifact digest.</summary>
public string? ArtifactDigest { get; init; }
/// <summary>Scope: image reference pattern.</summary>
public string? ImagePattern { get; init; }
/// <summary>Scope: environments.</summary>
public string[] Environments { get; init; } = [];
/// <summary>Requested TTL in days.</summary>
public int RequestedTtlDays { get; init; } = 30;
/// <summary>When the request was created.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>When the request expires.</summary>
public DateTimeOffset RequestExpiresAt { get; init; }
/// <summary>When the exception would expire if approved.</summary>
public DateTimeOffset? ExceptionExpiresAt { get; init; }
/// <summary>When the request was resolved.</summary>
public DateTimeOffset? ResolvedAt { get; init; }
/// <summary>Rejection reason.</summary>
public string? RejectionReason { get; init; }
/// <summary>Additional metadata as JSON.</summary>
public string Metadata { get; init; } = "{}";
/// <summary>Version for optimistic concurrency.</summary>
public int Version { get; init; } = 1;
/// <summary>Last update timestamp.</summary>
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Entity representing an approval audit entry.
/// </summary>
public sealed class ExceptionApprovalAuditEntity
{
/// <summary>Unique identifier.</summary>
public required Guid Id { get; init; }
/// <summary>Reference to approval request.</summary>
public required string RequestId { get; init; }
/// <summary>Tenant identifier.</summary>
public required string TenantId { get; init; }
/// <summary>Sequence number within request's audit trail.</summary>
public required int SequenceNumber { get; init; }
/// <summary>Action type.</summary>
public required string ActionType { get; init; }
/// <summary>Identity of the actor.</summary>
public required string ActorId { get; init; }
/// <summary>When this action occurred.</summary>
public DateTimeOffset OccurredAt { get; init; }
/// <summary>Previous status.</summary>
public string? PreviousStatus { get; init; }
/// <summary>New status after action.</summary>
public required string NewStatus { get; init; }
/// <summary>Human-readable description.</summary>
public string? Description { get; init; }
/// <summary>Additional structured details as JSON.</summary>
public string Details { get; init; } = "{}";
/// <summary>Client info as JSON.</summary>
public string ClientInfo { get; init; } = "{}";
}
/// <summary>
/// Entity representing approval rules by gate level.
/// </summary>
public sealed class ExceptionApprovalRuleEntity
{
/// <summary>Unique identifier.</summary>
public required Guid Id { get; init; }
/// <summary>Tenant identifier.</summary>
public required string TenantId { get; init; }
/// <summary>Rule name.</summary>
public required string Name { get; init; }
/// <summary>Rule description.</summary>
public string? Description { get; init; }
/// <summary>Gate level this rule applies to.</summary>
public GateLevel GateLevel { get; init; }
/// <summary>Minimum number of approvers required.</summary>
public int MinApprovers { get; init; } = 1;
/// <summary>Required approver roles.</summary>
public string[] RequiredRoles { get; init; } = [];
/// <summary>Max TTL allowed in days.</summary>
public int MaxTtlDays { get; init; } = 30;
/// <summary>Whether self-approval is allowed.</summary>
public bool AllowSelfApproval { get; init; }
/// <summary>Whether evidence is required.</summary>
public bool RequireEvidence { get; init; }
/// <summary>Whether compensating controls are required.</summary>
public bool RequireCompensatingControls { get; init; }
/// <summary>Minimum rationale length.</summary>
public int MinRationaleLength { get; init; }
/// <summary>Rule priority.</summary>
public int Priority { get; init; }
/// <summary>Whether rule is active.</summary>
public bool Enabled { get; init; } = true;
/// <summary>When the rule was created.</summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>When the rule was last updated.</summary>
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,745 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for exception approval workflow operations.
/// </summary>
public sealed class ExceptionApprovalRepository : RepositoryBase<PolicyDataSource>, IExceptionApprovalRepository
{
public ExceptionApprovalRepository(PolicyDataSource dataSource, ILogger<ExceptionApprovalRepository> logger)
: base(dataSource, logger)
{
}
// ========================================================================
// Approval Request Operations
// ========================================================================
public async Task<ExceptionApprovalRequestEntity> CreateRequestAsync(
ExceptionApprovalRequestEntity request,
CancellationToken ct = default)
{
const string sql = """
INSERT INTO policy.exception_approval_requests (
id, request_id, tenant_id, exception_id, requestor_id,
required_approver_ids, approved_by_ids, status, gate_level,
justification, rationale, reason_code, evidence_refs,
compensating_controls, ticket_ref, vulnerability_id, purl_pattern,
artifact_digest, image_pattern, environments, requested_ttl_days,
created_at, request_expires_at, exception_expires_at, metadata, version, updated_at
)
VALUES (
@id, @request_id, @tenant_id, @exception_id, @requestor_id,
@required_approver_ids, @approved_by_ids, @status, @gate_level,
@justification, @rationale, @reason_code, @evidence_refs::jsonb,
@compensating_controls::jsonb, @ticket_ref, @vulnerability_id, @purl_pattern,
@artifact_digest, @image_pattern, @environments, @requested_ttl_days,
@created_at, @request_expires_at, @exception_expires_at, @metadata::jsonb, @version, @updated_at
)
RETURNING *
""";
await using var conn = await DataSource.OpenConnectionAsync(request.TenantId, "writer", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "id", request.Id);
AddParameter(cmd, "request_id", request.RequestId);
AddParameter(cmd, "tenant_id", request.TenantId);
AddParameter(cmd, "exception_id", request.ExceptionId);
AddParameter(cmd, "requestor_id", request.RequestorId);
AddParameter(cmd, "required_approver_ids", request.RequiredApproverIds);
AddParameter(cmd, "approved_by_ids", request.ApprovedByIds);
AddParameter(cmd, "status", request.Status.ToString().ToLowerInvariant());
AddParameter(cmd, "gate_level", (int)request.GateLevel);
AddParameter(cmd, "justification", request.Justification);
AddParameter(cmd, "rationale", request.Rationale);
AddParameter(cmd, "reason_code", MapReasonCode(request.ReasonCode));
AddParameter(cmd, "evidence_refs", request.EvidenceRefs);
AddParameter(cmd, "compensating_controls", request.CompensatingControls);
AddParameter(cmd, "ticket_ref", request.TicketRef);
AddParameter(cmd, "vulnerability_id", request.VulnerabilityId);
AddParameter(cmd, "purl_pattern", request.PurlPattern);
AddParameter(cmd, "artifact_digest", request.ArtifactDigest);
AddParameter(cmd, "image_pattern", request.ImagePattern);
AddParameter(cmd, "environments", request.Environments);
AddParameter(cmd, "requested_ttl_days", request.RequestedTtlDays);
AddParameter(cmd, "created_at", request.CreatedAt);
AddParameter(cmd, "request_expires_at", request.RequestExpiresAt);
AddParameter(cmd, "exception_expires_at", request.ExceptionExpiresAt);
AddParameter(cmd, "metadata", request.Metadata);
AddParameter(cmd, "version", request.Version);
AddParameter(cmd, "updated_at", request.UpdatedAt);
await using var reader = await cmd.ExecuteReaderAsync(ct);
await reader.ReadAsync(ct);
return MapApprovalRequest(reader);
}
public async Task<ExceptionApprovalRequestEntity?> GetRequestAsync(
string tenantId,
string requestId,
CancellationToken ct = default)
{
const string sql = """
SELECT * FROM policy.exception_approval_requests
WHERE tenant_id = @tenant_id AND request_id = @request_id
""";
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "request_id", requestId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return MapApprovalRequest(reader);
}
return null;
}
public async Task<ExceptionApprovalRequestEntity?> GetRequestByIdAsync(
string tenantId,
Guid id,
CancellationToken ct = default)
{
const string sql = """
SELECT * FROM policy.exception_approval_requests
WHERE tenant_id = @tenant_id AND id = @id
""";
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return MapApprovalRequest(reader);
}
return null;
}
public async Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListRequestsAsync(
string tenantId,
ApprovalRequestStatus? status = null,
int limit = 100,
int offset = 0,
CancellationToken ct = default)
{
var sql = """
SELECT * FROM policy.exception_approval_requests
WHERE tenant_id = @tenant_id
""";
if (status.HasValue)
{
sql += " AND status = @status";
}
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "tenant_id", tenantId);
if (status.HasValue)
{
AddParameter(cmd, "status", status.Value.ToString().ToLowerInvariant());
}
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
var results = new List<ExceptionApprovalRequestEntity>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapApprovalRequest(reader));
}
return results;
}
public async Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListPendingForApproverAsync(
string tenantId,
string approverId,
int limit = 100,
CancellationToken ct = default)
{
const string sql = """
SELECT * FROM policy.exception_approval_requests
WHERE tenant_id = @tenant_id
AND status IN ('pending', 'partial')
AND @approver_id = ANY(required_approver_ids)
AND NOT (@approver_id = ANY(approved_by_ids))
ORDER BY created_at ASC
LIMIT @limit
""";
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "approver_id", approverId);
AddParameter(cmd, "limit", limit);
var results = new List<ExceptionApprovalRequestEntity>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapApprovalRequest(reader));
}
return results;
}
public async Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListByRequestorAsync(
string tenantId,
string requestorId,
int limit = 100,
CancellationToken ct = default)
{
const string sql = """
SELECT * FROM policy.exception_approval_requests
WHERE tenant_id = @tenant_id AND requestor_id = @requestor_id
ORDER BY created_at DESC
LIMIT @limit
""";
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "requestor_id", requestorId);
AddParameter(cmd, "limit", limit);
var results = new List<ExceptionApprovalRequestEntity>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapApprovalRequest(reader));
}
return results;
}
public async Task<bool> UpdateRequestAsync(
ExceptionApprovalRequestEntity request,
int expectedVersion,
CancellationToken ct = default)
{
const string sql = """
UPDATE policy.exception_approval_requests
SET approved_by_ids = @approved_by_ids,
rejected_by_id = @rejected_by_id,
status = @status,
resolved_at = @resolved_at,
rejection_reason = @rejection_reason,
version = @new_version,
updated_at = @updated_at
WHERE tenant_id = @tenant_id AND request_id = @request_id AND version = @expected_version
""";
await using var conn = await DataSource.OpenConnectionAsync(request.TenantId, "writer", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "tenant_id", request.TenantId);
AddParameter(cmd, "request_id", request.RequestId);
AddParameter(cmd, "approved_by_ids", request.ApprovedByIds);
AddParameter(cmd, "rejected_by_id", request.RejectedById);
AddParameter(cmd, "status", request.Status.ToString().ToLowerInvariant());
AddParameter(cmd, "resolved_at", request.ResolvedAt);
AddParameter(cmd, "rejection_reason", request.RejectionReason);
AddParameter(cmd, "new_version", request.Version);
AddParameter(cmd, "expected_version", expectedVersion);
AddParameter(cmd, "updated_at", request.UpdatedAt);
var rows = await cmd.ExecuteNonQueryAsync(ct);
return rows == 1;
}
public async Task<ExceptionApprovalRequestEntity?> ApproveAsync(
string tenantId,
string requestId,
string approverId,
string? comment,
CancellationToken ct = default)
{
var request = await GetRequestAsync(tenantId, requestId, ct);
if (request is null)
return null;
if (request.Status is not (ApprovalRequestStatus.Pending or ApprovalRequestStatus.Partial))
return request;
// Add approver to approved list
var approvedByIds = request.ApprovedByIds.Append(approverId).Distinct().ToArray();
var requiredCount = request.RequiredApproverIds.Length;
var approvedCount = approvedByIds.Length;
var newStatus = approvedCount >= requiredCount
? ApprovalRequestStatus.Approved
: ApprovalRequestStatus.Partial;
var updated = request with
{
ApprovedByIds = approvedByIds,
Status = newStatus,
ResolvedAt = newStatus == ApprovalRequestStatus.Approved ? DateTimeOffset.UtcNow : null,
Version = request.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow
};
if (await UpdateRequestAsync(updated, request.Version, ct))
{
// Record audit entry
await RecordAuditAsync(new ExceptionApprovalAuditEntity
{
Id = Guid.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
SequenceNumber = await GetNextSequenceAsync(tenantId, requestId, ct),
ActionType = "approved",
ActorId = approverId,
OccurredAt = DateTimeOffset.UtcNow,
PreviousStatus = request.Status.ToString().ToLowerInvariant(),
NewStatus = newStatus.ToString().ToLowerInvariant(),
Description = comment ?? $"Approved by {approverId}"
}, ct);
return updated;
}
return null;
}
public async Task<ExceptionApprovalRequestEntity?> RejectAsync(
string tenantId,
string requestId,
string rejectorId,
string reason,
CancellationToken ct = default)
{
var request = await GetRequestAsync(tenantId, requestId, ct);
if (request is null)
return null;
if (request.Status is not (ApprovalRequestStatus.Pending or ApprovalRequestStatus.Partial))
return request;
var updated = request with
{
RejectedById = rejectorId,
Status = ApprovalRequestStatus.Rejected,
ResolvedAt = DateTimeOffset.UtcNow,
RejectionReason = reason,
Version = request.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow
};
if (await UpdateRequestAsync(updated, request.Version, ct))
{
await RecordAuditAsync(new ExceptionApprovalAuditEntity
{
Id = Guid.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
SequenceNumber = await GetNextSequenceAsync(tenantId, requestId, ct),
ActionType = "rejected",
ActorId = rejectorId,
OccurredAt = DateTimeOffset.UtcNow,
PreviousStatus = request.Status.ToString().ToLowerInvariant(),
NewStatus = "rejected",
Description = reason
}, ct);
return updated;
}
return null;
}
public async Task<bool> CancelRequestAsync(
string tenantId,
string requestId,
string actorId,
string? reason,
CancellationToken ct = default)
{
var request = await GetRequestAsync(tenantId, requestId, ct);
if (request is null)
return false;
if (request.Status is not (ApprovalRequestStatus.Pending or ApprovalRequestStatus.Partial))
return false;
var updated = request with
{
Status = ApprovalRequestStatus.Cancelled,
ResolvedAt = DateTimeOffset.UtcNow,
Version = request.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow
};
if (await UpdateRequestAsync(updated, request.Version, ct))
{
await RecordAuditAsync(new ExceptionApprovalAuditEntity
{
Id = Guid.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
SequenceNumber = await GetNextSequenceAsync(tenantId, requestId, ct),
ActionType = "cancelled",
ActorId = actorId,
OccurredAt = DateTimeOffset.UtcNow,
PreviousStatus = request.Status.ToString().ToLowerInvariant(),
NewStatus = "cancelled",
Description = reason ?? "Request cancelled by requestor"
}, ct);
return true;
}
return false;
}
public async Task<int> ExpirePendingRequestsAsync(CancellationToken ct = default)
{
const string sql = "SELECT policy.expire_pending_approval_requests()";
await using var conn = await DataSource.OpenSystemConnectionAsync(ct);
await using var cmd = CreateCommand(sql, conn);
var result = await cmd.ExecuteScalarAsync(ct);
return Convert.ToInt32(result);
}
// ========================================================================
// Audit Trail Operations
// ========================================================================
public async Task<ExceptionApprovalAuditEntity> RecordAuditAsync(
ExceptionApprovalAuditEntity audit,
CancellationToken ct = default)
{
const string sql = """
INSERT INTO policy.exception_approval_audit (
id, request_id, tenant_id, sequence_number, action_type,
actor_id, occurred_at, previous_status, new_status,
description, details, client_info
)
VALUES (
@id, @request_id, @tenant_id, @sequence_number, @action_type,
@actor_id, @occurred_at, @previous_status, @new_status,
@description, @details::jsonb, @client_info::jsonb
)
RETURNING *
""";
await using var conn = await DataSource.OpenConnectionAsync(audit.TenantId, "writer", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "id", audit.Id);
AddParameter(cmd, "request_id", audit.RequestId);
AddParameter(cmd, "tenant_id", audit.TenantId);
AddParameter(cmd, "sequence_number", audit.SequenceNumber);
AddParameter(cmd, "action_type", audit.ActionType);
AddParameter(cmd, "actor_id", audit.ActorId);
AddParameter(cmd, "occurred_at", audit.OccurredAt);
AddParameter(cmd, "previous_status", audit.PreviousStatus);
AddParameter(cmd, "new_status", audit.NewStatus);
AddParameter(cmd, "description", audit.Description);
AddParameter(cmd, "details", audit.Details);
AddParameter(cmd, "client_info", audit.ClientInfo);
await using var reader = await cmd.ExecuteReaderAsync(ct);
await reader.ReadAsync(ct);
return MapAuditEntry(reader);
}
public async Task<IReadOnlyList<ExceptionApprovalAuditEntity>> GetAuditTrailAsync(
string tenantId,
string requestId,
CancellationToken ct = default)
{
const string sql = """
SELECT * FROM policy.exception_approval_audit
WHERE tenant_id = @tenant_id AND request_id = @request_id
ORDER BY sequence_number ASC
""";
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "request_id", requestId);
var results = new List<ExceptionApprovalAuditEntity>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapAuditEntry(reader));
}
return results;
}
private async Task<int> GetNextSequenceAsync(string tenantId, string requestId, CancellationToken ct)
{
const string sql = """
SELECT COALESCE(MAX(sequence_number), 0) + 1
FROM policy.exception_approval_audit
WHERE request_id = @request_id
""";
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "request_id", requestId);
var result = await cmd.ExecuteScalarAsync(ct);
return Convert.ToInt32(result);
}
// ========================================================================
// Approval Rules Operations
// ========================================================================
public async Task<ExceptionApprovalRuleEntity?> GetApprovalRuleAsync(
string tenantId,
GateLevel gateLevel,
CancellationToken ct = default)
{
const string sql = """
SELECT * FROM policy.exception_approval_rules
WHERE (tenant_id = @tenant_id OR tenant_id = '__default__')
AND gate_level = @gate_level
AND enabled = true
ORDER BY
CASE WHEN tenant_id = @tenant_id THEN 0 ELSE 1 END,
priority DESC
LIMIT 1
""";
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "gate_level", (int)gateLevel);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
return MapApprovalRule(reader);
}
return null;
}
public async Task<IReadOnlyList<ExceptionApprovalRuleEntity>> ListApprovalRulesAsync(
string tenantId,
CancellationToken ct = default)
{
const string sql = """
SELECT * FROM policy.exception_approval_rules
WHERE tenant_id = @tenant_id OR tenant_id = '__default__'
ORDER BY gate_level, priority DESC
""";
await using var conn = await DataSource.OpenConnectionAsync(tenantId, "reader", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "tenant_id", tenantId);
var results = new List<ExceptionApprovalRuleEntity>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapApprovalRule(reader));
}
return results;
}
public async Task<ExceptionApprovalRuleEntity> UpsertApprovalRuleAsync(
ExceptionApprovalRuleEntity rule,
CancellationToken ct = default)
{
const string sql = """
INSERT INTO policy.exception_approval_rules (
id, tenant_id, name, description, gate_level, min_approvers,
required_roles, max_ttl_days, allow_self_approval, require_evidence,
require_compensating_controls, min_rationale_length, priority, enabled,
created_at, updated_at
)
VALUES (
@id, @tenant_id, @name, @description, @gate_level, @min_approvers,
@required_roles, @max_ttl_days, @allow_self_approval, @require_evidence,
@require_compensating_controls, @min_rationale_length, @priority, @enabled,
@created_at, @updated_at
)
ON CONFLICT (tenant_id, gate_level, name) DO UPDATE SET
description = EXCLUDED.description,
min_approvers = EXCLUDED.min_approvers,
required_roles = EXCLUDED.required_roles,
max_ttl_days = EXCLUDED.max_ttl_days,
allow_self_approval = EXCLUDED.allow_self_approval,
require_evidence = EXCLUDED.require_evidence,
require_compensating_controls = EXCLUDED.require_compensating_controls,
min_rationale_length = EXCLUDED.min_rationale_length,
priority = EXCLUDED.priority,
enabled = EXCLUDED.enabled,
updated_at = EXCLUDED.updated_at
RETURNING *
""";
await using var conn = await DataSource.OpenConnectionAsync(rule.TenantId, "writer", ct);
await using var cmd = CreateCommand(sql, conn);
AddParameter(cmd, "id", rule.Id);
AddParameter(cmd, "tenant_id", rule.TenantId);
AddParameter(cmd, "name", rule.Name);
AddParameter(cmd, "description", rule.Description);
AddParameter(cmd, "gate_level", (int)rule.GateLevel);
AddParameter(cmd, "min_approvers", rule.MinApprovers);
AddParameter(cmd, "required_roles", rule.RequiredRoles);
AddParameter(cmd, "max_ttl_days", rule.MaxTtlDays);
AddParameter(cmd, "allow_self_approval", rule.AllowSelfApproval);
AddParameter(cmd, "require_evidence", rule.RequireEvidence);
AddParameter(cmd, "require_compensating_controls", rule.RequireCompensatingControls);
AddParameter(cmd, "min_rationale_length", rule.MinRationaleLength);
AddParameter(cmd, "priority", rule.Priority);
AddParameter(cmd, "enabled", rule.Enabled);
AddParameter(cmd, "created_at", rule.CreatedAt);
AddParameter(cmd, "updated_at", rule.UpdatedAt);
await using var reader = await cmd.ExecuteReaderAsync(ct);
await reader.ReadAsync(ct);
return MapApprovalRule(reader);
}
// ========================================================================
// Mapping Helpers
// ========================================================================
private static ExceptionApprovalRequestEntity MapApprovalRequest(NpgsqlDataReader reader)
{
return new ExceptionApprovalRequestEntity
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
RequestId = reader.GetString(reader.GetOrdinal("request_id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ExceptionId = GetNullableString(reader, reader.GetOrdinal("exception_id")),
RequestorId = reader.GetString(reader.GetOrdinal("requestor_id")),
RequiredApproverIds = GetStringArray(reader, "required_approver_ids"),
ApprovedByIds = GetStringArray(reader, "approved_by_ids"),
RejectedById = GetNullableString(reader, reader.GetOrdinal("rejected_by_id")),
Status = ParseApprovalStatus(reader.GetString(reader.GetOrdinal("status"))),
GateLevel = (GateLevel)reader.GetInt32(reader.GetOrdinal("gate_level")),
Justification = reader.GetString(reader.GetOrdinal("justification")),
Rationale = GetNullableString(reader, reader.GetOrdinal("rationale")),
ReasonCode = ParseReasonCode(reader.GetString(reader.GetOrdinal("reason_code"))),
EvidenceRefs = reader.GetString(reader.GetOrdinal("evidence_refs")),
CompensatingControls = reader.GetString(reader.GetOrdinal("compensating_controls")),
TicketRef = GetNullableString(reader, reader.GetOrdinal("ticket_ref")),
VulnerabilityId = GetNullableString(reader, reader.GetOrdinal("vulnerability_id")),
PurlPattern = GetNullableString(reader, reader.GetOrdinal("purl_pattern")),
ArtifactDigest = GetNullableString(reader, reader.GetOrdinal("artifact_digest")),
ImagePattern = GetNullableString(reader, reader.GetOrdinal("image_pattern")),
Environments = GetStringArray(reader, "environments"),
RequestedTtlDays = reader.GetInt32(reader.GetOrdinal("requested_ttl_days")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
RequestExpiresAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("request_expires_at")),
ExceptionExpiresAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("exception_expires_at")),
ResolvedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("resolved_at")),
RejectionReason = GetNullableString(reader, reader.GetOrdinal("rejection_reason")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
Version = reader.GetInt32(reader.GetOrdinal("version")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
};
}
private static ExceptionApprovalAuditEntity MapAuditEntry(NpgsqlDataReader reader)
{
return new ExceptionApprovalAuditEntity
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
RequestId = reader.GetString(reader.GetOrdinal("request_id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
SequenceNumber = reader.GetInt32(reader.GetOrdinal("sequence_number")),
ActionType = reader.GetString(reader.GetOrdinal("action_type")),
ActorId = reader.GetString(reader.GetOrdinal("actor_id")),
OccurredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
PreviousStatus = GetNullableString(reader, reader.GetOrdinal("previous_status")),
NewStatus = reader.GetString(reader.GetOrdinal("new_status")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
Details = reader.GetString(reader.GetOrdinal("details")),
ClientInfo = reader.GetString(reader.GetOrdinal("client_info"))
};
}
private static ExceptionApprovalRuleEntity MapApprovalRule(NpgsqlDataReader reader)
{
return new ExceptionApprovalRuleEntity
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
Name = reader.GetString(reader.GetOrdinal("name")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
GateLevel = (GateLevel)reader.GetInt32(reader.GetOrdinal("gate_level")),
MinApprovers = reader.GetInt32(reader.GetOrdinal("min_approvers")),
RequiredRoles = GetStringArray(reader, "required_roles"),
MaxTtlDays = reader.GetInt32(reader.GetOrdinal("max_ttl_days")),
AllowSelfApproval = reader.GetBoolean(reader.GetOrdinal("allow_self_approval")),
RequireEvidence = reader.GetBoolean(reader.GetOrdinal("require_evidence")),
RequireCompensatingControls = reader.GetBoolean(reader.GetOrdinal("require_compensating_controls")),
MinRationaleLength = reader.GetInt32(reader.GetOrdinal("min_rationale_length")),
Priority = reader.GetInt32(reader.GetOrdinal("priority")),
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at"))
};
}
private static string[] GetStringArray(NpgsqlDataReader reader, string columnName)
{
var ordinal = reader.GetOrdinal(columnName);
if (reader.IsDBNull(ordinal))
return [];
return reader.GetFieldValue<string[]>(ordinal) ?? [];
}
private static DateTimeOffset? GetNullableDateTimeOffset(NpgsqlDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : reader.GetFieldValue<DateTimeOffset>(ordinal);
}
private static ApprovalRequestStatus ParseApprovalStatus(string status) => status switch
{
"pending" => ApprovalRequestStatus.Pending,
"partial" => ApprovalRequestStatus.Partial,
"approved" => ApprovalRequestStatus.Approved,
"rejected" => ApprovalRequestStatus.Rejected,
"expired" => ApprovalRequestStatus.Expired,
"cancelled" => ApprovalRequestStatus.Cancelled,
_ => ApprovalRequestStatus.Pending
};
private static ExceptionReasonCode ParseReasonCode(string code) => code switch
{
"false_positive" => ExceptionReasonCode.FalsePositive,
"accepted_risk" => ExceptionReasonCode.AcceptedRisk,
"compensating_control" => ExceptionReasonCode.CompensatingControl,
"test_only" => ExceptionReasonCode.TestOnly,
"vendor_not_affected" => ExceptionReasonCode.VendorNotAffected,
"scheduled_fix" => ExceptionReasonCode.ScheduledFix,
"deprecation_in_progress" => ExceptionReasonCode.DeprecationInProgress,
"runtime_mitigation" => ExceptionReasonCode.RuntimeMitigation,
"network_isolation" => ExceptionReasonCode.NetworkIsolation,
_ => ExceptionReasonCode.Other
};
private static string MapReasonCode(ExceptionReasonCode code) => code switch
{
ExceptionReasonCode.FalsePositive => "false_positive",
ExceptionReasonCode.AcceptedRisk => "accepted_risk",
ExceptionReasonCode.CompensatingControl => "compensating_control",
ExceptionReasonCode.TestOnly => "test_only",
ExceptionReasonCode.VendorNotAffected => "vendor_not_affected",
ExceptionReasonCode.ScheduledFix => "scheduled_fix",
ExceptionReasonCode.DeprecationInProgress => "deprecation_in_progress",
ExceptionReasonCode.RuntimeMitigation => "runtime_mitigation",
ExceptionReasonCode.NetworkIsolation => "network_isolation",
_ => "other"
};
}

View File

@@ -0,0 +1,152 @@
using StellaOps.Policy.Storage.Postgres.Models;
namespace StellaOps.Policy.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for exception approval workflow operations.
/// </summary>
public interface IExceptionApprovalRepository
{
// ========================================================================
// Approval Request Operations
// ========================================================================
/// <summary>
/// Creates a new approval request.
/// </summary>
Task<ExceptionApprovalRequestEntity> CreateRequestAsync(
ExceptionApprovalRequestEntity request,
CancellationToken ct = default);
/// <summary>
/// Gets an approval request by ID.
/// </summary>
Task<ExceptionApprovalRequestEntity?> GetRequestAsync(
string tenantId,
string requestId,
CancellationToken ct = default);
/// <summary>
/// Gets an approval request by internal UUID.
/// </summary>
Task<ExceptionApprovalRequestEntity?> GetRequestByIdAsync(
string tenantId,
Guid id,
CancellationToken ct = default);
/// <summary>
/// Lists approval requests for a tenant.
/// </summary>
Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListRequestsAsync(
string tenantId,
ApprovalRequestStatus? status = null,
int limit = 100,
int offset = 0,
CancellationToken ct = default);
/// <summary>
/// Lists pending approval requests for an approver.
/// </summary>
Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListPendingForApproverAsync(
string tenantId,
string approverId,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Lists approval requests by requestor.
/// </summary>
Task<IReadOnlyList<ExceptionApprovalRequestEntity>> ListByRequestorAsync(
string tenantId,
string requestorId,
int limit = 100,
CancellationToken ct = default);
/// <summary>
/// Updates an approval request with optimistic concurrency.
/// </summary>
Task<bool> UpdateRequestAsync(
ExceptionApprovalRequestEntity request,
int expectedVersion,
CancellationToken ct = default);
/// <summary>
/// Records an approval action.
/// </summary>
Task<ExceptionApprovalRequestEntity?> ApproveAsync(
string tenantId,
string requestId,
string approverId,
string? comment,
CancellationToken ct = default);
/// <summary>
/// Records a rejection action.
/// </summary>
Task<ExceptionApprovalRequestEntity?> RejectAsync(
string tenantId,
string requestId,
string rejectorId,
string reason,
CancellationToken ct = default);
/// <summary>
/// Cancels an approval request (by requestor).
/// </summary>
Task<bool> CancelRequestAsync(
string tenantId,
string requestId,
string actorId,
string? reason,
CancellationToken ct = default);
/// <summary>
/// Expires pending requests past their expiry time.
/// </summary>
Task<int> ExpirePendingRequestsAsync(CancellationToken ct = default);
// ========================================================================
// Audit Trail Operations
// ========================================================================
/// <summary>
/// Records an audit entry.
/// </summary>
Task<ExceptionApprovalAuditEntity> RecordAuditAsync(
ExceptionApprovalAuditEntity audit,
CancellationToken ct = default);
/// <summary>
/// Gets audit trail for a request.
/// </summary>
Task<IReadOnlyList<ExceptionApprovalAuditEntity>> GetAuditTrailAsync(
string tenantId,
string requestId,
CancellationToken ct = default);
// ========================================================================
// Approval Rules Operations
// ========================================================================
/// <summary>
/// Gets approval rules for a gate level.
/// </summary>
Task<ExceptionApprovalRuleEntity?> GetApprovalRuleAsync(
string tenantId,
GateLevel gateLevel,
CancellationToken ct = default);
/// <summary>
/// Lists all approval rules for a tenant.
/// </summary>
Task<IReadOnlyList<ExceptionApprovalRuleEntity>> ListApprovalRulesAsync(
string tenantId,
CancellationToken ct = default);
/// <summary>
/// Creates or updates an approval rule.
/// </summary>
Task<ExceptionApprovalRuleEntity> UpsertApprovalRuleAsync(
ExceptionApprovalRuleEntity rule,
CancellationToken ct = default);
}