Files
Unraid-AppOps-Gateway-Public/backend/app/api/routes_actions.py
T

515 lines
19 KiB
Python

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'],
)