Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:35:57 +02:00
commit 1dfbca08a1
528 files changed
+131054

No files matched your search

+944
View File
@@ -0,0 +1,944 @@
from datetime import UTC, datetime
from sqlalchemy import inspect, text
from sqlmodel import SQLModel, select
from . import session as db_session
from .session import get_session
from ..core.config import Settings
from ..services.app_classifier import classify_container
from ..services.app_registry import category_label
from .models import App, AppContainerMapping, Connector, DiscoveredContainer, NotificationChannel, NotificationEvent, NotificationRule, Policy, WorkMeshPromotion
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
from ..services.runtime_service import apply_stored_operator_auth_setting
apply_stored_connector_settings(session)
apply_stored_operator_auth_setting(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."),
]