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.session import get_session from ..db.models import App, AppSetting, utc_now 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.action_registry import ( ACTIONS, ACTION_RISK, 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.action_execution import action_execution_profile, execute_governed_action 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 { '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'], )