oidc_env.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. """Read the single OIDC provider defined by BAMBUDDY_OIDC_* env vars (#2593).
  2. A declarative deployment (compose, Helm, GitOps) has no way to click through
  3. the settings UI, so one provider can be configured entirely from the
  4. environment. This module only reads and defaults; validity is decided by the
  5. same OIDCProviderCreate schema the API uses, so env config cannot bypass a
  6. check the UI enforces.
  7. """
  8. from __future__ import annotations
  9. import contextlib
  10. import logging
  11. import os
  12. from pydantic import ValidationError
  13. from sqlalchemy import select, update
  14. from sqlalchemy.ext.asyncio import AsyncSession
  15. logger = logging.getLogger(__name__)
  16. # All four or nothing: a provider missing its secret would be written to the
  17. # database and then fail at authorize time, long after the operator could
  18. # connect the failure to a typo in their compose file.
  19. _REQUIRED = (
  20. "BAMBUDDY_OIDC_NAME",
  21. "BAMBUDDY_OIDC_ISSUER_URL",
  22. "BAMBUDDY_OIDC_CLIENT_ID",
  23. "BAMBUDDY_OIDC_CLIENT_SECRET",
  24. )
  25. _TRUTHY = {"true", "1", "yes"}
  26. _FALSY = {"false", "0", "no"}
  27. class EnvOIDCConfigError(Exception):
  28. """A BAMBUDDY_OIDC_* value the reader cannot interpret. Only ever carries a
  29. boolean variable's name and value -- booleans are not secret, so the message
  30. is safe to log in full (unlike client_secret, which never reaches here)."""
  31. def env_bool(key: str, default: bool) -> bool:
  32. value = os.environ.get(key)
  33. if value is None or value.strip() == "":
  34. return default # absent or blank == unset -> default, per the module's promise
  35. norm = value.strip().lower()
  36. if norm in _TRUTHY:
  37. return True
  38. if norm in _FALSY:
  39. return False
  40. raise EnvOIDCConfigError(f"{key}={value!r} is not a recognized boolean (use true/1/yes or false/0/no)")
  41. def read_env_oidc_config() -> dict | None:
  42. """The provider's fields from the environment, or None if it isn't configured.
  43. An empty required var counts as unset -- `BAMBUDDY_OIDC_CLIENT_SECRET=` in
  44. a compose file is a forgotten value, not an intentional empty secret.
  45. """
  46. if not all(os.environ.get(key) for key in _REQUIRED):
  47. return None
  48. return {
  49. "name": os.environ["BAMBUDDY_OIDC_NAME"],
  50. "issuer_url": os.environ["BAMBUDDY_OIDC_ISSUER_URL"],
  51. "client_id": os.environ["BAMBUDDY_OIDC_CLIENT_ID"],
  52. "client_secret": os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"],
  53. "scopes": (os.environ.get("BAMBUDDY_OIDC_SCOPES") or "").strip() or "openid email profile",
  54. "is_enabled": env_bool("BAMBUDDY_OIDC_ENABLED", True),
  55. "auto_create_users": env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False),
  56. "auto_link_existing_accounts": env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False),
  57. "email_claim": (os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM") or "").strip() or "email",
  58. "require_email_verified": env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
  59. "icon_url": (os.environ.get("BAMBUDDY_OIDC_ICON_URL") or "").strip() or None,
  60. "is_autologin": env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
  61. # A name, not an id: ids are assigned per install, so the same compose
  62. # file would point at a different group on every deployment. Resolved
  63. # against the database in apply_env_oidc_provider -- the reader has no
  64. # session and stays dumb.
  65. "default_group": (os.environ.get("BAMBUDDY_OIDC_DEFAULT_GROUP") or "").strip() or None,
  66. }
  67. # Everything the schema validates and the model stores, except client_secret --
  68. # that one goes through the property so it is encrypted at rest.
  69. _APPLIED_FIELDS = (
  70. "name",
  71. "issuer_url",
  72. "client_id",
  73. "scopes",
  74. "is_enabled",
  75. "auto_create_users",
  76. "auto_link_existing_accounts",
  77. "email_claim",
  78. "require_email_verified",
  79. "icon_url",
  80. "is_autologin",
  81. # Written on every boot, so a group that is no longer declared is cleared:
  82. # the environment is the whole truth for this row, and the API lock means
  83. # a lingering value could not be removed in the UI either.
  84. "default_group_id",
  85. )
  86. async def apply_env_oidc_provider(db: AsyncSession) -> None:
  87. """Upsert the env-managed provider, or release it when the config is gone.
  88. Never raises: this runs during startup, and a typo in one variable -- or a
  89. DB error on commit -- must not stop the app from booting. A rejected
  90. config is logged and skipped.
  91. """
  92. try:
  93. await _apply_env_oidc_provider(db)
  94. except Exception as exc: # noqa: BLE001 -- startup must survive any failure here
  95. # Never str(exc): a DB error message can echo a configured value. Class only.
  96. logger.error("BAMBUDDY_OIDC_* could not be applied: %s", type(exc).__name__)
  97. # A commit may have half-applied; roll back so the shared session is
  98. # left clean for the rest of startup. Suppressed because rollback on a
  99. # wedged connection can itself raise -- and the whole point here is that
  100. # nothing in this path takes the boot down. The session is discarded by
  101. # the caller's `async with` regardless.
  102. with contextlib.suppress(Exception):
  103. await db.rollback()
  104. async def _apply_env_oidc_provider(db: AsyncSession) -> None:
  105. # Imported here rather than at module scope: app.core is imported by the
  106. # models themselves, so a top-level import would be a cycle.
  107. from backend.app.models.group import Group
  108. from backend.app.models.oidc_provider import OIDCProvider
  109. from backend.app.schemas.auth import OIDCProviderCreate
  110. try:
  111. config = read_env_oidc_config()
  112. except EnvOIDCConfigError as exc:
  113. # Same disposition as a ValidationError or an unmatched DEFAULT_GROUP:
  114. # log clearly and leave any running provider as it was. Safe to log the
  115. # full message -- EnvOIDCConfigError only ever carries a boolean var.
  116. logger.error("BAMBUDDY_OIDC_* config rejected, provider not applied: %s", exc)
  117. return
  118. if config is None:
  119. # Nothing to look up by name any more, so the previously managed rows are
  120. # found by the flag -- and then released. All of them: the upsert's sweep
  121. # should keep that at one, but scalar_one_or_none() would raise
  122. # MultipleResultsFound out of the lifespan the moment it isn't, and
  123. # losing the boot is too steep a price for an invariant check.
  124. released_rows = (
  125. (await db.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))).scalars().all()
  126. )
  127. for released in released_rows:
  128. # Disabled, never deleted: user_oidc_links.provider_id is FK ON
  129. # DELETE CASCADE, so removing the row would unlink every bound
  130. # account and the links would not come back when the variables do.
  131. # The flag is cleared as well: with no config behind it, a provider
  132. # the API still refuses to edit or delete would be a dead end
  133. # reachable only through the database.
  134. released.is_enabled = False
  135. released.is_env_managed = False
  136. # Cleared too, or the released row keeps a latent autologin claim:
  137. # update_oidc_provider only re-runs the exclusivity sweep when a
  138. # request sets is_autologin=True, so re-enabling this row in the UI
  139. # would silently make it the autologin target again.
  140. released.is_autologin = False
  141. logger.info(
  142. "BAMBUDDY_OIDC_* is unset -- provider %r disabled and released to the UI.",
  143. released.name,
  144. )
  145. if released_rows:
  146. await db.commit()
  147. return
  148. # Identity is the name, which is unique on the table. Matching on the flag
  149. # instead meant an operator who named the env provider after one that
  150. # already existed hit that unique constraint during startup -- and this
  151. # function runs in the lifespan, so the app would not boot.
  152. existing = (await db.execute(select(OIDCProvider).where(OIDCProvider.name == config["name"]))).scalar_one_or_none()
  153. # Resolved before anything is written, so a name that matches no group
  154. # leaves the running provider untouched. Refused rather than defaulted:
  155. # falling back would put every auto-created user in Viewers (routes/mfa.py)
  156. # for as long as the typo lives, and the API answers 422 for a
  157. # default_group_id that does not exist -- env config gets the same answer.
  158. group_name = config.pop("default_group", None)
  159. if group_name is not None:
  160. group = (await db.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
  161. if group is None:
  162. # Spelled out because the two cases differ sharply: an existing
  163. # provider keeps running on its last good config, while on a first
  164. # boot nothing is created at all and the login page has no SSO
  165. # button until the name matches.
  166. logger.error(
  167. "BAMBUDDY_OIDC_DEFAULT_GROUP=%r matches no group, provider not applied (%s).",
  168. group_name,
  169. "previous config left running" if existing is not None else "no provider created",
  170. )
  171. return
  172. config["default_group_id"] = group.id
  173. try:
  174. # The same schema the API uses, so env config cannot reach a state the
  175. # UI would have refused (notably the SEC-1 auto-link check).
  176. validated = OIDCProviderCreate(**config)
  177. except ValidationError as exc:
  178. # errors(include_input=False) strips the submitted values -- str(exc)
  179. # embeds input_value=... and would leak BAMBUDDY_OIDC_CLIENT_SECRET.
  180. logger.error(
  181. "BAMBUDDY_OIDC_* config rejected, provider not applied: %s",
  182. exc.errors(include_input=False),
  183. )
  184. return
  185. except Exception as exc: # noqa: BLE001 -- any rejection must be survivable
  186. # Log only the exception class, never str(exc): an unexpected error here
  187. # could carry a configured value in its message. Structural guarantee,
  188. # not one contingent on which exceptions the schema validators raise.
  189. logger.error("BAMBUDDY_OIDC_* config could not be applied: %s", type(exc).__name__)
  190. return
  191. # Computed before `existing` is reassigned below: a freshly-created row is
  192. # not an adoption, and a found row that was already env-managed is a
  193. # routine re-apply -- only a found row that the UI created is an adoption.
  194. adopted_ui_provider = existing is not None and not existing.is_env_managed
  195. if existing is None:
  196. existing = OIDCProvider(is_env_managed=True)
  197. db.add(existing)
  198. for field in _APPLIED_FIELDS:
  199. setattr(existing, field, getattr(validated, field))
  200. existing.client_secret = validated.client_secret
  201. existing.is_env_managed = True
  202. await db.flush() # the id is needed by the sweeps below
  203. # Renaming BAMBUDDY_OIDC_NAME matches nothing, so the row managed until now
  204. # stays behind. Left flagged it would keep a stale issuer and secret on the
  205. # login page while the API refuses every edit, disable and delete on it
  206. # (409) -- the dead end reachable only through the database that the release
  207. # path exists to prevent -- and the next release would find two rows and
  208. # take the boot down with MultipleResultsFound. Released, not deleted, for
  209. # the same cascade reason as everywhere else.
  210. await db.execute(
  211. update(OIDCProvider)
  212. .where(OIDCProvider.id != existing.id, OIDCProvider.is_env_managed.is_(True))
  213. .values(is_env_managed=False, is_enabled=False, is_autologin=False)
  214. )
  215. if existing.is_autologin:
  216. await db.execute(
  217. update(OIDCProvider)
  218. .where(OIDCProvider.id != existing.id, OIDCProvider.is_autologin.is_(True))
  219. .values(is_autologin=False)
  220. )
  221. await db.commit()
  222. if adopted_ui_provider:
  223. logger.warning(
  224. "Env-managed OIDC provider %r adopted an existing UI-created provider of the "
  225. "same name; its issuer, client and secret are now managed by BAMBUDDY_OIDC_*.",
  226. existing.name,
  227. )
  228. else:
  229. logger.info("Env-managed OIDC provider %r applied.", existing.name)