240 lines
9.8 KiB
Python
240 lines
9.8 KiB
Python
from __future__ import annotations
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from ..db.models import Policy
|
|
from .action_registry import ACTION_RISK, ACTIONS
|
|
|
|
_DEFAULT_DECISIONS = {
|
|
'privatemuse.comfyui.install_artifact': 'approval_required',
|
|
'deployment.target.deploy_workmesh': 'approval_required',
|
|
'deployment.target.promote_workmesh': 'approval_required',
|
|
'container.clear_logs': 'blocked',
|
|
'container.stop': 'blocked',
|
|
'gateway.clear_cache': 'blocked',
|
|
'jellyfin.scan_library': 'blocked',
|
|
'volume.delete': 'blocked',
|
|
'docker.prune': 'blocked',
|
|
'secrets.show': 'blocked',
|
|
'unraid.disk.format': 'blocked',
|
|
'unraid.vm.stop': 'blocked',
|
|
'unraid.vm.reboot': 'blocked',
|
|
'unraid.vm.force_stop': 'blocked',
|
|
'unraid.vm.reset': 'blocked',
|
|
'sonarr.delete_series': 'blocked',
|
|
'sonarr.mass_edit': 'blocked',
|
|
'radarr.delete_movie': 'blocked',
|
|
'radarr.delete_files': 'blocked',
|
|
'radarr.mass_edit': 'blocked',
|
|
'widefrog.delete_job': 'blocked',
|
|
'widefrog.delete_media': 'blocked',
|
|
'widefrog.clear_library': 'blocked',
|
|
'widefrog.clear_queue': 'blocked',
|
|
'widefrog.reset_database': 'blocked',
|
|
'widefrog.delete_downloads': 'blocked',
|
|
'widefrog.prune': 'blocked',
|
|
'prowlarr.delete_indexer': 'blocked',
|
|
'prowlarr.delete_application': 'blocked',
|
|
'prowlarr.update_indexer': 'blocked',
|
|
'prowlarr.update_application': 'blocked',
|
|
'prowlarr.grab_release': 'blocked',
|
|
'prowlarr.push_release': 'blocked',
|
|
'prowlarr.mass_edit': 'blocked',
|
|
'prowlarr.test_all_indexers': 'blocked',
|
|
'deluge.remove_torrent': 'blocked',
|
|
'deluge.remove_torrent_data': 'blocked',
|
|
'deluge.delete_data': 'blocked',
|
|
'deluge.force_recheck': 'blocked',
|
|
'deluge.move_storage': 'blocked',
|
|
'deluge.add_torrent': 'blocked',
|
|
'deluge.add_magnet': 'blocked',
|
|
'deluge.mass_edit': 'blocked',
|
|
'deluge.set_options': 'blocked',
|
|
'deluge.unknown_mutation': 'blocked',
|
|
'plex.delete_media': 'blocked',
|
|
'plex.delete_library': 'blocked',
|
|
'plex.empty_trash': 'blocked',
|
|
'plex.clean_bundles': 'blocked',
|
|
'plex.optimize_database': 'blocked',
|
|
'plex.kill_session': 'blocked',
|
|
'plex.user_management': 'blocked',
|
|
'plex.force_refresh_all_metadata': 'blocked',
|
|
'plex.mass_edit': 'blocked',
|
|
'plex.unknown_mutation': 'blocked',
|
|
'tautulli.terminate_session': 'blocked',
|
|
'tautulli.delete_newsletter': 'blocked',
|
|
'tautulli.delete_notification_log': 'blocked',
|
|
'tautulli.delete_notifier': 'blocked',
|
|
'tautulli.delete_recently_added': 'blocked',
|
|
'tautulli.download_config': 'blocked',
|
|
'tautulli.download_database': 'blocked',
|
|
'tautulli.download_export': 'blocked',
|
|
'tautulli.download_log': 'blocked',
|
|
'tautulli.download_plex_log': 'blocked',
|
|
'tautulli.notify': 'blocked',
|
|
'tautulli.user_management': 'blocked',
|
|
'tautulli.mass_edit': 'blocked',
|
|
'tautulli.unknown_mutation': 'blocked',
|
|
'npm.create_proxy_host': 'blocked',
|
|
'npm.update_proxy_host': 'blocked',
|
|
'npm.delete_proxy_host': 'blocked',
|
|
'npm.enable_proxy_host': 'blocked',
|
|
'npm.disable_proxy_host': 'blocked',
|
|
'npm.renew_certificate': 'blocked',
|
|
'npm.delete_certificate': 'blocked',
|
|
'npm.modify_access_list': 'blocked',
|
|
'npm.create_user': 'blocked',
|
|
'npm.update_user': 'blocked',
|
|
'npm.delete_user': 'blocked',
|
|
'npm.mass_edit': 'blocked',
|
|
'npm.unknown_mutation': 'blocked',
|
|
'authentik.create_application': 'blocked',
|
|
'authentik.update_application': 'blocked',
|
|
'authentik.delete_application': 'blocked',
|
|
'authentik.create_provider': 'blocked',
|
|
'authentik.update_provider': 'blocked',
|
|
'authentik.delete_provider': 'blocked',
|
|
'authentik.create_outpost': 'blocked',
|
|
'authentik.update_outpost': 'blocked',
|
|
'authentik.delete_outpost': 'blocked',
|
|
'authentik.create_policy': 'blocked',
|
|
'authentik.update_policy': 'blocked',
|
|
'authentik.delete_policy': 'blocked',
|
|
'authentik.create_user': 'blocked',
|
|
'authentik.update_user': 'blocked',
|
|
'authentik.delete_user': 'blocked',
|
|
'authentik.create_group': 'blocked',
|
|
'authentik.update_group': 'blocked',
|
|
'authentik.delete_group': 'blocked',
|
|
'authentik.rotate_token': 'blocked',
|
|
'authentik.force_sync': 'blocked',
|
|
'authentik.mass_edit': 'blocked',
|
|
'authentik.unknown_mutation': 'blocked',
|
|
}
|
|
|
|
_DEFAULT_REASONS = {
|
|
'privatemuse.comfyui.install_artifact': 'Installing a model artifact mutates the production ComfyUI model store and requires an explicit approved operator flow with pinned SHA-256 provenance.',
|
|
'deployment.target.deploy_workmesh': 'WorkMesh first deployment is a high-impact production mutation and requires an explicit approved operator flow outside typed MCP automation.',
|
|
'deployment.target.promote_workmesh': 'WorkMesh product promotion requires exact-revision controller preflight and a human approval bound to its digest.',
|
|
'container.clear_logs': 'Clearing container logs is outside AppOps Gateway V1; log viewing and local download remain read-only.',
|
|
'container.stop': 'Container stop is outside AppOps Gateway V1; use Portainer or Unraid directly after manual review.',
|
|
'gateway.clear_cache': 'Clearing gateway cache is outside AppOps Gateway V1; use explicit refresh or restart procedures after review.',
|
|
'jellyfin.scan_library': 'No live Jellyfin connector or executor is registered. Use the Jellyfin UI until a typed, audited connector is implemented.',
|
|
'unraid.vm.stop': 'Unraid stop may force-stop after its graceful timeout; AppOps blocks that destructive fallback.',
|
|
'unraid.vm.reboot': 'VM reboot remains blocked until guest-shutdown and recovery behavior is safely verifiable.',
|
|
'unraid.vm.force_stop': 'Force-stop is destructive and blocked by default.',
|
|
'unraid.vm.reset': 'VM reset is destructive and blocked by default.',
|
|
}
|
|
|
|
|
|
def evaluate_policy(
|
|
session: Session,
|
|
action: str,
|
|
target: str,
|
|
source: str,
|
|
) -> dict:
|
|
if action not in ACTIONS:
|
|
return {
|
|
'decision': 'blocked',
|
|
'reason': 'Unknown action id.',
|
|
'risk': ACTION_RISK.get(action, 'critical'),
|
|
'policy_id': None,
|
|
'scope_decision': None,
|
|
}
|
|
|
|
if action in _DEFAULT_DECISIONS:
|
|
reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.')
|
|
return {
|
|
'decision': _DEFAULT_DECISIONS[action],
|
|
'reason': reason,
|
|
'risk': ACTION_RISK.get(action, 'critical'),
|
|
'policy_id': None,
|
|
'scope_decision': None,
|
|
}
|
|
|
|
if action == 'container.restart' and source == 'voice' and target == 'widefrog':
|
|
return {
|
|
'decision': 'allowed',
|
|
'reason': 'Widefrog restart is explicitly allow-listed for voice actions.',
|
|
'risk': ACTION_RISK.get(action, 'medium'),
|
|
'policy_id': None,
|
|
'scope_decision': None,
|
|
}
|
|
|
|
records = session.exec(
|
|
select(Policy).where(
|
|
(Policy.action == action) & (
|
|
(Policy.target == target) | (Policy.target == '*')
|
|
)
|
|
)
|
|
).all()
|
|
|
|
# Prefer exact target over wildcard.
|
|
exact_match = next((r for r in records if r.target == target), None)
|
|
wildcard_match = next((r for r in records if r.target == '*'), None)
|
|
policy = exact_match or wildcard_match
|
|
|
|
if not policy:
|
|
# Safety-first defaults by scope.
|
|
if action in {'deployment.apply', 'deployment.rollback'}:
|
|
return {
|
|
'decision': 'blocked',
|
|
'reason': 'Production deployment requires an explicit exact-target deployment policy.',
|
|
'risk': ACTION_RISK.get(action, 'high'),
|
|
'policy_id': None,
|
|
'scope_decision': None,
|
|
}
|
|
|
|
if source == 'voice' and action.startswith('container.') and target in {'authentik', 'portainer'}:
|
|
return {
|
|
'decision': 'approval_required',
|
|
'reason': 'High-risk service action via voice requires explicit approval by policy.',
|
|
'risk': ACTION_RISK.get(action, 'high'),
|
|
'policy_id': None,
|
|
'scope_decision': None,
|
|
}
|
|
|
|
if source == 'voice' and action.startswith('container.') and target in {'postgres', 'redis', 'database'}:
|
|
return {
|
|
'decision': 'blocked',
|
|
'reason': 'Datastore/container restarts via voice are blocked by default.',
|
|
'risk': ACTION_RISK.get(action, 'high'),
|
|
'policy_id': None,
|
|
'scope_decision': None,
|
|
}
|
|
|
|
if source == 'voice' and action in {'unraid.mover.start'}:
|
|
return {
|
|
'decision': 'blocked',
|
|
'reason': 'High-impact maintenance action is blocked for voice unless explicitly approved.',
|
|
'risk': ACTION_RISK.get(action, 'high'),
|
|
'policy_id': None,
|
|
'scope_decision': None,
|
|
}
|
|
|
|
if action == 'stack.redeploy':
|
|
return {
|
|
'decision': 'approval_required',
|
|
'reason': 'Default safe policy for stack redeploy requires approval.',
|
|
'risk': ACTION_RISK.get(action, 'high'),
|
|
'policy_id': None,
|
|
'scope_decision': None,
|
|
}
|
|
|
|
return {
|
|
'decision': 'approval_required' if source == 'voice' else 'allowed',
|
|
'reason': 'No explicit policy found; using default safe policy.',
|
|
'risk': ACTION_RISK.get(action, 'medium'),
|
|
'policy_id': None,
|
|
'scope_decision': None,
|
|
}
|
|
|
|
decision = policy.voice if source == 'voice' else policy.manual
|
|
return {
|
|
'decision': decision,
|
|
'reason': policy.reason or 'Policy match from database.',
|
|
'risk': policy.risk,
|
|
'policy_id': policy.id,
|
|
'scope_decision': decision,
|
|
}
|