from __future__ import annotations import hashlib import json import uuid from datetime import timedelta from sqlmodel import Session, select from ..db.models import Approval from ..core.time import utc_now VALID_DECISIONS = {'approved', 'rejected'} class ApprovalStateError(ValueError): pass def approval_fingerprint(action: str, target: str, source: str, risk: str, context: dict | None = None) -> str: canonical_context = json.dumps(context or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=True) canonical = "\x1f".join([*(part.strip() for part in (action, target, source, risk)), canonical_context]) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() def get_approval(session: Session, approval_id: str) -> Approval | None: approval = session.get(Approval, approval_id) if approval and not approval.action_fingerprint: approval.action_fingerprint = approval_fingerprint(approval.action, approval.target, approval.source, approval.risk, approval.request_context_json) session.add(approval) session.commit() session.refresh(approval) if approval and _expire_if_needed(session, approval): session.refresh(approval) return approval def list_approvals(session: Session, *, include_dismissed: bool = False): statement = select(Approval).order_by(Approval.created_at.desc()) if not include_dismissed: statement = statement.where(Approval.dismissed_at.is_(None)) approvals = session.exec(statement).all() changed = any(_expire_if_needed(session, approval, commit=False) for approval in approvals) if changed: session.commit() for approval in approvals: session.refresh(approval) return approvals def dismiss_expired_approvals(session: Session) -> list[Approval]: """Archive only terminal expired approvals while retaining their audit evidence.""" approvals = list_approvals(session) dismissed_at = utc_now() dismissed = [approval for approval in approvals if approval.status == 'expired'] for approval in dismissed: approval.dismissed_at = dismissed_at session.add(approval) if dismissed: session.commit() for approval in dismissed: session.refresh(approval) return dismissed def _expire_if_needed(session: Session, approval: Approval, *, commit: bool = True) -> bool: if approval.status != 'pending' or approval.expires_at is None or approval.expires_at > utc_now(): return False approval.status = 'expired' approval.decided_at = utc_now() session.add(approval) if commit: session.commit() return True def create_approval( session: Session, *, action: str, target: str, source: str, risk: str, reason: str | None = None, requester: str = 'manual', expires_in_minutes: int = 30, context: dict | None = None, ) -> Approval: approval = Approval( id=f'appr-{uuid.uuid4().hex[:12]}', action=action, target=target, source=source, risk=risk, status='pending', requester=requester, reason=reason, expires_at=(utc_now() + timedelta(minutes=expires_in_minutes)), request_context_json=context or {}, action_fingerprint=approval_fingerprint(action, target, source, risk, context), ) session.add(approval) session.commit() session.refresh(approval) return approval def update_approval_status(session: Session, approval_id: str, status: str, *, actor: str = "operator") -> Approval | None: approval = get_approval(session, approval_id) if not approval: return None if status not in VALID_DECISIONS: raise ApprovalStateError('Approval status must be approved or rejected.') if approval.status == 'expired': raise ApprovalStateError('Approval has expired and can no longer be decided.') if approval.status != 'pending': raise ApprovalStateError(f'Approval is already {approval.status}.') approval.status = status approval.decided_at = utc_now() approval.approved_by = actor if status == 'approved' else None session.add(approval) session.commit() session.refresh(approval) return approval def mark_approval_executed( session: Session, approval: Approval, *, job_id: str, audit_event_id: str, actor: str = "operator", ) -> Approval: if approval.status != 'approved': raise ApprovalStateError('Only an approved request can be executed.') expected = approval_fingerprint(approval.action, approval.target, approval.source, approval.risk, approval.request_context_json) if not approval.action_fingerprint or approval.action_fingerprint != expected: raise ApprovalStateError('Approval action context changed after review.') approval.status = 'executed' approval.executed_at = utc_now() approval.job_id = job_id approval.audit_event_id = audit_event_id approval.executed_by = actor session.add(approval) session.commit() session.refresh(approval) return approval