Files
Unraid-AppOps-Gateway-Public/backend/app/services/policy_engine.py
T

308 lines
13 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 == "controller.managed-storage.initialize":
exact_policy = session.exec(
select(Policy).where(
(Policy.action == action) & (Policy.target == "tower/deployment-controller")
)
).first()
allowed = bool(
target == "tower/deployment-controller"
and source != "voice"
and exact_policy is not None
and exact_policy.manual == "allowed"
)
return {
"decision": "allowed" if allowed else "blocked",
"reason": (
"Managed storage initialization requires an exact-target explicit operator policy."
),
"risk": ACTION_RISK.get(action, "high"),
"policy_id": exact_policy.id if allowed and exact_policy is not None else None,
"scope_decision": "allowed" if allowed else None,
}
if action == "forge.oidc.provision":
# This is the only manual Authentik mutation exposed by AppOps. Requiring
# exactly one row for the action prevents a wildcard or duplicate row
# from becoming an alternate authorization path.
policies = session.exec(select(Policy).where(Policy.action == action)).all()
policy = policies[0] if len(policies) == 1 else None
allowed = bool(
source == "manual"
and target == "itworx-forge"
and policy is not None
and policy.target == "itworx-forge"
and policy.manual == "allowed"
and policy.voice == "blocked"
and policy.risk == "critical"
)
return {
"decision": "allowed" if allowed else "blocked",
"reason": "Forge OIDC provisioning requires one exact-target manual policy; voice is always blocked.",
"risk": "critical",
"policy_id": policy.id if allowed and policy is not None else None,
"scope_decision": "allowed" if allowed else None,
}
if action == "forge.route.stage":
# This narrowly scoped external-route mutation never inherits the
# generic manual fallback or a wildcard grant. The row is seeded
# blocked and must be explicitly enabled for the one Forge target.
policies = session.exec(select(Policy).where(Policy.action == action)).all()
policy = policies[0] if len(policies) == 1 else None
allowed = bool(
source == "manual"
and target == "itworx-forge"
and policy is not None
and policy.target == "itworx-forge"
and policy.manual == "allowed"
and policy.voice == "blocked"
and policy.risk == "critical"
)
return {
"decision": "allowed" if allowed else "blocked",
"reason": "Forge route staging requires one exact-target manual policy; voice is always blocked.",
"risk": "critical",
"policy_id": policy.id if allowed and policy is not None else None,
"scope_decision": "allowed" if allowed else 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,
}