511 lines
19 KiB
Python
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'],
|
|
)
|