from __future__ import annotations import json import os from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from typing import Any from govoplan_core.core.policy import normalize_policy_scope_type, policy_source_path from govoplan_files.backend.storage.connector_policy import ConnectorPolicySource, connector_policy_sources_from_payload _ENV_JSON_KEYS = ("GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON", "FILES_CONNECTOR_PROFILES_JSON") _ENV_FILE_KEYS = ("GOVOPLAN_FILES_CONNECTOR_PROFILES_FILE", "FILES_CONNECTOR_PROFILES_FILE") _SUPPORTED_PROVIDERS = {"seafile", "nextcloud", "webdav", "smb", "s3", "sharepoint", "onedrive", "nfs", "dms", "generic"} _INLINE_SECRET_FIELDS = ( "password", "token", "api_key", "access_key", "access_key_id", "secret_key", "secret_access_key", "session_token", ) def supported_connector_providers() -> set[str]: return set(_SUPPORTED_PROVIDERS) @dataclass(frozen=True, slots=True) class ConnectorProfile: id: str label: str provider: str scope_type: str = "system" scope_id: str | None = None endpoint_url: str | None = None base_path: str | None = None enabled: bool = True credential_profile_id: str | None = None credential_profile_label: str | None = None credential_mode: str = "none" username: str | None = None password_env: str | None = None token_env: str | None = None secret_ref: str | None = None password_value: str | None = field(default=None, repr=False) token_value: str | None = field(default=None, repr=False) has_inline_secret: bool = False capabilities: tuple[str, ...] = () policy_sources: tuple[ConnectorPolicySource, ...] = () metadata: Mapping[str, Any] = field(default_factory=dict) source_kind: str = "settings" @property def source_path(self) -> str: return policy_source_path(self.scope_type, self.scope_id) @property def credential_source(self) -> str | None: if self.credential_profile_id: return "credential_profile" if self.secret_ref: return "secret_ref" if self.token_value or self.password_value: return "stored" if self.token_env: return "token_env" if self.password_env: return "password_env" if self.has_inline_secret: return "inline" return None @property def credentials_configured(self) -> bool: mode = self.credential_mode.casefold() if mode in {"", "none", "anonymous"}: return True if self.secret_ref or self.has_inline_secret or self.password_value or self.token_value: return True if self.token_env: return bool(os.environ.get(self.token_env)) if self.password_env: return bool(os.environ.get(self.password_env)) return False def to_response(self) -> dict[str, Any]: return { "id": self.id, "label": self.label, "provider": self.provider, "endpoint_url": self.endpoint_url, "base_path": self.base_path, "enabled": self.enabled, "scope_type": self.scope_type, "scope_id": self.scope_id, "source_path": self.source_path, "credential_profile_id": self.credential_profile_id, "credential_profile_label": self.credential_profile_label, "credential_mode": self.credential_mode, "credential_source": self.credential_source, "credentials_configured": self.credentials_configured, "username": self.username, "capabilities": list(self.capabilities), "policy_sources": [_policy_source_response(source) for source in self.policy_sources], "metadata": dict(self.metadata), "source_kind": self.source_kind, } def connector_profiles_from_settings(settings: object | None = None) -> list[ConnectorProfile]: raw = _raw_profiles_payload(settings) if raw in (None, ""): return [] payload = json.loads(raw) if isinstance(raw, str) else raw return connector_profiles_from_payload(payload) def connector_profiles_from_payload(value: object) -> list[ConnectorProfile]: if isinstance(value, Mapping): raw_profiles = value.get("profiles", []) elif isinstance(value, list): raw_profiles = value else: raise ValueError("Connector profiles must be a JSON object with profiles or a list") if not isinstance(raw_profiles, list): raise ValueError("Connector profiles payload requires a profiles list") return [_profile_from_mapping(item) for item in raw_profiles if isinstance(item, Mapping)] def _profile_from_mapping(value: Mapping[str, Any]) -> ConnectorProfile: profile_id = _profile_id_from_mapping(value) provider = _provider_from_mapping(value) scope_type, scope_id = _scope_from_mapping(value) credentials = _credentials_from_mapping(value) mode = _clean(value.get("credential_mode") or value.get("auth_type") or credentials.get("mode") or credentials.get("type")) or "none" profile = _profile_from_normalized_mapping( value, profile_id=profile_id, provider=provider, scope_type=scope_type, scope_id=scope_id, credential_mode=mode, credentials=credentials, ) return ConnectorProfile( id=profile.id, label=profile.label, provider=profile.provider, scope_type=profile.scope_type, scope_id=profile.scope_id, endpoint_url=profile.endpoint_url, base_path=profile.base_path, enabled=profile.enabled, credential_profile_id=profile.credential_profile_id, credential_profile_label=profile.credential_profile_label, credential_mode=profile.credential_mode, username=profile.username, password_env=profile.password_env, token_env=profile.token_env, secret_ref=profile.secret_ref, password_value=profile.password_value, token_value=profile.token_value, has_inline_secret=profile.has_inline_secret, capabilities=profile.capabilities, policy_sources=tuple(_policy_sources_from_profile(value, profile)), metadata=profile.metadata, source_kind="settings", ) def _profile_id_from_mapping(value: Mapping[str, Any]) -> str: profile_id = _clean(value.get("id") or value.get("connector_id") or value.get("name")) if not profile_id: raise ValueError("Connector profiles require id") return profile_id def _provider_from_mapping(value: Mapping[str, Any]) -> str: provider = (_clean(value.get("provider") or value.get("type")) or "generic").casefold() if provider not in _SUPPORTED_PROVIDERS: raise ValueError(f"Unsupported connector provider: {provider}") return provider def _scope_from_mapping(value: Mapping[str, Any]) -> tuple[str, str | None]: scope_type = normalize_policy_scope_type(str(value.get("scope_type") or "system")) scope_id = _clean(value.get("scope_id")) if scope_type == "system": return scope_type, None if not scope_id: raise ValueError(f"{scope_type} connector profiles require scope_id") return scope_type, scope_id def _credentials_from_mapping(value: Mapping[str, Any]) -> Mapping[str, Any]: credentials = value.get("credentials") return credentials if isinstance(credentials, Mapping) else {} def _profile_from_normalized_mapping( value: Mapping[str, Any], *, profile_id: str, provider: str, scope_type: str, scope_id: str | None, credential_mode: str, credentials: Mapping[str, Any], ) -> ConnectorProfile: return ConnectorProfile( id=profile_id, label=_clean(value.get("label") or value.get("name")) or profile_id, provider=provider, endpoint_url=_clean(value.get("endpoint_url") or value.get("base_url") or value.get("url")), base_path=_clean(value.get("base_path") or value.get("path") or value.get("root")), enabled=_bool(value.get("enabled"), default=True), scope_type=scope_type, scope_id=scope_id, credential_profile_id=_clean(value.get("credential_profile_id") or value.get("credential_id")), credential_profile_label=_clean(value.get("credential_profile_label") or value.get("credential_label")), credential_mode=credential_mode, username=_clean(value.get("username") or credentials.get("username")), password_env=_clean(value.get("password_env") or credentials.get("password_env")), token_env=_clean(value.get("token_env") or credentials.get("token_env")), secret_ref=_clean(value.get("secret_ref") or credentials.get("secret_ref")), password_value=_clean(value.get("password") or credentials.get("password")), token_value=_clean(value.get("token") or credentials.get("token")), has_inline_secret=any(_clean(value.get(field) or credentials.get(field)) for field in _INLINE_SECRET_FIELDS), capabilities=tuple(_string_list(value.get("capabilities"))), metadata=_public_metadata(value.get("metadata")), ) def _raw_profiles_payload(settings: object | None) -> object: for key in _ENV_JSON_KEYS: value = os.environ.get(key) if value: return value for key in _ENV_FILE_KEYS: path = os.environ.get(key) if path: with open(path, encoding="utf-8") as handle: return handle.read() if settings is not None: value = getattr(settings, "files_connector_profiles_json", None) if value: return value value = getattr(settings, "files_connector_profiles", None) if value: return value return None def _policy_sources_from_profile(value: Mapping[str, Any], profile: ConnectorProfile) -> list[ConnectorPolicySource]: raw_sources: list[Mapping[str, Any]] = [] policy = value.get("policy") if isinstance(policy, Mapping): raw_sources.append({ "scope_type": profile.scope_type, "scope_id": profile.scope_id, "label": profile.label, "policy": policy, }) configured_sources = value.get("policy_sources") or value.get("connector_policy_sources") if configured_sources is not None: raw = connector_policy_sources_from_payload(configured_sources) raw_sources.extend(_policy_source_response(source) for source in raw) return connector_policy_sources_from_payload(raw_sources) def _policy_source_response(source: ConnectorPolicySource) -> dict[str, Any]: return { "scope_type": source.scope_type, "scope_id": source.scope_id, "label": source.label, "policy": dict(source.policy or {}), } def _public_metadata(value: object) -> Mapping[str, Any]: if not isinstance(value, Mapping): return {} return { str(key): item for key, item in value.items() if str(key).strip().casefold() not in _INLINE_SECRET_FIELDS } def _string_list(value: object) -> list[str]: if value is None: return [] if isinstance(value, str): values: Iterable[object] = value.split(",") elif isinstance(value, Iterable) and not isinstance(value, (bytes, bytearray, Mapping)): values = value else: values = (value,) return [str(item).strip() for item in values if str(item).strip()] def _bool(value: object, *, default: bool) -> bool: if value is None: return default if isinstance(value, bool): return value if isinstance(value, str): return value.strip().casefold() not in {"0", "false", "no", "off"} return bool(value) def _clean(value: object) -> str | None: if value is None: return None text = str(value).strip() return text or None