Files
Unraid-AppOps-Gateway-Public/backend/app/services/approval_service.py
T

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