151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from ..db.session import get_session
|
|
from ..services.audit_logger import log_event
|
|
from ..services.action_execution import approval_execution_supported, execute_approved_action
|
|
from ..services.approval_service import (
|
|
ApprovalStateError,
|
|
approval_fingerprint,
|
|
create_approval,
|
|
dismiss_expired_approvals,
|
|
get_approval,
|
|
list_approvals,
|
|
mark_approval_executed,
|
|
update_approval_status,
|
|
)
|
|
|
|
router = APIRouter(prefix='/api/approvals')
|
|
|
|
|
|
class ApprovalCreateRequest(BaseModel):
|
|
model_config = ConfigDict(extra='forbid')
|
|
action: str = Field(min_length=1, max_length=160)
|
|
target: str = Field(min_length=1, max_length=240)
|
|
source: str = Field(default='manual', pattern='^(manual|voice|automation|system)$')
|
|
risk: str = Field(default='medium', pattern='^(low|medium|high|critical)$')
|
|
reason: str | None = Field(default=None, max_length=1000)
|
|
requester: str = Field(default='manual', max_length=120)
|
|
plan: list[str] = Field(default_factory=list, max_length=30)
|
|
|
|
|
|
class ApprovalDecisionRequest(BaseModel):
|
|
model_config = ConfigDict(extra='forbid')
|
|
status: str = Field(min_length=1, max_length=32)
|
|
|
|
|
|
@router.get('')
|
|
def list_all(include_dismissed: bool = Query(default=False), session=Depends(get_session)):
|
|
return [
|
|
{
|
|
**approval.model_dump(),
|
|
'executionSupported': approval_execution_supported(approval.action),
|
|
'executionReason': None if approval_execution_supported(approval.action) else 'Geen rollback-veilige executor geregistreerd.',
|
|
}
|
|
for approval in list_approvals(session, include_dismissed=include_dismissed)
|
|
]
|
|
|
|
|
|
@router.post('/cleanup')
|
|
def cleanup_expired(session=Depends(get_session)):
|
|
dismissed = dismiss_expired_approvals(session)
|
|
audit = log_event(
|
|
session,
|
|
source='manual',
|
|
identity='operator',
|
|
action_id='approval.cleanup',
|
|
target='approval-queue',
|
|
target_type='approval',
|
|
risk='low',
|
|
policy_decision='allowed',
|
|
reason=f'{len(dismissed)} expired approval(s) archived; active approvals were retained.',
|
|
result='success',
|
|
execution_plan={'steps': ['expire overdue pending approvals', 'archive expired approvals', 'retain approval and audit records']},
|
|
redacted_metadata={'dismissedCount': len(dismissed)},
|
|
)
|
|
return {
|
|
'success': True,
|
|
'message': f'{len(dismissed)} verlopen approval(s) opgeruimd.',
|
|
'dismissedCount': len(dismissed),
|
|
'auditId': audit.id,
|
|
}
|
|
|
|
|
|
@router.post('')
|
|
def create(payload: ApprovalCreateRequest, session=Depends(get_session)):
|
|
approval = create_approval(
|
|
session,
|
|
action=payload.action,
|
|
target=payload.target,
|
|
source=payload.source,
|
|
risk=payload.risk,
|
|
reason=payload.reason,
|
|
requester=payload.requester,
|
|
context={'plan': payload.plan},
|
|
)
|
|
return {'success': True, 'message': 'Approval created', 'id': approval.id}
|
|
|
|
|
|
@router.patch('/{approval_id}')
|
|
def update(approval_id: str, payload: ApprovalDecisionRequest, session=Depends(get_session)):
|
|
status = payload.status
|
|
try:
|
|
updated = update_approval_status(session, approval_id, status)
|
|
except ApprovalStateError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
if not updated:
|
|
return {'success': False, 'message': 'Approval not found', 'id': approval_id}
|
|
|
|
audit = log_event(
|
|
session,
|
|
source='manual',
|
|
identity='operator',
|
|
action_id=f'approval.{status}',
|
|
target=updated.target,
|
|
target_type='approval',
|
|
risk=updated.risk,
|
|
policy_decision='approval_required',
|
|
reason=updated.reason or f'Approval {approval_id} marked {status}.',
|
|
result='success',
|
|
execution_plan={'steps': ['load approval', f'mark {status}', 'write audit']},
|
|
redacted_metadata={'approvalId': updated.id, 'approvalStatus': updated.status, 'requestedAction': updated.action},
|
|
)
|
|
|
|
return {
|
|
'success': True,
|
|
'id': updated.id,
|
|
'status': updated.status,
|
|
'auditId': audit.id,
|
|
'message': f'Approval {updated.status}',
|
|
}
|
|
|
|
|
|
@router.post('/{approval_id}/execute')
|
|
def execute_approved(approval_id: str, session=Depends(get_session)):
|
|
approval = get_approval(session, approval_id)
|
|
if not approval:
|
|
raise HTTPException(status_code=404, detail='Approval not found.')
|
|
if approval.status != 'approved':
|
|
raise HTTPException(status_code=409, detail='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 HTTPException(status_code=409, detail='Approval action context changed after review.')
|
|
|
|
result = execute_approved_action(session, approval)
|
|
if not result.get('success'):
|
|
return result
|
|
|
|
mark_approval_executed(
|
|
session,
|
|
approval,
|
|
job_id=str(result['jobId']),
|
|
audit_event_id=str(result['auditId']),
|
|
)
|
|
return {
|
|
**result,
|
|
'approvalId': approval.id,
|
|
'approvalStatus': 'executed',
|
|
}
|