Files
Unraid-AppOps-Gateway-Public/backend/app/services/setup_service.py
T

647 lines
27 KiB
Python

from __future__ import annotations
from typing import Any
from sqlmodel import Session, select
from ..connectors.portainer import PortainerConnector
from ..connectors.unraid import UnraidConnector
from ..core.config import settings
from ..core.redaction import redact_message
from ..core.time import utc_now
from ..core.network_security import validate_connector_url
from ..db.models import AppContainerMapping, AppSetting, AuditEvent, Connector, DiscoveryRun, Secret
from .audit_logger import log_event
from .secret_store import empty_secret_status, get_secret, has_secret, secret_status, secret_storage_status, set_secret
from . import safety_center_service
_CONNECTOR_FIELDS: dict[str, dict[str, Any]] = {
'portainer': {
'required': ['PORTAINER_URL', 'PORTAINER_API_KEY', 'PORTAINER_ENDPOINT_ID'],
'secret_env': 'PORTAINER_API_KEY',
'field_env': {'url': 'PORTAINER_URL', 'endpointId': 'PORTAINER_ENDPOINT_ID', 'verifyTls': 'PORTAINER_VERIFY_TLS'},
'safe_fields': ['url', 'endpointId', 'verifyTls'],
},
'unraid': {
'required': ['UNRAID_URL', 'UNRAID_API_KEY'],
'secret_env': 'UNRAID_API_KEY',
'field_env': {'url': 'UNRAID_URL', 'verifyTls': 'UNRAID_VERIFY_TLS'},
'safe_fields': ['url', 'verifyTls'],
},
'sonarr': {
'required': ['SONARR_URL', 'SONARR_API_KEY'],
'secret_env': 'SONARR_API_KEY',
'field_env': {'url': 'SONARR_URL'},
'safe_fields': ['url'],
},
'radarr': {
'required': ['RADARR_URL', 'RADARR_API_KEY'],
'secret_env': 'RADARR_API_KEY',
'field_env': {'url': 'RADARR_URL', 'verifyTls': 'RADARR_VERIFY_TLS'},
'safe_fields': ['url', 'verifyTls'],
},
'prowlarr': {
'required': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
'secret_env': 'PROWLARR_API_KEY',
'field_env': {'url': 'PROWLARR_URL', 'verifyTls': 'PROWLARR_VERIFY_TLS'},
'safe_fields': ['url', 'verifyTls'],
},
'deluge': {
'required': ['DELUGE_URL', 'DELUGE_PASSWORD'],
'secret_env': 'DELUGE_PASSWORD',
'field_env': {'url': 'DELUGE_URL', 'verifyTls': 'DELUGE_VERIFY_TLS'},
'safe_fields': ['url', 'verifyTls'],
},
'plex': {
'required': ['PLEX_URL', 'PLEX_TOKEN'],
'secret_env': 'PLEX_TOKEN',
'field_env': {'url': 'PLEX_URL', 'verifyTls': 'PLEX_VERIFY_TLS'},
'safe_fields': ['url', 'verifyTls'],
},
'tautulli': {
'required': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'],
'secret_env': 'TAUTULLI_API_KEY',
'field_env': {'url': 'TAUTULLI_URL', 'verifyTls': 'TAUTULLI_VERIFY_TLS'},
'safe_fields': ['url', 'verifyTls'],
},
'nginx_proxy_manager': {
'required': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'],
'secret_env': 'NPM_SECRET',
'field_env': {'url': 'NPM_URL', 'identity': 'NPM_IDENTITY', 'verifyTls': 'NPM_VERIFY_TLS'},
'safe_fields': ['url', 'identity', 'verifyTls'],
},
'authentik': {
'required': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'],
'secret_env': 'AUTHENTIK_TOKEN',
'field_env': {
'url': 'AUTHENTIK_URL',
'verifyTls': 'AUTHENTIK_VERIFY_TLS',
'outpostUrl': 'AUTHENTIK_OUTPOST_URL',
'expectEmbeddedOutpost': 'AUTHENTIK_EXPECT_EMBEDDED_OUTPOST',
},
'safe_fields': ['url', 'verifyTls', 'outpostUrl', 'expectEmbeddedOutpost'],
},
'widefrog': {
'required': ['WIDEFROG_URL'],
'secret_env': 'WIDEFROG_API_KEY',
'field_env': {'url': 'WIDEFROG_URL', 'verifyTls': 'WIDEFROG_VERIFY_TLS'},
'safe_fields': ['url', 'verifyTls'],
},
'home_assistant': {
'required': ['HOME_ASSISTANT_URL', 'HOME_ASSISTANT_TOKEN'],
'secret_env': 'HOME_ASSISTANT_TOKEN',
'field_env': {'url': 'HOME_ASSISTANT_URL', 'verifyTls': 'HOME_ASSISTANT_VERIFY_TLS'},
'safe_fields': ['url', 'verifyTls'],
},
}
def _setting_key(connector_id: str, suffix: str) -> str:
return f'connector:{connector_id}:{suffix}'
def _get_setting(session: Session, key: str) -> AppSetting | None:
return session.get(AppSetting, key)
def _set_setting(session: Session, key: str, value: dict[str, Any], is_secret: bool = False) -> AppSetting:
row = _get_setting(session, key)
if not row:
row = AppSetting(key=key, is_secret=is_secret)
row.value_json = value
row.is_secret = is_secret
row.updated_at = utc_now()
session.add(row)
return row
def _connector_schema(connector_id: str) -> dict[str, Any]:
return _CONNECTOR_FIELDS.get(connector_id, {
'required': [f'{connector_id.upper()}_URL'],
'secret_env': f'{connector_id.upper()}_API_KEY',
'field_env': {'url': f'{connector_id.upper()}_URL'},
'safe_fields': ['url'],
})
def _safe_config_from_row(session: Session, connector_id: str) -> dict[str, Any]:
row = _get_setting(session, _setting_key(connector_id, 'config'))
config = dict(row.value_json) if row else {}
schema = _connector_schema(connector_id)
safe: dict[str, Any] = {}
for field in schema['safe_fields']:
value = config.get(field)
if value is None:
env_name = schema['field_env'].get(field)
value = settings.apis.get(env_name) if env_name else None
if value is not None:
safe[field] = value
if connector_id == 'portainer' and not str(safe.get('endpointId') or '').strip():
safe['endpointId'] = '1'
return safe
def _has_secret(session: Session, connector_id: str) -> bool:
schema = _connector_schema(connector_id)
secret_row = _get_setting(session, _setting_key(connector_id, 'secret'))
secret_env = schema.get('secret_env')
return bool(secret_env and has_secret(session, secret_env)) or bool(secret_row and secret_row.value_json.get('value')) or bool(secret_env and settings.apis.get(secret_env))
def _connector_secret_status(session: Session, connector_id: str) -> dict[str, Any]:
schema = _connector_schema(connector_id)
secret_env = schema.get('secret_env')
if not secret_env:
return empty_secret_status(f'{connector_id.upper()}_API_KEY', used_by=[connector_id])
row = session.get(Secret, secret_env)
if row:
return secret_status(row)
if settings.apis.get(secret_env):
status = empty_secret_status(secret_env, used_by=[connector_id])
status['hasSecret'] = True
status['status'] = 'env_fallback'
status['lastUpdated'] = None
return status
return empty_secret_status(secret_env, used_by=[connector_id])
def _migrate_legacy_connector_secret(session: Session, connector_id: str) -> None:
schema = _connector_schema(connector_id)
secret_env = schema.get('secret_env')
if not secret_env:
return
legacy_row = _get_setting(session, _setting_key(connector_id, 'secret'))
legacy_value = legacy_row.value_json.get('value') if legacy_row else None
if not isinstance(legacy_value, str) or not legacy_value.strip():
return
set_secret(
session,
secret_env,
legacy_value,
used_by=[connector_id],
metadata={'connectorId': connector_id, 'migratedFrom': _setting_key(connector_id, 'secret')},
commit=False,
)
legacy_row.value_json = {'secretRef': secret_env, 'hasSecret': True, 'migrated': True}
legacy_row.updated_at = utc_now()
session.add(legacy_row)
log_event(
session,
source='system',
identity='secret_store',
action_id='secret_migrated',
target=secret_env,
target_type='secret',
risk='low',
policy_decision='allowed',
reason='Legacy plaintext connector secret migrated to encrypted secret store.',
result='success',
redacted_metadata={'connector': connector_id, 'secretRef': secret_env},
)
session.commit()
def _apply_connector_config_to_runtime(session: Session, connector_id: str) -> None:
_migrate_legacy_connector_secret(session, connector_id)
schema = _connector_schema(connector_id)
safe_config = _safe_config_from_row(session, connector_id)
for field, env_name in schema['field_env'].items():
if field in safe_config:
settings.apis[env_name] = safe_config[field]
if 'verifyTls' in safe_config and connector_id in {'portainer', 'unraid'}:
settings.verify_tls = bool(safe_config['verifyTls'])
secret_row = _get_setting(session, _setting_key(connector_id, 'secret'))
secret_env = schema.get('secret_env')
if secret_env:
stored_secret = get_secret(session, secret_env, touch=False)
if stored_secret:
settings.apis[secret_env] = stored_secret
elif secret_row and secret_row.value_json.get('value'):
settings.apis[secret_env] = secret_row.value_json.get('value')
def apply_stored_connector_settings(session: Session, connector_ids: set[str] | None = None) -> None:
selected = connector_ids if connector_ids is not None else set(_CONNECTOR_FIELDS)
for connector_id in _CONNECTOR_FIELDS:
if connector_id not in selected:
continue
_apply_connector_config_to_runtime(session, connector_id)
def serialize_connector_settings(session: Session, connector_id: str) -> dict[str, Any]:
_apply_connector_config_to_runtime(session, connector_id)
schema = _connector_schema(connector_id)
missing = [name for name in schema['required'] if not settings.apis.get(name)]
return {
'connectorId': connector_id,
'configured': not missing,
'hasSecret': _has_secret(session, connector_id),
'missing': missing,
'safeConfig': _safe_config_from_row(session, connector_id),
'secretRef': schema.get('secret_env'),
'secretStatus': _connector_secret_status(session, connector_id),
'secretStore': 'encrypted_local_db',
'secretStorage': secret_storage_status(),
}
def list_connector_settings(session: Session) -> list[dict[str, Any]]:
connector_ids = [connector.id for connector in session.exec(select(Connector).order_by(Connector.id)).all()]
for connector_id in _CONNECTOR_FIELDS:
if connector_id not in connector_ids:
connector_ids.append(connector_id)
return [serialize_connector_settings(session, connector_id) for connector_id in connector_ids]
def _normalize_connector_field(field: str, value: Any) -> Any:
if field == 'verifyTls':
return bool(value)
if not isinstance(value, str):
return value
normalized = value.strip()
if field in {'url', 'outpostUrl'}:
normalized = validate_connector_url(normalized)
return normalized
def save_connector_settings(session: Session, connector_id: str, payload: dict[str, Any]) -> dict[str, Any]:
schema = _connector_schema(connector_id)
safe_payload = dict(_safe_config_from_row(session, connector_id))
for field in schema['safe_fields']:
if field in payload and payload[field] is not None:
safe_payload[field] = _normalize_connector_field(field, payload[field])
if connector_id == 'portainer' and not str(safe_payload.get('endpointId') or '').strip():
safe_payload['endpointId'] = '1'
_set_setting(session, _setting_key(connector_id, 'config'), safe_payload, is_secret=False)
secret_value = payload.get('apiKey') or payload.get('token') or payload.get('password') or payload.get('secret')
if isinstance(secret_value, str) and secret_value.strip():
secret_env = schema.get('secret_env')
if secret_env:
set_secret(
session,
secret_env,
secret_value,
used_by=[connector_id],
metadata={'connectorId': connector_id},
commit=False,
)
_set_setting(session, _setting_key(connector_id, 'secret'), {'secretRef': secret_env, 'hasSecret': True}, is_secret=True)
session.commit()
_apply_connector_config_to_runtime(session, connector_id)
connector = session.get(Connector, connector_id)
if connector:
connector.status = 'disconnected' if serialize_connector_settings(session, connector_id)['configured'] else 'missing_config'
session.add(connector)
session.commit()
safety_center_service.invalidate_cache()
return serialize_connector_settings(session, connector_id)
def _step(step_id: str, label: str, status: str, required: bool) -> dict[str, Any]:
return {'id': step_id, 'label': label, 'status': status, 'required': required}
def _successful_portainer_test_exists(session: Session) -> bool:
event = session.exec(
select(AuditEvent)
.where(AuditEvent.action_id == 'setup.test.portainer')
.where(AuditEvent.result == 'success')
.order_by(AuditEvent.timestamp.desc())
).first()
return bool(event)
def _setup_flag(session: Session) -> tuple[bool, str | None]:
first_run = _get_setting(session, 'setup:first_run_completed')
completed_at = _get_setting(session, 'setup:completed_at')
return bool(first_run and first_run.value_json.get('value')), completed_at.value_json.get('value') if completed_at else None
def _set_setup_completed(session: Session, *, completed_at: str | None = None) -> str:
now = completed_at or utc_now().isoformat()
_set_setting(session, 'setup:first_run_completed', {'value': True})
_set_setting(session, 'setup:completed_at', {'value': now})
session.commit()
safety_center_service.invalidate_cache()
return now
def _required_blockers(
*,
portainer_ready: bool,
discovery_complete: bool,
managed_apps: int,
review_blockers: int,
secret_storage_ready: bool,
) -> list[str]:
blockers: list[str] = []
if not portainer_ready:
blockers.append('portainer_not_connected')
if not discovery_complete:
blockers.append('discovery_not_completed')
if managed_apps <= 0:
blockers.append('no_managed_apps')
if review_blockers > 0:
blockers.append('apps_need_review')
if not secret_storage_ready:
blockers.append('secret_storage_not_ready')
return blockers
def get_setup_status(session: Session) -> dict[str, Any]:
from .inventory_discovery import _latest_discovered_items
from .app_registry import list_active_apps
first_run_completed, setup_completed_at = _setup_flag(session)
portainer = serialize_connector_settings(session, 'portainer')
unraid = serialize_connector_settings(session, 'unraid')
portainer_row = session.get(Connector, 'portainer')
unraid_row = session.get(Connector, 'unraid')
portainer_connected = portainer['configured'] and portainer_row is not None and portainer_row.status == 'connected'
portainer_tested = _successful_portainer_test_exists(session)
unraid_ready = unraid['configured'] and (unraid_row is None or unraid_row.status == 'connected')
latest_run = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first()
# Setup is an operator-facing current-state summary. Discovery run history
# remains available under Inventory, but recreated Docker IDs must not make
# the setup counters grow forever.
discovered = _latest_discovered_items(session)
mappings = session.exec(select(AppContainerMapping)).all()
apps = list_active_apps(session)
connectors = list_connector_settings(session)
mapped_container_ids = {mapping.container_id for mapping in mappings}
apps_need_review = len([app for app in apps if app.needs_review]) + len([
item
for item in discovered
if item.import_status in {'discovered', 'unmapped'} or (item.import_status == 'changed' and item.container_id not in mapped_container_ids)
])
optional_connectors = [item for item in connectors if item['connectorId'] != 'portainer']
missing_optional_sources = [item['connectorId'] for item in optional_connectors if item['missing']]
missing_config = len([item for item in connectors if item['missing']])
mapped_apps = len(mappings)
managed_apps = len(apps)
review_blockers = 0 if first_run_completed else apps_need_review
discovery_status = 'pending'
if latest_run:
discovery_status = 'complete' if latest_run.status == 'success' else 'failed' if latest_run.status == 'failed' else 'warning'
discovery_complete = bool(latest_run and latest_run.status == 'success') or bool(discovered) or mapped_apps > 0
portainer_ready = portainer_connected or portainer_tested or discovery_complete
review_status = 'pending' if not latest_run else ('complete' if review_blockers == 0 else 'warning')
connectors_status = 'warning' if missing_optional_sources else 'complete'
home_assistant_configured = 'home_assistant' not in missing_optional_sources
voice_status = 'complete' if settings.voice_token and home_assistant_configured else 'warning'
storage_status = secret_storage_status()
secret_storage_ready = storage_status['status'] == 'ready'
required_blockers = _required_blockers(
portainer_ready=portainer_ready,
discovery_complete=discovery_complete,
managed_apps=mapped_apps,
review_blockers=review_blockers,
secret_storage_ready=secret_storage_ready,
)
required_steps_complete = not required_blockers
setup_complete = required_steps_complete and first_run_completed
coverage_complete = not missing_optional_sources
live_ready = setup_complete and settings.port == 1216
warnings: list[str] = []
if missing_optional_sources:
warnings.append('Optional connector coverage is partial.')
if settings.port != 1216:
warnings.append('Runtime is not on canonical production port 1216.')
next_actions = [
{'id': blocker, 'required': True}
for blocker in required_blockers
]
if required_steps_complete and not first_run_completed:
next_actions.append({'id': 'reconcile_setup_state', 'required': True})
for connector_id in missing_optional_sources:
next_actions.append({'id': f'configure_optional_{connector_id}', 'required': False})
steps = [
_step('portainer', 'Portainer verbinden', 'complete' if portainer_ready else 'pending', True),
_step('unraid', 'Unraid API verbinden', 'complete' if unraid_ready else 'warning', False),
_step('discovery', 'Container discovery draaien', 'complete' if discovery_complete else discovery_status, True),
_step('review', 'Reviewbaseline vastgelegd' if first_run_completed else 'Apps reviewen', review_status, True),
_step('connectors', 'Connectoren binden', connectors_status, False),
_step('voice', 'Voice/Home Assistant instellen', voice_status, False),
]
return {
'firstRunCompleted': first_run_completed,
'setupComplete': setup_complete,
'liveReady': live_ready,
'coverageComplete': coverage_complete,
'securityCoveragePartial': not coverage_complete,
'setupCompletedAt': setup_completed_at,
'requiredStepsComplete': required_steps_complete,
'optionalStepsComplete': coverage_complete,
'reviewBlockers': review_blockers,
'managedApps': managed_apps,
'needsReview': apps_need_review,
'missingOptionalSources': missing_optional_sources,
'steps': steps,
'warnings': warnings,
'nextActions': next_actions,
'blockers': required_blockers,
'summary': {
'containersDiscovered': len({item.container_id for item in discovered}),
'appsManaged': managed_apps,
'appsNeedReview': apps_need_review,
'connectorsMissingConfig': missing_config,
'missingOptionalSources': missing_optional_sources,
},
}
def complete_setup(session: Session) -> dict[str, Any]:
from .inventory_discovery import sync_app_connector_statuses
sync_app_connector_statuses(session)
_set_setup_completed(session)
return {'success': True, 'message': 'setup_completed', 'status': get_setup_status(session)}
def reconcile_setup(session: Session) -> dict[str, Any]:
from .inventory_discovery import sync_app_connector_statuses
sync_app_connector_statuses(session)
before = get_setup_status(session)
blockers = list(before.get('blockers') or [])
if blockers:
log_event(
session,
source='manual',
identity='setup',
action_id='setup.reconcile',
target='setup',
target_type='setup',
risk='low',
policy_decision='allowed',
reason='Setup reconciliation blocked by required criteria.',
result='blocked',
before_state={'firstRunCompleted': before['firstRunCompleted'], 'blockers': blockers},
after_state={'firstRunCompleted': before['firstRunCompleted']},
redacted_metadata={'blockers': blockers},
)
after = get_setup_status(session)
return {'success': False, 'message': 'setup_reconcile_blocked', 'blockers': blockers, 'before': before, 'after': after}
if not before['firstRunCompleted']:
_set_setup_completed(session)
after = get_setup_status(session)
log_event(
session,
source='manual',
identity='setup',
action_id='setup.reconcile',
target='setup',
target_type='setup',
risk='low',
policy_decision='allowed',
reason='Setup state reconciled from existing live data.',
result='success',
before_state={'firstRunCompleted': before['firstRunCompleted'], 'setupComplete': before['setupComplete']},
after_state={'firstRunCompleted': after['firstRunCompleted'], 'setupComplete': after['setupComplete']},
redacted_metadata={'managedApps': after['managedApps'], 'coverageComplete': after['coverageComplete']},
)
return {'success': True, 'message': 'setup_reconciled', 'blockers': [], 'before': before, 'after': after}
def mark_setup_complete(session: Session, payload: dict[str, Any]) -> dict[str, Any]:
if payload.get('confirm') != 'MARK_SETUP_COMPLETE':
from fastapi import HTTPException
raise HTTPException(status_code=400, detail='confirm_required')
before = get_setup_status(session)
_set_setup_completed(session)
after = get_setup_status(session)
log_event(
session,
source='manual',
identity='setup',
action_id='setup.mark_complete',
target='setup',
target_type='setup',
risk='medium',
policy_decision='allowed',
reason='Operator explicitly marked setup complete.',
result='success',
before_state={'firstRunCompleted': before['firstRunCompleted'], 'setupComplete': before['setupComplete']},
after_state={'firstRunCompleted': after['firstRunCompleted'], 'setupComplete': after['setupComplete']},
redacted_metadata={'coverageComplete': after['coverageComplete'], 'blockers': after.get('blockers', [])},
)
return {'success': True, 'message': 'setup_marked_complete', 'status': after}
def mark_voice_configured(session: Session) -> dict[str, Any]:
_set_setting(session, 'setup:voice_configured', {'value': True})
session.commit()
return {'success': True, 'message': 'voice_configured', 'status': get_setup_status(session)}
def test_portainer_setup(session: Session) -> dict[str, Any]:
config = serialize_connector_settings(session, 'portainer')
if config['missing']:
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': config['missing'], 'message': 'missing_config'}
connector = PortainerConnector()
result = connector.test_connection()
status = str(result.get('status', 'error'))
if status != 'connected':
message = redact_message(str(result.get('reason', status)))
log_setup_test(session, 'portainer', status, message)
response_status = 'unreachable' if status == 'error' else status
required_fields = ['PORTAINER_ENDPOINT_ID'] if response_status == 'endpoint_error' else None
return {
'success': False,
'status': response_status,
'connected': False,
'message': message,
'requiredFields': required_fields,
'safeConfig': config['safeConfig'],
}
containers = connector.list_containers()
stacks = connector.list_stacks()
if containers.get('status') != 'ok':
message = redact_message(str(containers.get('reason', 'endpoint_error')))
log_setup_test(session, 'portainer', 'endpoint_error', message)
return {
'success': False,
'status': 'endpoint_error',
'connected': False,
'message': message,
'requiredFields': ['PORTAINER_ENDPOINT_ID'],
'summary': {'containers': 0, 'stacks': 0, 'endpointReachable': False},
'safeConfig': config['safeConfig'],
}
payload = {
'success': True,
'status': 'connected',
'connected': True,
'summary': {
'containers': len(containers.get('containers', [])),
'stacks': len(stacks.get('stacks', [])) if stacks.get('status') == 'ok' else 0,
'endpointReachable': True,
},
'safeConfig': config['safeConfig'],
}
connector_row = session.get(Connector, 'portainer')
if connector_row:
connector_row.status = 'connected'
session.add(connector_row)
session.commit()
log_setup_test(session, 'portainer', 'connected', 'connected')
return payload
def test_unraid_setup(session: Session) -> dict[str, Any]:
config = serialize_connector_settings(session, 'unraid')
if config['missing']:
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': config['missing'], 'message': 'missing_config'}
connector = UnraidConnector()
result = connector.test_connection()
status = str(result.get('status', 'error'))
if status != 'connected':
message = redact_message(str(result.get('reason', status)))
log_setup_test(session, 'unraid', status, message)
return {'success': False, 'status': 'unreachable' if status == 'error' else status, 'connected': False, 'message': message}
info = connector.get_server_info()
array = connector.get_array_status()
docker = connector.get_docker_service_status()
payload = {
'success': True,
'status': 'connected',
'connected': True,
'summary': {
'server': info.get('info', {}),
'arrayStatus': array.get('arrayStatus'),
'dockerService': docker.get('serviceStatus'),
},
'safeConfig': config['safeConfig'],
}
connector_row = session.get(Connector, 'unraid')
if connector_row:
connector_row.status = 'connected'
session.add(connector_row)
session.commit()
log_setup_test(session, 'unraid', 'connected', 'connected')
return payload
def log_setup_test(session: Session, connector_id: str, result: str, reason: str) -> None:
log_event(
session,
source='manual',
identity='setup',
action_id=f'setup.test.{connector_id}',
target=connector_id,
target_type='connector',
risk='low',
policy_decision='allowed',
reason=redact_message(reason),
result='success' if result == 'connected' else 'failed',
redacted_metadata={'connector': connector_id, 'result': result},
)