from __future__ import annotations import hashlib import json from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, ConfigDict, Field from sqlmodel import Session, select from ..db.models import App, AppSetting, utc_now from ..db.session import get_session from ..services.action_execution import ( action_execution_profile, execute_governed_action, ) from ..services.action_registry import ( ACTION_RISK, ACTIONS, action_blast_radius, action_capability, action_category, action_default_target, action_label, action_operator_guidance, action_safeguards, ) from ..services.approval_service import create_approval from ..services.audit_logger import log_event from ..services.container_restart_service import execute_container_restart from ..services.policy_engine import evaluate_policy from ..services.preflight_service import build_preflight router = APIRouter(prefix='/api/actions') def _idempotency_context(session: Session, key: str | None, payload: dict) -> tuple[AppSetting | None, str | None]: if not key: return None, None normalized = key.strip() if not normalized or len(normalized) > 128: raise HTTPException(status_code=400, detail='Idempotency-Key must contain 1 to 128 characters.') request_hash = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest() setting_key = f'action-idempotency:{hashlib.sha256(normalized.encode()).hexdigest()}' existing = session.get(AppSetting, setting_key) if existing: if existing.value_json.get('requestHash') != request_hash: raise HTTPException(status_code=409, detail='Idempotency-Key was already used for a different request.') return existing, request_hash return AppSetting(key=setting_key, is_secret=False), request_hash def _idempotent_response(session: Session, row: AppSetting | None, request_hash: str | None, response: dict) -> dict: if row is None or request_hash is None: return response if row.value_json.get('response') is not None: return {**row.value_json['response'], 'idempotentReplay': True} row.value_json = {'requestHash': request_hash, 'response': response} row.updated_at = utc_now() session.add(row) session.commit() return response class ActionRequest(BaseModel): model_config = ConfigDict(extra='forbid') input: str | None = Field(default=None, max_length=2000) action: str | None = Field(default=None, max_length=160) intent: str | None = Field(default=None, max_length=160) target: str | None = Field(default=None, max_length=240) source: str = Field(default='manual', pattern='^(manual|voice|automation|system)$') risk: str | None = Field(default=None, pattern='^(low|medium|high|critical)$') policy: str | None = Field(default=None, pattern='^(allowed|approval_required|blocked)$') reason: str | None = Field(default=None, max_length=1000) plan: list[str] | dict | None = None postChecks: list[str] = Field(default_factory=list, max_length=30) def _request_payload(payload: ActionRequest) -> dict: return payload.model_dump(exclude_none=True) def _extract_plan(payload: dict) -> dict: plan = payload.get('plan') if isinstance(plan, dict): return plan if isinstance(payload.get('intent'), str): return { 'intent': payload.get('intent'), 'target': payload.get('target'), 'source': payload.get('source', 'manual'), 'risk': payload.get('risk', 'low'), 'plan': payload.get('steps', []) or payload.get('plan', []), 'postChecks': payload.get('postChecks', []), 'policy': payload.get('policy'), } return {} def _build_heuristic_plan(payload: dict, session: Session) -> dict | None: input_text = str(payload.get('input', '')).lower() source = str(payload.get('source', 'manual')).strip() or 'manual' apps = session.exec(select(App.id)).all() available_apps = [app_id for app_id in apps] def has_app(name: str) -> bool: return name in available_apps if ('restart' in input_text or 'herstart' in input_text): if has_app('widefrog') and 'widefrog' in input_text: return { 'intent': 'container.restart', 'target': 'widefrog', 'source': source, 'risk': 'medium', 'policy': 'allowed', 'reason': 'Media app restart is low-medium risk.', 'plan': ['Verify container status', 'Restart widefrog container', 'Health check'], 'postChecks': ['HTTP 200 at port 1207', 'Container running'], } if has_app('authentik') and 'authentik' in input_text: return { 'intent': 'container.restart', 'target': 'authentik', 'source': source, 'risk': 'high', 'policy': 'approval_required', 'reason': 'Auth service is critical and requires explicit approval.', 'plan': ['Check current sessions', 'Stop if maintenance mode', 'Restart container'], 'postChecks': ['SSO endpoint responds', 'Login flow returns'], } health_terms = {'health', 'check', 'controleer'} if any(term in input_text for term in health_terms) and ('unraid' in input_text or 'array' in input_text): return { 'intent': 'unraid.health_summary', 'target': 'unraid', 'source': source, 'risk': 'low', 'policy': 'allowed', 'reason': 'Read-only health summary.', 'plan': ['Collect array status', 'Collect disk warnings', 'Collect cache usage'], 'postChecks': ['Sanity of returned summary'], } if 'volume' in input_text and 'delete' in input_text: return { 'intent': 'volume.delete', 'target': 'volume', 'source': source, 'risk': 'critical', 'policy': 'blocked', 'reason': 'Destructive action is blocked by default.', 'plan': [], 'postChecks': [], } aliases = { 'scan plex': 'plex.scan_all_libraries', 'scan bibliotheek': 'plex.scan_all_libraries', 'pauzeer deluge': 'deluge.pause_all', 'pause deluge': 'deluge.pause_all', 'hervat deluge': 'deluge.resume_all', 'resume deluge': 'deluge.resume_all', 'controleer media pipeline': 'media_pipeline.check', 'check media pipeline': 'media_pipeline.check', 'diagnose media pipeline': 'media_pipeline.diagnose', 'controleer veiligheid': 'safety.summary', 'safety summary': 'safety.summary', 'notificatie overzicht': 'notifications.summary', 'notification summary': 'notifications.summary', 'setup status': 'setup.status', } matched_action = next((action for phrase, action in aliases.items() if phrase in input_text and action in ACTIONS), None) if not matched_action: matched_action = next((action for action in ACTIONS if action.lower() in input_text), None) if not matched_action: input_terms = set(input_text.replace('.', ' ').replace('_', ' ').split()) candidates = [] for action in ACTIONS: label_terms = set(action_label(action).lower().replace('/', ' ').split()) overlap = len(input_terms & label_terms) if overlap >= 2: candidates.append((overlap, action)) if candidates: matched_action = max(candidates)[1] if matched_action: target = action_default_target(matched_action) matched_app = next((app_id for app_id in available_apps if app_id.lower() in input_text), None) if matched_app and matched_action.startswith('container.'): target = matched_app policy = evaluate_policy(session, action=matched_action, target=target, source=source) capability = action_capability(matched_action) return { 'intent': matched_action, 'target': target, 'source': source, 'risk': str(policy.get('risk') or ACTION_RISK.get(matched_action, 'medium')), 'policy': policy['decision'], 'reason': policy['reason'], 'plan': [ f'Validate current state of {target}', ('Collect read-only result' if capability == 'read_only' else f'Run {action_label(matched_action)} through its guarded executor'), ], 'postChecks': [f'Confirm {target} state and record the result in Audit'], } return None def _registry_item(action: str, session: Session) -> dict: target = action_default_target(action) policy = evaluate_policy(session, action=action, target=target, source='manual') decision = str(policy.get('decision', 'blocked')) risk = str(policy.get('risk') or ACTION_RISK.get(action, 'medium')) capability = action_capability(action) execution = action_execution_profile(action, decision, capability) return { 'id': action, 'label': action_label(action), 'category': action_category(action), 'target': target, 'risk': risk, 'capability': capability, 'policyDecision': decision, 'policyReason': policy.get('reason') or 'Policy preview.', **execution, } @router.get('/registry') def action_registry(session: Session = Depends(get_session)): items = [] summary = { 'total': 0, 'allowed': 0, 'approvalRequired': 0, 'blocked': 0, 'readOnly': 0, 'operator': 0, 'destructive': 0, 'highRisk': 0, 'commandExecutable': 0, 'approvalExecutable': 0, 'dedicatedSurface': 0, 'governanceOnly': 0, } for action in sorted(ACTIONS): item = _registry_item(action, session) decision = item['policyDecision'] risk = item['risk'] capability = item['capability'] summary['total'] += 1 if decision == 'allowed': summary['allowed'] += 1 elif decision == 'approval_required': summary['approvalRequired'] += 1 elif decision == 'blocked': summary['blocked'] += 1 if capability == 'read_only': summary['readOnly'] += 1 elif capability == 'destructive': summary['destructive'] += 1 else: summary['operator'] += 1 if risk in {'high', 'critical'}: summary['highRisk'] += 1 execution_mode = item['executionMode'] if execution_mode == 'command_center': summary['commandExecutable'] += 1 elif execution_mode == 'approval_then_execute': summary['approvalExecutable'] += 1 elif execution_mode == 'dedicated_surface': summary['dedicatedSurface'] += 1 elif execution_mode == 'governance_only': summary['governanceOnly'] += 1 items.append(item) return {'summary': summary, 'items': items} @router.get('/registry/{action_id}') def action_registry_detail(action_id: str, session: Session = Depends(get_session)): if action_id not in ACTIONS: raise HTTPException(status_code=404, detail='Unknown action id.') item = _registry_item(action_id, session) return { **item, 'blastRadius': action_blast_radius(action_id, item['capability']), 'safeguards': action_safeguards(action_id, item['policyDecision'], item['capability']), 'operatorGuidance': action_operator_guidance(action_id, item['policyDecision'], item['capability']), 'relatedSurfaces': [ '/command-center', '/policies', '/audit', ], } @router.post('/plan') def action_plan(payload: ActionRequest, session: Session = Depends(get_session)): payload = _request_payload(payload) source = str(payload.get('source', 'manual')).strip() or 'manual' plan = _extract_plan(payload) if not plan: plan = _build_heuristic_plan(payload, session) if not plan: return {'decision': 'blocked', 'reason': 'No actionable intent detected.'} return { 'intent': plan['intent'], 'target': plan['target'], 'source': source, 'risk': plan['risk'], 'policy': plan['policy'], 'reason': plan['reason'], 'plan': plan['plan'], 'postChecks': plan['postChecks'], } @router.post('/preflight') def preflight(payload: ActionRequest, session: Session = Depends(get_session)): payload = _request_payload(payload) action = str(payload.get('action', '')).strip() target = str(payload.get('target', '')).strip() source = str(payload.get('source', 'manual')).strip() or 'manual' if not action or not target: if plan := _build_heuristic_plan(payload, session): action = plan['intent'] target = plan['target'] source = plan['source'] else: action = payload.get('action', '') target = payload.get('target', '') policy = evaluate_policy(session, action=action, target=target, source=source) risk = policy.get('risk', 'medium') return build_preflight(action, target, policy['decision'], risk) @router.post('/execute') def execute(payload: ActionRequest, session: Session = Depends(get_session), idempotency_key: str | None = Header(default=None, alias='Idempotency-Key')): payload = _request_payload(payload) idempotency_row, request_hash = _idempotency_context(session, idempotency_key, payload) if idempotency_row is not None and idempotency_row.value_json.get('response') is not None: return _idempotent_response(session, idempotency_row, request_hash, {}) plan = _extract_plan(payload) if not plan: return _idempotent_response(session, idempotency_row, request_hash, {'success': False, 'message': 'No valid action plan provided.'}) action = str(plan.get('intent', '')).strip() target = str(plan.get('target', '')).strip() source = str(plan.get('source', 'manual')).strip() or 'manual' steps = plan.get('plan', []) if isinstance(plan.get('plan', []), list) else [] if action == 'deployment.target.promote_workmesh': audit = log_event( session, source='action', identity=source, action_id=action, target=target, risk='high', policy_decision='blocked', reason='WorkMesh promotion requires its exact-revision deployment preflight endpoint.', result='blocked', execution_plan={'plan': steps, 'executor': '/api/deployments/workmesh/promotions/preflight'}, ) return _idempotent_response(session, idempotency_row, request_hash, { 'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'preflight_required', 'message': 'Use the fixed WorkMesh revision preflight and approval workflow.', 'auditId': audit.id, }) decision = evaluate_policy(session, action=action, target=target, source=source) risk = str(decision.get('risk', 'medium')).strip() or 'medium' # Always audit every request. if decision['decision'] == 'blocked': audit = log_event( session, source='action', identity=source, action_id=action, target=target, risk=risk, policy_decision=decision['decision'], reason=decision['reason'], result='blocked', execution_plan={'plan': steps, 'policyReason': decision['reason']}, ) return _idempotent_response(session, idempotency_row, request_hash, { 'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': decision['decision'], 'result': 'blocked', 'message': decision['reason'], 'auditId': audit.id, }) if decision['decision'] == 'approval_required': approval = create_approval( session, action=action, target=target, source=source, risk=risk, reason=decision['reason'], requester=source, ) audit = log_event( session, source='action', identity=source, action_id=action, target=target, risk=risk, policy_decision=decision['decision'], reason=decision['reason'], result='approval_requested', execution_plan={'plan': steps, 'approvalId': approval.id}, redacted_metadata={'approval_id': approval.id}, ) return _idempotent_response(session, idempotency_row, request_hash, { 'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': decision['decision'], 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id, }) if action in { 'controller.managed-storage.initialize', 'deployment.targets.sync', 'deployment.target.deploy_workmesh', 'deployment.target.register_portfolio', 'deployment.target.register_privatemuse', }: response = execute_governed_action( session, action=action, target=target, source=source, risk=risk, reason=str(decision.get('reason') or 'Execute the fixed deployment target operation.'), original_policy_decision=str(decision['decision']), ) return _idempotent_response(session, idempotency_row, request_hash, response) if action == 'container.restart': response = execute_container_restart( session=session, action=action, target=target, source=source, risk=risk, steps=steps if isinstance(steps, list) else None, ) return _idempotent_response(session, idempotency_row, request_hash, response) # Fail closed for allowed actions without a real executor. This avoids # presenting read-only checks or future actions as successfully executed. audit = log_event( session, source='action', identity=source, action_id=action, target=target, risk=risk, policy_decision=decision['decision'], reason='No live executor is registered for this action.', result='unsupported', execution_plan={'plan': steps, 'executor': None}, ) return _idempotent_response(session, idempotency_row, request_hash, { 'success': False, 'decision': 'allowed', 'status': 'failed', 'policy_decision': decision['decision'], 'result': 'unsupported', 'message': 'No live executor is registered for this action.', 'auditId': audit.id, }) @router.post('/container/{app_id}/restart') def restart_container(app_id: str, session: Session = Depends(get_session)): return execute_container_restart( session=session, action='container.restart', target=app_id, source='manual', risk='medium', steps=['Resolve container', 'Restart container', 'Post-check status'], )