secret_fields.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  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. "virtual_printer_access_code": (),
  97. "ldap_bind_password": ("ldap_server_url", "ldap_security"),
  98. }
  99. # Settings rows that hold a credential, by name: a Git backup writes them as
  100. # the placeholder and a restore never writes them. By hint rather than by list,
  101. # so a credential setting added later is covered by default.
  102. SECRET_SETTING_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase", "verifier")
  103. def is_secret_setting_key(key: str) -> bool:
  104. lowered = key.lower()
  105. return any(hint in lowered for hint in SECRET_SETTING_KEY_HINTS)
  106. REST_PLUG_URLS = ("rest_on_url", "rest_off_url", "rest_status_url", "rest_power_url", "rest_energy_url")
  107. # Slicer preset keys holding a printer connection's credentials (for a LAN
  108. # printer, its access code). Slicer cloud sync carries them.
  109. PRESET_SECRET_KEYS = ("printhost_apikey", "printhost_password", "printhost_user")
  110. def _mask_url_logins(value: Any) -> Any:
  111. if isinstance(value, str):
  112. return redact_url_credentials(value)
  113. if isinstance(value, list):
  114. return [_mask_url_logins(item) for item in value]
  115. return value
  116. def mask_preset(content: Any) -> Any:
  117. """A slicer preset with its connection credentials shown as the placeholder
  118. and the login in any of its URLs masked."""
  119. if not isinstance(content, dict):
  120. return content
  121. return {key: _mask_url_logins(value) for key, value in mask_fields(content, PRESET_SECRET_KEYS).items()}
  122. # Smart plug secrets and the fields that decide where each is sent.
  123. SMART_PLUG_SECRETS: dict[str, tuple[str, ...]] = {
  124. "password": ("plug_type", "ip_address"),
  125. "rest_headers": ("plug_type", *REST_PLUG_URLS),
  126. }
  127. def mask_fields(values: dict[str, Any], fields: Any) -> dict[str, Any]:
  128. """values with each set field of ``fields`` shown as the placeholder."""
  129. return {key: (SECRET_PLACEHOLDER if key in fields and value else value) for key, value in values.items()}
  130. def merge_partial(
  131. stored: dict[str, Any],
  132. update: dict[str, Any],
  133. secrets: dict[str, tuple[str, ...]],
  134. ) -> dict[str, Any]:
  135. """A partial update with the placeholder resolved: the secret is kept.
  136. A secret is kept when it is sent as the placeholder or not sent at all.
  137. Refused when a kept secret's destination changes, or the placeholder
  138. stands for a secret that is not stored.
  139. """
  140. result = dict(update)
  141. for secret, destinations in secrets.items():
  142. sent = secret in update
  143. if sent and update[secret] == SECRET_PLACEHOLDER:
  144. if not stored.get(secret):
  145. raise SecretConfigError(f"Enter {secret}; there is no stored value to keep")
  146. del result[secret]
  147. elif sent:
  148. continue
  149. if not stored.get(secret):
  150. continue
  151. moved = [
  152. field for field in destinations if field in update and not same_value(update[field], stored.get(field))
  153. ]
  154. if moved:
  155. raise SecretConfigError(f"Enter {secret} again: {', '.join(moved)} changed")
  156. return result
  157. # The SMTP settings that decide where the stored SMTP password is sent.
  158. SMTP_DESTINATIONS = ("smtp_host", "smtp_port", "smtp_security")