Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user