oidc_env.py 9.5 KB

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