secret_fields.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. """Write-only secrets: notification provider configs, settings, smart plugs.
  2. Responses show a set secret as SECRET_PLACEHOLDER. A config sent back with
  3. the placeholder keeps the stored value, an empty value clears it, and anything
  4. else replaces it. A stored secret is only used for the destination it was
  5. entered for: a config that changes the destination (or the provider type) must
  6. carry the secret again, and a type change must bring a config of its own.
  7. """
  8. from __future__ import annotations
  9. from typing import Any
  10. from backend.app.core.logging_filters import redact_url_credentials
  11. SECRET_PLACEHOLDER = "********"
  12. # The config fields that hold a credential, per provider type. For Discord and
  13. # the generic webhook the URL itself is commonly the credential.
  14. SECRET_FIELDS: dict[str, tuple[str, ...]] = {
  15. "callmebot": ("apikey",),
  16. "ntfy": ("auth_token",),
  17. "pushover": ("user_key", "app_token"),
  18. "telegram": ("bot_token",),
  19. "email": ("password",),
  20. "discord": ("webhook_url",),
  21. "webhook": ("webhook_url", "auth_header"),
  22. "bark": ("device_key",),
  23. "gotify": ("app_token",),
  24. "notify": ("token",),
  25. "homeassistant": (),
  26. }
  27. # Every secret field name of every type. Responses mask these whatever the
  28. # provider's type, so a key left over from another type is never shown.
  29. ALL_SECRET_FIELDS = frozenset(field for fields in SECRET_FIELDS.values() for field in fields)
  30. # The config fields that decide where the secrets are sent. Types not listed
  31. # send to a fixed service.
  32. DESTINATION_FIELDS: dict[str, tuple[str, ...]] = {
  33. "email": ("smtp_server", "smtp_port", "security"),
  34. "ntfy": ("server",),
  35. "bark": ("server",),
  36. "gotify": ("server",),
  37. "webhook": ("webhook_url",),
  38. }
  39. class SecretConfigError(ValueError):
  40. """A config that would reuse a stored secret where it may not."""
  41. def mask_config(config: dict[str, Any]) -> dict[str, Any]:
  42. """The config as a response shows it: every set secret as the placeholder."""
  43. return {key: (SECRET_PLACEHOLDER if key in ALL_SECRET_FIELDS and value else value) for key, value in config.items()}
  44. def same_value(a: Any, b: Any) -> bool:
  45. """Equal as the edit dialog sends them back: as text, None as empty.
  46. Not stripped: a sender compares the exact value (" starttls" is not TLS).
  47. """
  48. return ("" if a is None else str(a)) == ("" if b is None else str(b))
  49. # What a sender uses for a destination field that is not in the config at all.
  50. _SENDER_DEFAULTS: dict[tuple[str, str], str] = {
  51. ("email", "security"): "starttls",
  52. }
  53. def _effective(provider_type: str, config: dict[str, Any], field: str) -> Any:
  54. if field in config:
  55. return config[field]
  56. return _SENDER_DEFAULTS.get((provider_type, field))
  57. def has_placeholder(config: dict[str, Any]) -> bool:
  58. return any(value == SECRET_PLACEHOLDER for value in config.values())
  59. def reject_placeholder(config: dict[str, Any]) -> None:
  60. """A config with nothing stored behind it (a new provider, a test of one)."""
  61. if has_placeholder(config):
  62. raise SecretConfigError("Enter the secret values; nothing is stored for this provider yet")
  63. def merge_config(
  64. stored_type: str,
  65. stored: dict[str, Any],
  66. new_type: str,
  67. incoming: dict[str, Any],
  68. ) -> dict[str, Any]:
  69. """The config to use: the placeholder replaced by the stored secret.
  70. Refused when a placeholder would carry a stored secret to another provider
  71. type or another destination.
  72. """
  73. kept = [key for key, value in incoming.items() if value == SECRET_PLACEHOLDER]
  74. if not kept:
  75. return dict(incoming)
  76. if new_type != stored_type:
  77. raise SecretConfigError(f"Enter {', '.join(kept)} again for the new provider type")
  78. unknown = [key for key in kept if key not in ALL_SECRET_FIELDS or not stored.get(key)]
  79. if unknown:
  80. raise SecretConfigError(f"Enter {', '.join(unknown)}; there is no stored value to keep")
  81. moved = [
  82. field
  83. for field in DESTINATION_FIELDS.get(stored_type, ())
  84. if incoming.get(field) != SECRET_PLACEHOLDER
  85. and not same_value(_effective(stored_type, incoming, field), _effective(stored_type, stored, field))
  86. ]
  87. if moved:
  88. raise SecretConfigError(f"Enter {', '.join(kept)} again: {', '.join(moved)} changed")
  89. return {key: (stored[key] if value == SECRET_PLACEHOLDER else value) for key, value in incoming.items()}
  90. # Settings secrets and the settings that decide where each is sent.
  91. SETTINGS_SECRETS: dict[str, tuple[str, ...]] = {
  92. "mqtt_password": ("mqtt_broker", "mqtt_port", "mqtt_use_tls"),
  93. "ha_token": ("ha_url",),
  94. "obico_ml_token": ("obico_ml_url",),
  95. "prometheus_token": (),
  96. "ldap_bind_password": ("ldap_server_url", "ldap_security"),
  97. }
  98. # Settings rows that hold a credential, by name: a Git backup writes them as
  99. # the placeholder and a restore never writes them. By hint rather than by list,
  100. # so a credential setting added later is covered by default.
  101. SECRET_SETTING_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase", "verifier")
  102. def is_secret_setting_key(key: str) -> bool:
  103. lowered = key.lower()
  104. return any(hint in lowered for hint in SECRET_SETTING_KEY_HINTS)
  105. REST_PLUG_URLS = ("rest_on_url", "rest_off_url", "rest_status_url", "rest_power_url", "rest_energy_url")
  106. # Slicer preset keys holding a printer connection's credentials (for a LAN
  107. # printer, its access code). Slicer cloud sync carries them.
  108. PRESET_SECRET_KEYS = ("printhost_apikey", "printhost_password", "printhost_user")
  109. def _mask_url_logins(value: Any) -> Any:
  110. if isinstance(value, str):
  111. return redact_url_credentials(value)
  112. if isinstance(value, list):
  113. return [_mask_url_logins(item) for item in value]
  114. return value
  115. def mask_preset(content: Any) -> Any:
  116. """A slicer preset with its connection credentials shown as the placeholder
  117. and the login in any of its URLs masked."""
  118. if not isinstance(content, dict):
  119. return content
  120. return {key: _mask_url_logins(value) for key, value in mask_fields(content, PRESET_SECRET_KEYS).items()}
  121. # Smart plug secrets and the fields that decide where each is sent.
  122. SMART_PLUG_SECRETS: dict[str, tuple[str, ...]] = {
  123. "password": ("plug_type", "ip_address"),
  124. "rest_headers": ("plug_type", *REST_PLUG_URLS),
  125. }
  126. def mask_fields(values: dict[str, Any], fields: Any) -> dict[str, Any]:
  127. """values with each set field of ``fields`` shown as the placeholder."""
  128. return {key: (SECRET_PLACEHOLDER if key in fields and value else value) for key, value in values.items()}
  129. def merge_partial(
  130. stored: dict[str, Any],
  131. update: dict[str, Any],
  132. secrets: dict[str, tuple[str, ...]],
  133. ) -> dict[str, Any]:
  134. """A partial update with the placeholder resolved: the secret is kept.
  135. A secret is kept when it is sent as the placeholder or not sent at all.
  136. Refused when a kept secret's destination changes, or the placeholder
  137. stands for a secret that is not stored.
  138. """
  139. result = dict(update)
  140. for secret, destinations in secrets.items():
  141. sent = secret in update
  142. if sent and update[secret] == SECRET_PLACEHOLDER:
  143. if not stored.get(secret):
  144. raise SecretConfigError(f"Enter {secret}; there is no stored value to keep")
  145. del result[secret]
  146. elif sent:
  147. continue
  148. if not stored.get(secret):
  149. continue
  150. moved = [
  151. field for field in destinations if field in update and not same_value(update[field], stored.get(field))
  152. ]
  153. if moved:
  154. raise SecretConfigError(f"Enter {secret} again: {', '.join(moved)} changed")
  155. return result
  156. # The SMTP settings that decide where the stored SMTP password is sent.
  157. SMTP_DESTINATIONS = ("smtp_host", "smtp_port", "smtp_security")