| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- """Write-only secrets: notification provider configs, settings, smart plugs.
- Responses show a set secret as SECRET_PLACEHOLDER. A config sent back with
- the placeholder keeps the stored value, an empty value clears it, and anything
- else replaces it. A stored secret is only used for the destination it was
- entered for: a config that changes the destination (or the provider type) must
- carry the secret again, and a type change must bring a config of its own.
- """
- from __future__ import annotations
- from typing import Any
- from backend.app.core.logging_filters import redact_url_credentials
- SECRET_PLACEHOLDER = "********"
- # The config fields that hold a credential, per provider type. For Discord and
- # the generic webhook the URL itself is commonly the credential.
- SECRET_FIELDS: dict[str, tuple[str, ...]] = {
- "callmebot": ("apikey",),
- "ntfy": ("auth_token",),
- "pushover": ("user_key", "app_token"),
- "telegram": ("bot_token",),
- "email": ("password",),
- "discord": ("webhook_url",),
- "webhook": ("webhook_url", "auth_header"),
- "bark": ("device_key",),
- "gotify": ("app_token",),
- "notify": ("token",),
- "homeassistant": (),
- }
- # Every secret field name of every type. Responses mask these whatever the
- # provider's type, so a key left over from another type is never shown.
- ALL_SECRET_FIELDS = frozenset(field for fields in SECRET_FIELDS.values() for field in fields)
- # The config fields that decide where the secrets are sent. Types not listed
- # send to a fixed service.
- DESTINATION_FIELDS: dict[str, tuple[str, ...]] = {
- "email": ("smtp_server", "smtp_port", "security"),
- "ntfy": ("server",),
- "bark": ("server",),
- "gotify": ("server",),
- "webhook": ("webhook_url",),
- }
- class SecretConfigError(ValueError):
- """A config that would reuse a stored secret where it may not."""
- def mask_config(config: dict[str, Any]) -> dict[str, Any]:
- """The config as a response shows it: every set secret as the placeholder."""
- return {key: (SECRET_PLACEHOLDER if key in ALL_SECRET_FIELDS and value else value) for key, value in config.items()}
- def same_value(a: Any, b: Any) -> bool:
- """Equal as the edit dialog sends them back: as text, None as empty.
- Not stripped: a sender compares the exact value (" starttls" is not TLS).
- """
- return ("" if a is None else str(a)) == ("" if b is None else str(b))
- # What a sender uses for a destination field that is not in the config at all.
- _SENDER_DEFAULTS: dict[tuple[str, str], str] = {
- ("email", "security"): "starttls",
- }
- def _effective(provider_type: str, config: dict[str, Any], field: str) -> Any:
- if field in config:
- return config[field]
- return _SENDER_DEFAULTS.get((provider_type, field))
- def has_placeholder(config: dict[str, Any]) -> bool:
- return any(value == SECRET_PLACEHOLDER for value in config.values())
- def reject_placeholder(config: dict[str, Any]) -> None:
- """A config with nothing stored behind it (a new provider, a test of one)."""
- if has_placeholder(config):
- raise SecretConfigError("Enter the secret values; nothing is stored for this provider yet")
- def merge_config(
- stored_type: str,
- stored: dict[str, Any],
- new_type: str,
- incoming: dict[str, Any],
- ) -> dict[str, Any]:
- """The config to use: the placeholder replaced by the stored secret.
- Refused when a placeholder would carry a stored secret to another provider
- type or another destination.
- """
- kept = [key for key, value in incoming.items() if value == SECRET_PLACEHOLDER]
- if not kept:
- return dict(incoming)
- if new_type != stored_type:
- raise SecretConfigError(f"Enter {', '.join(kept)} again for the new provider type")
- unknown = [key for key in kept if key not in ALL_SECRET_FIELDS or not stored.get(key)]
- if unknown:
- raise SecretConfigError(f"Enter {', '.join(unknown)}; there is no stored value to keep")
- moved = [
- field
- for field in DESTINATION_FIELDS.get(stored_type, ())
- if incoming.get(field) != SECRET_PLACEHOLDER
- and not same_value(_effective(stored_type, incoming, field), _effective(stored_type, stored, field))
- ]
- if moved:
- raise SecretConfigError(f"Enter {', '.join(kept)} again: {', '.join(moved)} changed")
- return {key: (stored[key] if value == SECRET_PLACEHOLDER else value) for key, value in incoming.items()}
- # Settings secrets and the settings that decide where each is sent.
- SETTINGS_SECRETS: dict[str, tuple[str, ...]] = {
- "mqtt_password": ("mqtt_broker", "mqtt_port", "mqtt_use_tls"),
- "ha_token": ("ha_url",),
- "obico_ml_token": ("obico_ml_url",),
- "prometheus_token": (),
- "virtual_printer_access_code": (),
- "ldap_bind_password": ("ldap_server_url", "ldap_security"),
- }
- # Settings rows that hold a credential, by name: a Git backup writes them as
- # the placeholder and a restore never writes them. By hint rather than by list,
- # so a credential setting added later is covered by default.
- SECRET_SETTING_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase", "verifier")
- def is_secret_setting_key(key: str) -> bool:
- lowered = key.lower()
- return any(hint in lowered for hint in SECRET_SETTING_KEY_HINTS)
- REST_PLUG_URLS = ("rest_on_url", "rest_off_url", "rest_status_url", "rest_power_url", "rest_energy_url")
- # Slicer preset keys holding a printer connection's credentials (for a LAN
- # printer, its access code). Slicer cloud sync carries them.
- PRESET_SECRET_KEYS = ("printhost_apikey", "printhost_password", "printhost_user")
- def _mask_url_logins(value: Any) -> Any:
- if isinstance(value, str):
- return redact_url_credentials(value)
- if isinstance(value, list):
- return [_mask_url_logins(item) for item in value]
- return value
- def mask_preset(content: Any) -> Any:
- """A slicer preset with its connection credentials shown as the placeholder
- and the login in any of its URLs masked."""
- if not isinstance(content, dict):
- return content
- return {key: _mask_url_logins(value) for key, value in mask_fields(content, PRESET_SECRET_KEYS).items()}
- # Smart plug secrets and the fields that decide where each is sent.
- SMART_PLUG_SECRETS: dict[str, tuple[str, ...]] = {
- "password": ("plug_type", "ip_address"),
- "rest_headers": ("plug_type", *REST_PLUG_URLS),
- }
- def mask_fields(values: dict[str, Any], fields: Any) -> dict[str, Any]:
- """values with each set field of ``fields`` shown as the placeholder."""
- return {key: (SECRET_PLACEHOLDER if key in fields and value else value) for key, value in values.items()}
- def merge_partial(
- stored: dict[str, Any],
- update: dict[str, Any],
- secrets: dict[str, tuple[str, ...]],
- ) -> dict[str, Any]:
- """A partial update with the placeholder resolved: the secret is kept.
- A secret is kept when it is sent as the placeholder or not sent at all.
- Refused when a kept secret's destination changes, or the placeholder
- stands for a secret that is not stored.
- """
- result = dict(update)
- for secret, destinations in secrets.items():
- sent = secret in update
- if sent and update[secret] == SECRET_PLACEHOLDER:
- if not stored.get(secret):
- raise SecretConfigError(f"Enter {secret}; there is no stored value to keep")
- del result[secret]
- elif sent:
- continue
- if not stored.get(secret):
- continue
- moved = [
- field for field in destinations if field in update and not same_value(update[field], stored.get(field))
- ]
- if moved:
- raise SecretConfigError(f"Enter {secret} again: {', '.join(moved)} changed")
- return result
- # The SMTP settings that decide where the stored SMTP password is sent.
- SMTP_DESTINATIONS = ("smtp_host", "smtp_port", "smtp_security")
|