oidc_env.py 13 KB

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