"""``ConfigService`` — typed, cached, tenant-scoped configuration access. Precedence: **tenant row → global row → registry default**. Reads fail closed: with no tenant context only the global row and the registry default are visible, never an arbitrary tenant's row. Writes are audited and emit the public ``config_changed``/``config_reset`` signals with secrets masked. """ from __future__ import annotations import builtins import decimal import json from typing import Any from django.core.cache import caches from django.db import transaction from django.db.models import Q from infrasynth.shared.crypto import decrypt, encrypt from infrasynth.shared.exceptions import AuthError, NotFoundError, ServerError, ValidationAppError from infrasynth.shared.settings_utils import get_setting from infrasynth.tenancy.context import get_current_tenant from .models import ConfigValue from .registry import INVALID_CODE, ConfigDefinition, ConfigRegistry from .signals import config_changed, config_reset __all__ = ["ConfigService"] _UNSET: Any = object() _CACHE_MISS: Any = object() _DECRYPT_FAILED = "SERVER_CONFIG_DECRYPT_FAILED" _GLOBAL_WRITES_DISABLED = "AUTH_CONFIG_GLOBAL_WRITES_DISABLED" _TENANT_REQUIRED = "VALIDATION_TENANT_REQUIRED" def _unknown_key(key: str) -> NotFoundError: return NotFoundError( f"Unknown configuration key '{key}'.", code="NOT_FOUND", details=[{"field": "key", "issue": key}], ) class ConfigService: """Reads and writes typed configuration values for a tenant.""" # --- reads -------------------------------------------------------------- def get(self, key: str, *, tenant: Any = None, default: Any = _UNSET) -> Any: definition = ConfigRegistry.get(key) resolved_tenant = self._read_tenant(tenant) if resolved_tenant is not None: cached = self._cache_get(self._tenant_cache_key(resolved_tenant, key)) if cached is not _CACHE_MISS: return cached row = self._find_value(tenant=resolved_tenant, key=key) if row is not None: value = self._decode(definition, row.value) self._cache_set(self._tenant_cache_key(resolved_tenant, key), value) return value cached = self._cache_get(self._global_cache_key(key)) if cached is not _CACHE_MISS: return cached global_row = self._find_value(tenant=None, key=key) if global_row is not None: value = self._decode(definition, global_row.value) self._cache_set(self._global_cache_key(key), value) return value if definition is not None: return definition.default if default is not _UNSET: return default raise _unknown_key(key) def get_many(self, keys: list[str], *, tenant: Any = None) -> dict[str, Any]: """Resolves the asked keys, skipping unknown/unregistered ones.""" resolved = self._read_tenant(tenant) result: dict[str, Any] = {} for key in keys: try: result[key] = self.get(key, tenant=resolved) except NotFoundError: continue return result def get_all(self, *, tenant: Any = None, group: str | None = None) -> dict[str, Any]: """Every registered key plus any stored key, resolved for ``tenant``.""" resolved = self._read_tenant(tenant) keys = set(ConfigRegistry.all().keys()) keys.update(self._visible_rows(resolved).values_list("key", flat=True)) result: dict[str, Any] = {} for key in sorted(keys): definition = ConfigRegistry.get(key) if group is not None and (definition is None or definition.group != group): continue result[key] = self.get(key, tenant=resolved) return result def get_metadata(self, key: str, *, tenant: Any = None) -> dict[str, Any]: """Schema + state for ``key`` (for API/UI forms). Raises for unknown keys.""" resolved = self._read_tenant(tenant) definition = ConfigRegistry.get(key) tenant_row = self._find_value(tenant=resolved, key=key) if resolved is not None else None global_row = self._find_value(tenant=None, key=key) if definition is None and tenant_row is None and global_row is None: raise _unknown_key(key) row = tenant_row or global_row if definition is None: return { "key": key, "type": None, "default": None, "choices": [], "is_secret": False, "label": key, "group": "", "description": "", "is_overridden": tenant_row is not None, "updated_at": row.updated_at if row is not None else None, } return { "key": key, "type": definition.type.value, "default": None if definition.is_secret else definition.default, "choices": list(definition.choices), "is_secret": definition.is_secret, "label": definition.label or key, "group": definition.group, "description": definition.description, "is_overridden": tenant_row is not None, "updated_at": row.updated_at if row is not None else None, } def is_overridden(self, key: str, *, tenant: Any = None) -> bool: resolved = self._read_tenant(tenant) if resolved is None: return False return ConfigValue.all_objects.filter(tenant_id=resolved.pk, key=key).exists() # --- writes ------------------------------------------------------------- def set(self, key: str, value: Any, *, tenant: Any, user: Any = None) -> ConfigValue: """Sets the tenant override for ``key`` (creating or updating it).""" resolved = self._require_tenant(tenant) definition = self._known_definition(key, tenant=resolved) coerced = self._coerce(definition, key, value) stored = self._encode(definition, coerced) old_value = self._effective_value(resolved, key, definition) with transaction.atomic(): obj, _ = ConfigValue.all_objects.update_or_create( tenant=resolved, key=key, defaults={"value": stored, "updated_by": user}, ) self.invalidate(resolved, key) self._emit_changed( tenant_id=str(resolved.pk), scope="tenant", key=key, definition=definition, old_value=old_value, new_value=coerced, actor=user, ) return obj def set_global(self, key: str, value: Any, *, user: Any = None) -> ConfigValue: """Sets the platform default for ``key`` (``tenant IS NULL``).""" if not get_setting("INFRASYNTH_CONFIGS", "ALLOW_GLOBAL_WRITES", True): raise AuthError( "Global configuration writes are disabled for this deployment.", code=_GLOBAL_WRITES_DISABLED, status=403, ) definition = self._known_definition(key, tenant=None) coerced = self._coerce(definition, key, value) stored = self._encode(definition, coerced) old_value = self._effective_value(None, key, definition) with transaction.atomic(): obj, _ = ConfigValue.all_objects.update_or_create( tenant=None, key=key, defaults={"value": stored, "updated_by": user}, ) self.invalidate(None, key) self._emit_changed( tenant_id=None, scope="global", key=key, definition=definition, old_value=old_value, new_value=coerced, actor=user, ) return obj def reset(self, key: str, *, tenant: Any, user: Any = None) -> bool: """Deletes the tenant override so reads fall back to global/default.""" resolved = self._require_tenant(tenant) definition = ConfigRegistry.get(key) previous = self._effective_value(resolved, key, definition) deleted, _ = ConfigValue.all_objects.filter(tenant_id=resolved.pk, key=key).delete() self.invalidate(resolved, key) if deleted: config_reset.send( sender=ConfigValue, tenant_id=str(resolved.pk), key=key, scope="tenant", previous_value=self._mask(definition, previous), actor_id=getattr(user, "pk", None), ) return bool(deleted) def invalidate(self, tenant: Any, key: str | None = None) -> None: """Busts cached values. ``key=None`` clears every known key for the scope.""" cache, _ = self._cache() if key is not None: cache_key = self._global_cache_key(key) if tenant is None else self._tenant_cache_key(tenant, key) cache.delete(cache_key) return for known in self._known_keys(tenant): if tenant is None: cache.delete(self._global_cache_key(known)) else: cache.delete(self._tenant_cache_key(tenant, known)) # --- internals ---------------------------------------------------------- def _emit_changed( self, *, tenant_id: str | None, scope: str, key: str, definition: ConfigDefinition | None, old_value: Any, new_value: Any, actor: Any, ) -> None: config_changed.send( sender=ConfigValue, tenant_id=tenant_id, key=key, scope=scope, old_value=self._mask(definition, old_value), new_value=self._mask(definition, new_value), actor_id=getattr(actor, "pk", None), ) @staticmethod def _mask(definition: ConfigDefinition | None, value: Any) -> Any: if definition is not None and definition.is_secret: return None return value def _coerce(self, definition: ConfigDefinition | None, key: str, value: Any) -> Any: if definition is not None: return ConfigRegistry.coerce(definition, value) try: json.dumps(value) except (TypeError, ValueError) as exc: raise ValidationAppError( f"Invalid value for configuration key '{key}'.", code=INVALID_CODE, details=[{"field": key, "issue": str(exc)}], ) from exc return value def _known_definition(self, key: str, *, tenant: Any) -> ConfigDefinition | None: """A definition, or ``None`` when the key is stored but unregistered.""" definition = ConfigRegistry.get(key) if definition is not None: return definition if self._find_value(tenant=tenant, key=key) is not None or self._find_value(tenant=None, key=key) is not None: return None raise _unknown_key(key) @staticmethod def _encode(definition: ConfigDefinition | None, value: Any) -> Any: payload = ConfigService._jsonable(value) if definition is not None and definition.is_secret: return encrypt(json.dumps(payload)) return payload @staticmethod def _decode(definition: ConfigDefinition | None, raw: Any) -> Any: if definition is not None and definition.is_secret: if not isinstance(raw, str): raise ServerError( "Stored configuration secret is not a ciphertext token.", code=_DECRYPT_FAILED, ) try: value: Any = json.loads(decrypt(raw)) except Exception as exc: # noqa: BLE001 - never leak ciphertext raise ServerError( "Unable to decrypt the configuration secret.", code=_DECRYPT_FAILED, ) from exc else: value = raw return ConfigService._restore_type(definition, value) @staticmethod def _restore_type(definition: ConfigDefinition | None, value: Any) -> Any: if definition is None or value is None: return value if definition.type.value == "decimal": return decimal.Decimal(str(value)) if definition.type.value == "duration": return int(value) return value @staticmethod def _jsonable(value: Any) -> Any: if isinstance(value, decimal.Decimal): return str(value) if isinstance(value, dict): return {str(k): ConfigService._jsonable(v) for k, v in value.items()} if isinstance(value, (list, tuple)): return [ConfigService._jsonable(v) for v in value] return value def _effective_value(self, tenant: Any, key: str, definition: ConfigDefinition | None) -> Any: row = None if tenant is not None: row = self._find_value(tenant=tenant, key=key) if row is None: row = self._find_value(tenant=None, key=key) if row is not None: return self._decode(definition, row.value) return definition.default if definition is not None else None @staticmethod def _find_value(*, tenant: Any, key: str) -> ConfigValue | None: qs = ConfigValue.all_objects.filter(key=key) if tenant is None: return qs.filter(tenant__isnull=True).first() return qs.filter(tenant_id=tenant.pk).first() @staticmethod def _visible_rows(tenant: Any): qs = ConfigValue.all_objects.all() if tenant is None: return qs.filter(tenant__isnull=True) return qs.filter(Q(tenant_id=tenant.pk) | Q(tenant__isnull=True)) def _known_keys(self, tenant: Any) -> builtins.set[str]: keys = set(ConfigRegistry.all().keys()) keys.update(self._visible_rows(tenant).values_list("key", flat=True)) return keys @staticmethod def _read_tenant(tenant: Any) -> Any: if tenant is not None: return tenant return get_current_tenant() @staticmethod def _require_tenant(tenant: Any) -> Any: resolved = tenant if tenant is not None else get_current_tenant() if resolved is None: raise ValidationAppError( "A tenant context is required to write configuration.", code=_TENANT_REQUIRED, ) return resolved # --- cache -------------------------------------------------------------- @staticmethod def _cache(): from django.conf import settings config = getattr(settings, "INFRASYNTH_CONFIGS", {}) alias = config.get("CACHE_BACKEND", "default") or "default" return caches[alias], config.get("CACHE_KEY_PREFIX", "configs") @staticmethod def _ttl() -> int: return int(get_setting("INFRASYNTH_CONFIGS", "CACHE_TTL_SECONDS", 60)) @staticmethod def _tenant_cache_key(tenant: Any, key: str) -> str: _, prefix = ConfigService._cache() return f"{prefix}:tenant:{tenant.pk}:configs:{key}" @staticmethod def _global_cache_key(key: str) -> str: _, prefix = ConfigService._cache() return f"{prefix}:tenant:global:configs:{key}" def _cache_get(self, cache_key: str) -> Any: cache, _ = self._cache() return cache.get(cache_key, _CACHE_MISS) def _cache_set(self, cache_key: str, value: Any) -> None: cache, _ = self._cache() cache.set(cache_key, value, self._ttl())