152 lines
5.0 KiB
Python
152 lines
5.0 KiB
Python
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
|