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

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