from datetime import UTC, datetime from sqlalchemy import inspect, text from sqlmodel import SQLModel, select from ..core.config import Settings from ..services.app_classifier import classify_container from ..services.app_registry import category_label from . import session as db_session from .models import ( App, AppContainerMapping, Connector, DiscoveredContainer, NotificationChannel, NotificationEvent, NotificationRule, Policy, ) from .session import get_session settings = Settings() def init_db() -> None: SQLModel.metadata.create_all(db_session.engine) _apply_lightweight_schema_upgrades() with next(get_session()) as session: changed = False for app in _seed_apps(): if session.get(App, app.id) is None: session.add(app) changed = True for connector in _seed_connectors(): if session.get(Connector, connector.id) is None: session.add(connector) changed = True for policy in _seed_policies(): if session.get(Policy, policy.id) is None: session.add(policy) changed = True for channel in _seed_notification_channels(): if session.get(NotificationChannel, channel.id) is None: session.add(channel) changed = True for rule in _seed_notification_rules(): if session.get(NotificationRule, rule.id) is None: session.add(rule) changed = True changed = _upgrade_unknown_app_classifications(session) or changed changed = _upgrade_default_notification_rule_channels(session) or changed changed = _upgrade_duplicate_notification_events(session) or changed changed = _upgrade_suppressed_notification_events(session) or changed if changed: session.commit() from ..services.setup_service import apply_stored_connector_settings apply_stored_connector_settings(session) _ensure_notification_suppression_index() def _apply_lightweight_schema_upgrades() -> None: inspector = inspect(db_session.engine) table_names = set(inspector.get_table_names()) with db_session.engine.begin() as connection: if 'notification_channels' in table_names: columns = {column['name'] for column in inspector.get_columns('notification_channels')} if 'last_test_at' not in columns: connection.execute(text('ALTER TABLE notification_channels ADD COLUMN last_test_at DATETIME')) if 'approval' in table_names: columns = {column['name'] for column in inspector.get_columns('approval')} approval_columns = { 'decided_at': 'DATETIME', 'executed_at': 'DATETIME', 'dismissed_at': 'DATETIME', 'job_id': 'VARCHAR', 'audit_event_id': 'VARCHAR', 'approved_by': 'VARCHAR', 'executed_by': 'VARCHAR', 'action_fingerprint': 'VARCHAR', 'request_context_json': "JSON DEFAULT '{}'", } for name, sql_type in approval_columns.items(): if name not in columns: connection.execute(text(f'ALTER TABLE approval ADD COLUMN {name} {sql_type}')) if 'policy' in table_names: policy_columns = {column['name'] for column in inspector.get_columns('policy')} if 'provenance_json' not in policy_columns: connection.execute(text("ALTER TABLE policy ADD COLUMN provenance_json JSON DEFAULT '{}'")) if 'discovery_runs' in table_names: connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovery_runs_started_at ON discovery_runs (started_at)')) if 'discovered_containers' in table_names: connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovered_containers_discovery_run_id ON discovered_containers (discovery_run_id)')) connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovered_containers_container_id ON discovered_containers (container_id)')) if 'app_container_mappings' in table_names: connection.execute(text('CREATE INDEX IF NOT EXISTS ix_app_container_mappings_app_id ON app_container_mappings (app_id)')) connection.execute(text('CREATE INDEX IF NOT EXISTS ix_app_container_mappings_container_id ON app_container_mappings (container_id)')) def _upgrade_unknown_app_classifications(session) -> bool: changed = False risk_rank = {'low': 0, 'medium': 1, 'high': 2, 'critical': 3} unknown_apps = [ app for app in session.exec(select(App)).all() if str(app.category or '').strip().lower() in {'', 'unknown', 'generic'} ] if not unknown_apps: return False mappings_by_app: dict[str, list[AppContainerMapping]] = {} for mapping in session.exec(select(AppContainerMapping)).all(): mappings_by_app.setdefault(mapping.app_id, []).append(mapping) discovered_by_container: dict[str, list[DiscoveredContainer]] = {} for item in session.exec(select(DiscoveredContainer)).all(): discovered_by_container.setdefault(item.container_id, []).append(item) for app in unknown_apps: mappings = mappings_by_app.get(app.id, []) evidence = [ item for mapping in mappings for item in discovered_by_container.get(mapping.container_id, []) ] classification = classify_container({ 'name': ' '.join([app.id, app.name, *(mapping.container_name for mapping in mappings)]), 'image': ' '.join(item.image for item in evidence), 'ports': [port for item in evidence for port in (item.ports_json or [])], }) if classification.get('app_type') == 'unknown' or float(classification.get('confidence', 0)) < 0.8: continue app.category = category_label(str(classification['category'])) suggested_criticality = str(classification.get('criticality') or 'low') if risk_rank.get(suggested_criticality, 0) > risk_rank.get(app.criticality, 0): app.criticality = suggested_criticality app.protected = app.protected or bool(classification.get('protected')) session.add(app) changed = True return changed def _upgrade_duplicate_notification_events(session) -> bool: rules = {rule.id: rule for rule in session.exec(select(NotificationRule)).all()} rows = session.exec( select(NotificationEvent) .where(NotificationEvent.status != 'dismissed') .order_by(NotificationEvent.created_at.desc()) ).all() grouped: dict[tuple[str, ...], list[NotificationEvent]] = {} for row in rows: key = (row.source, row.event_type, row.dedupe_key, row.title, row.message) grouped.setdefault(key, []).append(row) changed = False severity_rank = {'info': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4} def specificity(row: NotificationEvent) -> tuple[int, object]: rule = rules.get(row.rule_id) if not rule: return 0, row.created_at conditions = rule.conditions_json if isinstance(rule.conditions_json, dict) else {} constrained = sum((bool(rule.categories_json), bool(rule.targets_json), bool(conditions))) return constrained * 100 + severity_rank.get(rule.severity_min, 0), row.created_at specific_media_rows = [ row for row in rows if row.rule_id in {'notify-deluge-stalled-downloads', 'notify-widefrog-failed-jobs'} ] for row in rows: if row.rule_id != 'notify-media-pipeline-issue': continue message = str(row.message or '').lower() expected_rule = ( 'notify-deluge-stalled-downloads' if 'deluge' in message and 'stalled' in message else 'notify-widefrog-failed-jobs' if 'widefrog' in message and 'fail' in message else None ) if not expected_rule: continue overlap = next(( specific for specific in specific_media_rows if specific.rule_id == expected_rule and abs((specific.created_at - row.created_at).total_seconds()) <= 5 ), None) if not overlap: continue row.status = 'dismissed' row.reason = 'superseded_by_specific_rule' row.read = True session.add(row) changed = True for duplicates in grouped.values(): if len(duplicates) < 2: continue timestamps = [row.created_at for row in duplicates] if (max(timestamps) - min(timestamps)).total_seconds() > 5: continue keep = max(duplicates, key=specificity) for row in duplicates: if row.id == keep.id: continue row.status = 'dismissed' row.reason = 'superseded_by_specific_rule' row.read = True session.add(row) changed = True return changed def _upgrade_suppressed_notification_events(session) -> bool: """Collapse legacy scheduler spam while retaining one read suppression record per identity.""" def suppression_count(row: NotificationEvent) -> int: payload = row.payload_json if isinstance(row.payload_json, dict) else {} try: return max(1, int(payload.get('suppressionCount') or 1)) except (TypeError, ValueError): return 1 def suppression_timestamp(row: NotificationEvent, key: str) -> datetime: payload = row.payload_json if isinstance(row.payload_json, dict) else {} raw_value = payload.get(key) if isinstance(raw_value, str): try: parsed = datetime.fromisoformat(raw_value.replace('Z', '+00:00')) if parsed.tzinfo is not None: parsed = parsed.astimezone(UTC).replace(tzinfo=None) return parsed except ValueError: pass return row.created_at rows = session.exec( select(NotificationEvent) .where(NotificationEvent.status == 'suppressed') .order_by(NotificationEvent.created_at.desc()) ).all() grouped: dict[tuple[str, str, str], list[NotificationEvent]] = {} changed = False for row in rows: if not row.read: row.read = True session.add(row) changed = True if row.reason not in {'cooldown', 'quiet_hours'}: continue key = (row.rule_id, row.dedupe_key, row.reason or 'unspecified') grouped.setdefault(key, []).append(row) for duplicates in grouped.values(): keeper = duplicates[0] payload = keeper.payload_json if isinstance(keeper.payload_json, dict) else {} total = sum(suppression_count(row) for row in duplicates) first_suppressed = min(suppression_timestamp(row, 'firstSuppressedAt') for row in duplicates) last_suppressed = max(suppression_timestamp(row, 'lastSuppressedAt') for row in duplicates) normalized_payload = { **payload, 'suppressionCount': total, 'firstSuppressedAt': first_suppressed.isoformat(), 'lastSuppressedAt': last_suppressed.isoformat(), } if not keeper.read or keeper.payload_json != normalized_payload: keeper.read = True keeper.payload_json = normalized_payload session.add(keeper) changed = True for duplicate in duplicates[1:]: session.delete(duplicate) changed = True return changed def _ensure_notification_suppression_index() -> None: """Enforce one live routine-suppression row per rule identity after legacy compaction.""" with db_session.engine.begin() as connection: connection.execute(text(""" CREATE UNIQUE INDEX IF NOT EXISTS uq_notification_routine_suppression ON notification_events (rule_id, dedupe_key, reason) WHERE status = 'suppressed' AND reason IN ('cooldown', 'quiet_hours') """)) def _seed_apps() -> list[App]: return [ App( id="widefrog", name="Widefrog", category="Media", status="healthy", criticality="medium", connector_status="connected", container_status="running", voice_enabled=True, ), App( id="sonarr", name="Sonarr", category="Media", status="healthy", criticality="low", connector_status="connected", container_status="running", voice_enabled=True, ), App( id="radarr", name="Radarr", category="Media", status="healthy", criticality="low", connector_status="connected", container_status="running", voice_enabled=True, ), App( id="prowlarr", name="Prowlarr", category="Media", status="healthy", criticality="medium", connector_status="connected" if (settings.apis.get("PROWLARR_URL") and settings.apis.get("PROWLARR_API_KEY")) else "disconnected", container_status="running", voice_enabled=False, ), App( id="deluge", name="Deluge", category="Downloads", status="healthy", criticality="medium", connector_status="connected" if (settings.apis.get("DELUGE_URL") and settings.apis.get("DELUGE_PASSWORD")) else "disconnected", container_status="running", voice_enabled=False, ), App( id="plex", name="Plex", category="Media", status="healthy", criticality="medium", connector_status="connected" if (settings.apis.get("PLEX_URL") and settings.apis.get("PLEX_TOKEN")) else "disconnected", container_status="running", voice_enabled=False, ), App( id="tautulli", name="Tautulli", category="Media", status="healthy", criticality="medium", connector_status="connected" if (settings.apis.get("TAUTULLI_URL") and settings.apis.get("TAUTULLI_API_KEY")) else "disconnected", container_status="running", voice_enabled=False, ), App( id="jellyfin", name="Jellyfin", category="Media", status="healthy", criticality="low", connector_status="connected", container_status="running", voice_enabled=True, ), App( id="gitea", name="Gitea", category="Dev", status="healthy", criticality="medium", connector_status="disconnected", container_status="running", voice_enabled=False, ), App( id="projectbrain", name="ProjectBrain", category="Dev", status="healthy", criticality="medium", connector_status="disconnected", container_status="running", voice_enabled=False, ), App( id="psforge", name="PSForge", category="Dev", status="healthy", criticality="low", connector_status="disconnected", container_status="running", voice_enabled=False, ), App( id="spoortracker", name="SpoorTracker", category="Monitoring", status="healthy", criticality="low", connector_status="disconnected", container_status="running", voice_enabled=False, ), App( id="authentik", name="Authentik", category="Auth", status="healthy", criticality="critical", connector_status="connected" if (settings.apis.get("AUTHENTIK_URL") and settings.apis.get("AUTHENTIK_TOKEN")) else "disconnected", container_status="running", voice_enabled=False, protected=True, ), App( id="nginx_proxy_manager", name="Nginx Proxy Manager", category="Network", status="healthy", criticality="critical", connector_status="connected" if (settings.apis.get("NPM_URL") and settings.apis.get("NPM_IDENTITY") and settings.apis.get("NPM_SECRET")) else "disconnected", container_status="running", voice_enabled=False, protected=True, ), App( id="zabbix", name="Zabbix", category="Monitoring", status="healthy", criticality="high", connector_status="disconnected", container_status="running", voice_enabled=False, protected=True, ), App( id="home_assistant", name="Home Assistant", category="Infrastructure", status="healthy", criticality="medium", connector_status="connected" if settings.apis.get("HOME_ASSISTANT_URL") else "disconnected", container_status="running", voice_enabled=True, ), App( id="portainer", name="Portainer", category="Infrastructure", status="healthy", criticality="high", connector_status="connected" if settings.apis.get("PORTAINER_URL") else "disconnected", container_status="running", voice_enabled=False, protected=True, ), App( id="postgres", name="PostgreSQL", category="Database", status="healthy", criticality="critical", connector_status="disconnected", container_status="running", voice_enabled=False, protected=True, ), App( id="redis", name="Redis", category="Database", status="healthy", criticality="high", connector_status="disconnected", container_status="running", voice_enabled=False, protected=True, ), ] def _seed_notification_channels() -> list[NotificationChannel]: return [ NotificationChannel( id="dashboard", type="dashboard", name="Dashboard", enabled=True, config_json={"delivery": "in_app"}, secret_refs_json=[], ), NotificationChannel( id="home_assistant", type="home_assistant", name="Home Assistant", enabled=False, config_json={ "url": settings.apis.get("HOME_ASSISTANT_URL"), "notifyDomain": settings.apis.get("HOME_ASSISTANT_NOTIFY_DOMAIN") or "notify", "notifyService": settings.apis.get("HOME_ASSISTANT_NOTIFY_SERVICE") or "notify", "target": settings.apis.get("HOME_ASSISTANT_NOTIFY_TARGET"), "verifyTls": bool(settings.apis.get("HOME_ASSISTANT_VERIFY_TLS")), "timeoutSeconds": int(settings.apis.get("HOME_ASSISTANT_TIMEOUT_SECONDS") or 10), "titlePrefix": settings.apis.get("HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX") or "AppOps", "dryRun": bool(settings.apis.get("HOME_ASSISTANT_DRY_RUN")), }, secret_refs_json=["HOME_ASSISTANT_TOKEN"], ), NotificationChannel(id="discord", type="discord", name="Discord", enabled=False, config_json={"username": "AppOps", "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_DISCORD_WEBHOOK"]), NotificationChannel(id="telegram", type="telegram", name="Telegram", enabled=False, config_json={"chatId": "", "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_TELEGRAM_BOT_TOKEN"]), NotificationChannel(id="email", type="email", name="E-mail", enabled=False, config_json={"host": "", "port": 465, "username": "", "fromAddress": "", "toAddress": "", "useTls": True, "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_SMTP_PASSWORD"]), ] def _upgrade_default_notification_rule_channels(session) -> bool: home_assistant_defaults = { "notify-critical-safety-finding", "notify-high-safety-finding", "notify-npm-certificate-expiring", "notify-public-host-without-auth", "notify-blocked-dangerous-action", } changed = False for rule_id in home_assistant_defaults: rule = session.get(NotificationRule, rule_id) if rule and "home_assistant" not in (rule.channels_json or []): rule.channels_json = [*(rule.channels_json or ["dashboard"]), "home_assistant"] changed = True session.add(rule) return changed def _seed_notification_rules() -> list[NotificationRule]: return [ NotificationRule(id="notify-critical-safety-finding", name="Critical Safety Finding", source="safety", event_type="safety.finding", severity_min="critical", cooldown_seconds=900, quiet_hours_json={"enabled": False, "allowCriticalOverride": True}, channels_json=["dashboard", "home_assistant"]), NotificationRule(id="notify-high-safety-finding", name="High Safety Finding", source="safety", event_type="safety.finding", severity_min="high", cooldown_seconds=3600, channels_json=["dashboard", "home_assistant"]), NotificationRule(id="notify-coverage-incomplete", name="Coverage Incomplete", source="safety", event_type="safety.finding", severity_min="medium", categories_json=["coverage", "connector_config", "setup", "connectors"], cooldown_seconds=86400, channels_json=["dashboard"]), NotificationRule(id="notify-connector-missing-config", name="Connector Missing Config", source="connector", event_type="connector.missing_config", severity_min="medium", cooldown_seconds=86400, channels_json=["dashboard"]), NotificationRule(id="notify-connector-failed", name="Connector Failure After Previously Connected", source="connector", event_type="connector.failed", severity_min="medium", cooldown_seconds=1800, channels_json=["dashboard"]), NotificationRule(id="notify-media-pipeline-issue", name="Media Pipeline Issue", source="media_pipeline", event_type="media.issue", severity_min="medium", cooldown_seconds=3600, channels_json=["dashboard"]), NotificationRule(id="notify-deluge-stalled-downloads", name="Deluge Stalled Downloads", source="media_pipeline", event_type="media.issue", severity_min="medium", categories_json=["downloads"], cooldown_seconds=3600, channels_json=["dashboard"]), NotificationRule(id="notify-widefrog-failed-jobs", name="Widefrog Failed Jobs", source="media_pipeline", event_type="media.issue", severity_min="medium", categories_json=["widefrog"], cooldown_seconds=3600, channels_json=["dashboard"]), NotificationRule(id="notify-npm-certificate-expiring", name="NPM Certificate Expiring", source="safety", event_type="safety.finding", severity_min="medium", categories_json=["exposure"], cooldown_seconds=86400, channels_json=["dashboard", "home_assistant"], conditions_json={"titleContains": ["certificate", "certificaat"]}), NotificationRule(id="notify-public-host-without-auth", name="Public Host Without Auth Coverage", source="safety", event_type="safety.finding", severity_min="high", categories_json=["auth", "exposure"], cooldown_seconds=43200, channels_json=["dashboard", "home_assistant"]), NotificationRule(id="notify-blocked-dangerous-action", name="Blocked Dangerous Action Attempt", source="audit", event_type="audit.blocked_action", severity_min="high", cooldown_seconds=1800, channels_json=["dashboard", "home_assistant"]), NotificationRule(id="notify-voice-auth-failure", name="Voice Auth Failure", source="audit", event_type="audit.voice_denied", severity_min="medium", cooldown_seconds=3600, channels_json=["dashboard"]), NotificationRule(id="notify-discovery-needs-review", name="Discovery Needs Review", source="inventory", event_type="inventory.needs_review", severity_min="medium", cooldown_seconds=86400, channels_json=["dashboard"]), ] def _seed_voice_export_policies() -> list[Policy]: return [ Policy(id="policy-unraid-health-summary", action="unraid.health_summary", target="unraid", voice="allowed", manual="allowed", risk="low", reason="Reading Unraid health, storage and VM inventory is read-only; mover, format and power mutations use separate actions."), Policy(id="policy-unraid-vms", action="unraid.vms", target="unraid", voice="allowed", manual="allowed", risk="low", reason="Reading the Unraid VM inventory is read-only."), Policy(id="policy-unraid-vm-start", action="unraid.vm.start", target="*", voice="approval_required", manual="allowed", risk="medium", reason="Starting a VM changes host load; manual execution is confirmed and audited, while voice requires approval."), Policy(id="policy-unraid-vm-resume", action="unraid.vm.resume", target="*", voice="approval_required", manual="allowed", risk="medium", reason="Resuming a paused VM changes host load; manual execution is confirmed and audited, while voice requires approval."), Policy(id="policy-unraid-vm-pause", action="unraid.vm.pause", target="*", voice="blocked", manual="approval_required", risk="high", reason="Pausing a running VM interrupts its workload and requires explicit operator approval."), Policy(id="policy-unraid-vm-stop", action="unraid.vm.stop", target="*", voice="blocked", manual="blocked", risk="critical", reason="Unraid stop can force-stop after timeout and remains blocked."), Policy(id="policy-unraid-vm-reboot", action="unraid.vm.reboot", target="*", voice="blocked", manual="blocked", risk="critical", reason="VM reboot remains blocked until shutdown and recovery semantics are safely verifiable."), Policy(id="policy-unraid-vm-force-stop", action="unraid.vm.force_stop", target="*", voice="blocked", manual="blocked", risk="critical", reason="Force-stop is destructive and blocked."), Policy(id="policy-unraid-vm-reset", action="unraid.vm.reset", target="*", voice="blocked", manual="blocked", risk="critical", reason="VM reset is destructive and blocked."), Policy(id="policy-setup-status", action="setup.status", target="appops", voice="allowed", manual="allowed", risk="low", reason="Reading AppOps setup status is read-only."), Policy(id="policy-safety-summary", action="safety.summary", target="safety", voice="allowed", manual="allowed", risk="low", reason="Reading Safety Center summary is read-only."), Policy(id="policy-media-pipeline-check", action="media_pipeline.check", target="media-pipeline", voice="allowed", manual="allowed", risk="low", reason="Media pipeline voice check is read-only in the voice export path."), Policy(id="policy-media-pipeline-diagnose", action="media_pipeline.diagnose", target="media-pipeline", voice="allowed", manual="allowed", risk="low", reason="Media pipeline diagnosis is a read-only evidence summary and does not execute connector actions."), Policy(id="policy-notifications-summary", action="notifications.summary", target="notifications", voice="allowed", manual="allowed", risk="low", reason="Reading notification summary is read-only and never returns notification tokens."), Policy(id="policy-notifications-evaluate-safety", action="notifications.evaluate_safety", target="notifications", voice="approval_required", manual="allowed", risk="medium", reason="Evaluating safety notifications can create notification events and requires voice approval."), ] def _seed_connectors() -> list[Connector]: return [ Connector( id="portainer", name="Portainer", status="disconnected" if not settings.apis.get("PORTAINER_URL") else "connected", last_test="never", secret_ref="PORTAINER_API_KEY", capabilities=[ "container.status", "container.logs", "container.restart", "container.stop", "stack.status", "stack.redeploy", ], type="Infrastructure", ), Connector( id="unraid", name="Unraid", status="disconnected" if not settings.apis.get("UNRAID_URL") else "connected", last_test="never", secret_ref="UNRAID_API_KEY", capabilities=[ "unraid.health_summary", "unraid.array", "unraid.disks", "unraid.cache", "unraid.parity", "unraid.docker", "unraid.vms", "unraid.shares", ], type="Infrastructure", ), Connector( id="sonarr", name="Sonarr", status="disconnected" if not settings.apis.get("SONARR_URL") else "connected", last_test="never", secret_ref="SONARR_API_KEY", capabilities=[ "sonarr.health", "sonarr.search_series", "sonarr.add_series", "sonarr.queue_status", "sonarr.wanted_missing", ], type="Media", ), Connector( id="radarr", name="Radarr", status="disconnected" if not (settings.apis.get("RADARR_URL") and settings.apis.get("RADARR_API_KEY")) else "connected", last_test="never", secret_ref="RADARR_API_KEY", capabilities=[ "radarr.health", "radarr.search_movie", "radarr.add_movie", "radarr.queue_status", "radarr.wanted_missing", ], type="Media", ), Connector( id="prowlarr", name="Prowlarr", status="disconnected" if not (settings.apis.get("PROWLARR_URL") and settings.apis.get("PROWLARR_API_KEY")) else "connected", last_test="never", secret_ref="PROWLARR_API_KEY", capabilities=[ "prowlarr.health", "prowlarr.indexer_status", "prowlarr.application_status", "prowlarr.indexer_health", "prowlarr.search_test", "prowlarr.sync_status", ], type="Media", ), Connector( id="deluge", name="Deluge", status="disconnected" if not (settings.apis.get("DELUGE_URL") and settings.apis.get("DELUGE_PASSWORD")) else "connected", last_test="never", secret_ref="DELUGE_PASSWORD", capabilities=[ "deluge.health", "deluge.session_status", "deluge.torrent_status", "deluge.active_downloads", "deluge.stalled_downloads", "deluge.paused_downloads", "deluge.speed_status", "deluge.pause_torrent", "deluge.resume_torrent", "deluge.pause_all", "deluge.resume_all", ], type="Downloads", ), Connector( id="plex", name="Plex", status="disconnected" if not (settings.apis.get("PLEX_URL") and settings.apis.get("PLEX_TOKEN")) else "connected", last_test="never", secret_ref="PLEX_TOKEN", capabilities=[ "plex.health", "plex.server_status", "plex.library_status", "plex.active_sessions", "plex.recent_media", "plex.scan_library", "plex.scan_all_libraries", "plex.stream_summary", ], type="Media", ), Connector( id="tautulli", name="Tautulli", status="disconnected" if not (settings.apis.get("TAUTULLI_URL") and settings.apis.get("TAUTULLI_API_KEY")) else "connected", last_test="never", secret_ref="TAUTULLI_API_KEY", capabilities=[ "tautulli.health", "tautulli.status", "tautulli.activity", "tautulli.history", "tautulli.users", "tautulli.libraries", "tautulli.recently_added", "tautulli.watch_stats", "tautulli.stream_summary", "tautulli.plex_info", ], type="Media", ), Connector( id="authentik", name="Authentik", status="disconnected" if not (settings.apis.get("AUTHENTIK_URL") and settings.apis.get("AUTHENTIK_TOKEN")) else "connected", last_test="never", secret_ref="AUTHENTIK_TOKEN", capabilities=[ "authentik.health", "authentik.system_status", "authentik.applications", "authentik.providers", "authentik.proxy_providers", "authentik.oauth2_providers", "authentik.outposts", "authentik.outpost_health", "authentik.flows_summary", "authentik.policies_summary", "authentik.events", "authentik.auth_coverage", "authentik.security_warnings", ], type="Auth", ), Connector( id="nginx_proxy_manager", name="Nginx Proxy Manager", status="disconnected" if not (settings.apis.get("NPM_URL") and settings.apis.get("NPM_IDENTITY") and settings.apis.get("NPM_SECRET")) else "connected", last_test="never", secret_ref="NPM_SECRET", capabilities=[ "npm.health", "npm.proxy_hosts", "npm.certificates", "npm.expiring_certificates", "npm.access_lists", "npm.exposure_summary", "npm.host_reachability", "npm.security_warnings", ], type="Network", ), Connector( id="widefrog", name="Widefrog", status="disconnected" if not settings.apis.get("WIDEFROG_URL") else "connected", last_test="never", secret_ref="WIDEFROG_API_KEY", capabilities=[ "widefrog.health", "widefrog.queue_status", "widefrog.failed_jobs", "widefrog.activity", "widefrog.retry_failed", "widefrog.scan_library", "widefrog.download_status", ], type="Media", ), Connector( id="home_assistant", name="Home Assistant", status="disconnected" if not settings.apis.get("HOME_ASSISTANT_URL") else "connected", last_test="never", secret_ref="HOME_ASSISTANT_TOKEN", capabilities=["read_status", "trigger_action"], type="Automation", ), ] def _seed_policies() -> list[Policy]: return [ Policy(id="policy-volume-delete", action="volume.delete", target="*", voice="blocked", manual="blocked", risk="critical", reason="Dangerous operation."), Policy(id="policy-container-stop", action="container.stop", target="*", voice="blocked", manual="blocked", risk="high", reason="Container stop is outside AppOps Gateway V1; use Portainer or Unraid directly after manual review."), Policy(id="policy-container-clear-logs", action="container.clear_logs", target="*", voice="blocked", manual="blocked", risk="high", reason="Clearing container logs is outside AppOps Gateway V1; log viewing and local download remain read-only."), Policy(id="policy-gateway-clear-cache", action="gateway.clear_cache", target="gateway", voice="blocked", manual="blocked", risk="high", reason="Clearing gateway cache is outside AppOps Gateway V1; use explicit refresh or restart procedures after review."), Policy(id="policy-container-restart-authentik", action="container.restart", target="authentik", voice="approval_required", manual="approval_required", risk="critical", reason="Authenticator restart is high-risk."), Policy(id="policy-container-restart-widefrog", action="container.restart", target="widefrog", voice="allowed", manual="allowed", risk="medium", reason="Widefrog restart is a safe first-line media action."), Policy(id="policy-unraid-mover", action="unraid.mover.start", target="unraid", voice="blocked", manual="approval_required", risk="high", reason="Mover start requires manual approval except read-only voice flows."), Policy(id="policy-stack-redeploy", action="stack.redeploy", target="*", voice="approval_required", manual="approval_required", risk="high", reason="Stack redeploy needs approval in this safe policy."), *_seed_voice_export_policies(), Policy(id="policy-sonarr-search", action="sonarr.search_series", target="sonarr", voice="allowed", manual="allowed", risk="low", reason="Read-only Sonarr lookup."), Policy(id="policy-sonarr-add", action="sonarr.add_series", target="sonarr", voice="approval_required", manual="allowed", risk="low", reason="Adding a monitored series is a reversible app-level action and does not affect infrastructure."), Policy(id="policy-sonarr-delete", action="sonarr.delete_series", target="sonarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting series can remove metadata/files and is not supported by AppOps Gateway V1."), Policy(id="policy-sonarr-mass-edit", action="sonarr.mass_edit", target="sonarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."), Policy(id="policy-radarr-search", action="radarr.search_movie", target="radarr", voice="allowed", manual="allowed", risk="low", reason="Read-only Radarr lookup."), Policy(id="policy-radarr-add", action="radarr.add_movie", target="radarr", voice="approval_required", manual="allowed", risk="low", reason="Adding a monitored movie is a reversible app-level action and does not affect infrastructure."), Policy(id="policy-radarr-delete", action="radarr.delete_movie", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting movies can remove metadata/files and is not supported by AppOps Gateway V1."), Policy(id="policy-radarr-delete-files", action="radarr.delete_files", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting movie files is destructive and is blocked by AppOps Gateway V1."), Policy(id="policy-radarr-mass-edit", action="radarr.mass_edit", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."), Policy(id="policy-widefrog-health", action="widefrog.health", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog health check."), Policy(id="policy-widefrog-queue", action="widefrog.queue_status", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog queue status."), Policy(id="policy-widefrog-failed", action="widefrog.failed_jobs", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog failed jobs status."), Policy(id="policy-widefrog-activity", action="widefrog.activity", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog activity status."), Policy(id="policy-widefrog-download-status", action="widefrog.download_status", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog download status."), Policy(id="policy-widefrog-scan-library", action="widefrog.scan_library", target="widefrog", voice="approval_required", manual="allowed", risk="medium", reason="Scanning the media library is a non-destructive app-level action but may create load."), Policy(id="policy-widefrog-retry-failed", action="widefrog.retry_failed", target="widefrog", voice="approval_required", manual="allowed", risk="medium", reason="Retrying failed jobs may start downloads or processing and should be operator-controlled."), Policy(id="policy-widefrog-delete-job", action="widefrog.delete_job", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting Widefrog jobs is not supported by AppOps Gateway V1."), Policy(id="policy-widefrog-delete-media", action="widefrog.delete_media", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting media is blocked."), Policy(id="policy-widefrog-clear-library", action="widefrog.clear_library", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Clearing library data is blocked."), Policy(id="policy-widefrog-clear-queue", action="widefrog.clear_queue", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Clearing queue data is blocked."), Policy(id="policy-widefrog-reset-database", action="widefrog.reset_database", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Database reset is blocked."), Policy(id="policy-widefrog-delete-downloads", action="widefrog.delete_downloads", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting downloads is blocked."), Policy(id="policy-widefrog-prune", action="widefrog.prune", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Prune actions are blocked."), Policy(id="policy-prowlarr-health", action="prowlarr.health", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr health is read-only."), Policy(id="policy-prowlarr-indexers", action="prowlarr.indexer_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr indexer status is read-only."), Policy(id="policy-prowlarr-applications", action="prowlarr.application_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr app sync status is read-only."), Policy(id="policy-prowlarr-indexer-health", action="prowlarr.indexer_health", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr indexer health is read-only."), Policy(id="policy-prowlarr-sync-status", action="prowlarr.sync_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr sync status is read-only."), Policy(id="policy-prowlarr-search-test", action="prowlarr.search_test", target="prowlarr", voice="approval_required", manual="allowed", risk="medium", reason="Search test consumes indexer API quota and is read-only controlled access."), Policy(id="policy-prowlarr-delete-indexer", action="prowlarr.delete_indexer", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting indexers is destructive and not supported by AppOps Gateway V1."), Policy(id="policy-prowlarr-delete-application", action="prowlarr.delete_application", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting Prowlarr applications is not supported by AppOps Gateway V1."), Policy(id="policy-prowlarr-update-indexer", action="prowlarr.update_indexer", target="prowlarr", voice="blocked", manual="blocked", risk="high", reason="Updating indexers is not supported by AppOps Gateway V1."), Policy(id="policy-prowlarr-update-application", action="prowlarr.update_application", target="prowlarr", voice="blocked", manual="blocked", risk="high", reason="Updating Prowlarr applications is not supported by AppOps Gateway V1."), Policy(id="policy-prowlarr-grab-release", action="prowlarr.grab_release", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Release grab/download is blocked in Prowlarr Connector V1."), Policy(id="policy-prowlarr-push-release", action="prowlarr.push_release", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Release push is blocked in Prowlarr Connector V1."), Policy(id="policy-prowlarr-mass-edit", action="prowlarr.mass_edit", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."), Policy(id="policy-prowlarr-test-all-indexers", action="prowlarr.test_all_indexers", target="prowlarr", voice="blocked", manual="blocked", risk="medium", reason="Testing all indexers may consume API quota and is disabled in V1."), Policy(id="policy-deluge-health", action="deluge.health", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge health is read-only."), Policy(id="policy-deluge-session", action="deluge.session_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge session status is read-only."), Policy(id="policy-deluge-torrents", action="deluge.torrent_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge torrent status is read-only."), Policy(id="policy-deluge-active", action="deluge.active_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading active downloads is read-only."), Policy(id="policy-deluge-stalled", action="deluge.stalled_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading stalled downloads is read-only."), Policy(id="policy-deluge-paused", action="deluge.paused_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading paused downloads is read-only."), Policy(id="policy-deluge-speeds", action="deluge.speed_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge speed status is read-only."), Policy(id="policy-deluge-pause-torrent", action="deluge.pause_torrent", target="deluge", voice="approval_required", manual="allowed", risk="medium", reason="Pausing a torrent changes download state and should be operator-controlled."), Policy(id="policy-deluge-resume-torrent", action="deluge.resume_torrent", target="deluge", voice="approval_required", manual="allowed", risk="medium", reason="Resuming a torrent changes download state and should be operator-controlled."), Policy(id="policy-deluge-pause-all", action="deluge.pause_all", target="deluge", voice="blocked", manual="approval_required", risk="high", reason="Pausing all downloads affects the media pipeline."), Policy(id="policy-deluge-resume-all", action="deluge.resume_all", target="deluge", voice="blocked", manual="approval_required", risk="high", reason="Resuming all downloads affects the media pipeline."), Policy(id="policy-deluge-remove-torrent", action="deluge.remove_torrent", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Removing torrents is destructive and blocked in V1."), Policy(id="policy-deluge-remove-data", action="deluge.remove_torrent_data", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Removing downloaded data is blocked."), Policy(id="policy-deluge-delete-data", action="deluge.delete_data", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Deleting downloaded data is blocked."), Policy(id="policy-deluge-force-recheck", action="deluge.force_recheck", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Force recheck is unsupported in V1."), Policy(id="policy-deluge-move-storage", action="deluge.move_storage", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Moving torrent storage is unsupported in V1."), Policy(id="policy-deluge-add-torrent", action="deluge.add_torrent", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Adding torrents is unsupported in V1."), Policy(id="policy-deluge-add-magnet", action="deluge.add_magnet", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Adding magnets is unsupported in V1."), Policy(id="policy-deluge-mass-edit", action="deluge.mass_edit", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are blocked in V1."), Policy(id="policy-deluge-set-options", action="deluge.set_options", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Changing torrent options is blocked in V1."), Policy(id="policy-plex-health", action="plex.health", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex health is read-only."), Policy(id="policy-plex-server-status", action="plex.server_status", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex server status is read-only."), Policy(id="policy-plex-library-status", action="plex.library_status", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex library status is read-only."), Policy(id="policy-plex-active-sessions", action="plex.active_sessions", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex active sessions is read-only."), Policy(id="policy-plex-recent-media", action="plex.recent_media", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex recent media is read-only."), Policy(id="policy-plex-stream-summary", action="plex.stream_summary", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex stream summary is read-only."), Policy(id="policy-plex-scan-library", action="plex.scan_library", target="plex", voice="approval_required", manual="allowed", risk="medium", reason="Scanning a Plex library can create server load and should be operator-controlled."), Policy(id="policy-plex-scan-all", action="plex.scan_all_libraries", target="plex", voice="blocked", manual="approval_required", risk="high", reason="Scanning all Plex libraries can create significant server load."), Policy(id="policy-plex-delete-media", action="plex.delete_media", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Deleting Plex media is blocked."), Policy(id="policy-plex-delete-library", action="plex.delete_library", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Deleting Plex libraries is blocked."), Policy(id="policy-plex-empty-trash", action="plex.empty_trash", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Empty trash can remove media references and is blocked."), Policy(id="policy-plex-clean-bundles", action="plex.clean_bundles", target="plex", voice="blocked", manual="blocked", risk="high", reason="Cleaning bundles is blocked in V1."), Policy(id="policy-plex-optimize-db", action="plex.optimize_database", target="plex", voice="blocked", manual="blocked", risk="high", reason="Database optimization is blocked in V1."), Policy(id="policy-plex-kill-session", action="plex.kill_session", target="plex", voice="blocked", manual="blocked", risk="high", reason="Killing user sessions is blocked in V1."), Policy(id="policy-plex-user-management", action="plex.user_management", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Plex user management is blocked."), Policy(id="policy-plex-force-refresh-all", action="plex.force_refresh_all_metadata", target="plex", voice="blocked", manual="blocked", risk="high", reason="Force refreshing all metadata is blocked in V1."), Policy(id="policy-plex-mass-edit", action="plex.mass_edit", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Plex mass edits are blocked in V1."), Policy(id="policy-tautulli-health", action="tautulli.health", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli health is read-only."), Policy(id="policy-tautulli-status", action="tautulli.status", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli status is read-only."), Policy(id="policy-tautulli-activity", action="tautulli.activity", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading current Tautulli activity is read-only."), Policy(id="policy-tautulli-history", action="tautulli.history", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="Viewing watch history may expose user activity."), Policy(id="policy-tautulli-users", action="tautulli.users", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="User statistics may expose personal viewing behavior."), Policy(id="policy-tautulli-libraries", action="tautulli.libraries", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli libraries is read-only."), Policy(id="policy-tautulli-recently-added", action="tautulli.recently_added", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading recently added media is read-only."), Policy(id="policy-tautulli-watch-stats", action="tautulli.watch_stats", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="Watch statistics may expose personal viewing behavior."), Policy(id="policy-tautulli-stream-summary", action="tautulli.stream_summary", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading stream summary is read-only."), Policy(id="policy-tautulli-plex-info", action="tautulli.plex_info", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Plex server info through Tautulli is read-only."), Policy(id="policy-tautulli-terminate-session", action="tautulli.terminate_session", target="tautulli", voice="blocked", manual="blocked", risk="high", reason="Terminating sessions is blocked in V1."), Policy(id="policy-tautulli-delete-newsletter", action="tautulli.delete_newsletter", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting newsletters is blocked in V1."), Policy(id="policy-tautulli-delete-notification-log", action="tautulli.delete_notification_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting notification logs is blocked in V1."), Policy(id="policy-tautulli-delete-notifier", action="tautulli.delete_notifier", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting notifiers is blocked in V1."), Policy(id="policy-tautulli-delete-recently-added", action="tautulli.delete_recently_added", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting recently-added records is blocked in V1."), Policy(id="policy-tautulli-download-config", action="tautulli.download_config", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli config is blocked in V1."), Policy(id="policy-tautulli-download-database", action="tautulli.download_database", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli database is blocked in V1."), Policy(id="policy-tautulli-download-export", action="tautulli.download_export", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli exports is blocked in V1."), Policy(id="policy-tautulli-download-log", action="tautulli.download_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli logs is blocked in V1."), Policy(id="policy-tautulli-download-plex-log", action="tautulli.download_plex_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Plex logs through Tautulli is blocked in V1."), Policy(id="policy-tautulli-notify", action="tautulli.notify", target="tautulli", voice="blocked", manual="blocked", risk="high", reason="Sending notifications through Tautulli is blocked in V1."), Policy(id="policy-tautulli-user-management", action="tautulli.user_management", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Tautulli user management is blocked in V1."), Policy(id="policy-tautulli-mass-edit", action="tautulli.mass_edit", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Tautulli mass edits are blocked in V1."), Policy(id="policy-npm-health", action="npm.health", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading NPM health is read-only."), Policy(id="policy-npm-proxy-hosts", action="npm.proxy_hosts", target="nginx_proxy_manager", voice="approval_required", manual="allowed", risk="medium", reason="Proxy hosts may reveal external exposure."), Policy(id="policy-npm-certificates", action="npm.certificates", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading certificate metadata is read-only."), Policy(id="policy-npm-expiring-certificates", action="npm.expiring_certificates", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading expiring certificate metadata is read-only."), Policy(id="policy-npm-access-lists", action="npm.access_lists", target="nginx_proxy_manager", voice="approval_required", manual="allowed", risk="medium", reason="Access list information may reveal security structure."), Policy(id="policy-npm-exposure-summary", action="npm.exposure_summary", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="medium", reason="Exposure summary is a read-only security overview."), Policy(id="policy-npm-host-reachability", action="npm.host_reachability", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="V1 host reachability is metadata-only."), Policy(id="policy-npm-security-warnings", action="npm.security_warnings", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading NPM security warnings is read-only."), Policy(id="policy-npm-create-proxy-host", action="npm.create_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Creating proxy hosts is not supported by AppOps Gateway V1."), Policy(id="policy-npm-update-proxy-host", action="npm.update_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Updating proxy hosts is not supported by AppOps Gateway V1."), Policy(id="policy-npm-delete-proxy-host", action="npm.delete_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Deleting proxy hosts is destructive and blocked."), Policy(id="policy-npm-enable-proxy-host", action="npm.enable_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Enabling proxy hosts changes external exposure and is blocked in V1."), Policy(id="policy-npm-disable-proxy-host", action="npm.disable_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Disabling proxy hosts changes external exposure and is blocked in V1."), Policy(id="policy-npm-renew-certificate", action="npm.renew_certificate", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Certificate renewal is not executed by AppOps Gateway V1."), Policy(id="policy-npm-delete-certificate", action="npm.delete_certificate", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Deleting certificates is blocked."), Policy(id="policy-npm-modify-access-list", action="npm.modify_access_list", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Access list mutations are blocked in V1."), Policy(id="policy-npm-create-user", action="npm.create_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."), Policy(id="policy-npm-update-user", action="npm.update_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."), Policy(id="policy-npm-delete-user", action="npm.delete_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."), Policy(id="policy-npm-mass-edit", action="npm.mass_edit", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM bulk mutations are blocked."), Policy(id="policy-npm-unknown-mutation", action="npm.unknown_mutation", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Unknown NPM mutations fail closed."), Policy(id="policy-authentik-health", action="authentik.health", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik health is read-only."), Policy(id="policy-authentik-system", action="authentik.system_status", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik system status is read-only."), Policy(id="policy-authentik-applications", action="authentik.applications", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Applications reveal SSO coverage and are operator-visible."), Policy(id="policy-authentik-providers", action="authentik.providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Provider metadata can reveal security topology."), Policy(id="policy-authentik-proxy-providers", action="authentik.proxy_providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Proxy provider metadata can reveal exposure topology."), Policy(id="policy-authentik-oauth2-providers", action="authentik.oauth2_providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="OAuth provider metadata can reveal application integrations."), Policy(id="policy-authentik-outposts", action="authentik.outposts", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Outpost metadata is security-topology information."), Policy(id="policy-authentik-outpost-health", action="authentik.outpost_health", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Outpost health summary is read-only."), Policy(id="policy-authentik-flows", action="authentik.flows_summary", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Flow summaries reveal authentication design."), Policy(id="policy-authentik-policies", action="authentik.policies_summary", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Policy summaries reveal security controls."), Policy(id="policy-authentik-events", action="authentik.events", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Events may expose user activity and require operator context."), Policy(id="policy-authentik-auth-coverage", action="authentik.auth_coverage", target="authentik", voice="allowed", manual="allowed", risk="medium", reason="Auth coverage is a read-only security overview."), Policy(id="policy-authentik-security-warnings", action="authentik.security_warnings", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik security warnings is read-only."), Policy(id="policy-authentik-create-application", action="authentik.create_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."), Policy(id="policy-authentik-update-application", action="authentik.update_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."), Policy(id="policy-authentik-delete-application", action="authentik.delete_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."), Policy(id="policy-authentik-create-provider", action="authentik.create_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."), Policy(id="policy-authentik-update-provider", action="authentik.update_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."), Policy(id="policy-authentik-delete-provider", action="authentik.delete_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."), Policy(id="policy-authentik-create-outpost", action="authentik.create_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."), Policy(id="policy-authentik-update-outpost", action="authentik.update_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."), Policy(id="policy-authentik-delete-outpost", action="authentik.delete_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."), Policy(id="policy-authentik-create-policy", action="authentik.create_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."), Policy(id="policy-authentik-update-policy", action="authentik.update_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."), Policy(id="policy-authentik-delete-policy", action="authentik.delete_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."), Policy(id="policy-authentik-create-user", action="authentik.create_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."), Policy(id="policy-authentik-update-user", action="authentik.update_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."), Policy(id="policy-authentik-delete-user", action="authentik.delete_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."), Policy(id="policy-authentik-create-group", action="authentik.create_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."), Policy(id="policy-authentik-update-group", action="authentik.update_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."), Policy(id="policy-authentik-delete-group", action="authentik.delete_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."), Policy(id="policy-authentik-rotate-token", action="authentik.rotate_token", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Token rotation is never automated by Connector V1."), Policy(id="policy-authentik-force-sync", action="authentik.force_sync", target="authentik", voice="blocked", manual="blocked", risk="high", reason="Force sync is a mutating operation and blocked in Connector V1."), Policy(id="policy-authentik-mass-edit", action="authentik.mass_edit", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Bulk Authentik mutations are blocked."), Policy(id="policy-authentik-unknown-mutation", action="authentik.unknown_mutation", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Unknown Authentik mutations fail closed."), Policy(id="policy-forge-oidc-provision", action="forge.oidc.provision", target="itworx-forge", voice="blocked", manual="allowed", risk="critical", reason="The fixed Forge OIDC client may be provisioned only by an authenticated manual operator route; voice and generic Authentik mutations remain blocked."), Policy(id="policy-forge-secret-prepare", action="forge.secrets.prepare", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Preparing the fixed Forge bootstrap secret bundle is default-denied and requires an explicit exact-target manual operator policy; voice remains blocked."), Policy(id="policy-forge-route-stage", action="forge.route.stage", target="itworx-forge", voice="blocked", manual="blocked", risk="critical", reason="Staging the fixed disabled Forge proxy host is default-denied; voice remains blocked."), ]