308 lines
13 KiB
Python
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,
|
|
}
|