955 lines
69 KiB
Python
955 lines
69 KiB
Python
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."),
|
|
]
|