oidc_env.py 11 KB

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