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, }