oidc_env.py 16 KB

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