Files
govoplan-files/src/govoplan_files/backend/storage/connector_profiles.py
2026-07-14 13:22:11 +02:00

321 lines
12 KiB
Python

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