oidc_env.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  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. }
  49. # Everything the schema validates and the model stores, except client_secret --
  50. # that one goes through the property so it is encrypted at rest.
  51. _APPLIED_FIELDS = (
  52. "name",
  53. "issuer_url",
  54. "client_id",
  55. "scopes",
  56. "is_enabled",
  57. "auto_create_users",
  58. "auto_link_existing_accounts",
  59. "email_claim",
  60. "require_email_verified",
  61. "icon_url",
  62. "is_autologin",
  63. )
  64. async def apply_env_oidc_provider(db: AsyncSession) -> None:
  65. """Upsert the env-managed provider, or release it when the config is gone.
  66. Never raises: this runs during startup, and a typo in one variable must not
  67. stop the app from booting. A rejected config is logged and skipped.
  68. """
  69. # Imported here rather than at module scope: app.core is imported by the
  70. # models themselves, so a top-level import would be a cycle.
  71. from backend.app.models.oidc_provider import OIDCProvider
  72. from backend.app.schemas.auth import OIDCProviderCreate
  73. config = read_env_oidc_config()
  74. if config is None:
  75. # Nothing to look up by name any more, so the previously managed row is
  76. # found by the flag -- and then released.
  77. released = (
  78. await db.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
  79. ).scalar_one_or_none()
  80. if released is not None:
  81. # Disabled, never deleted: user_oidc_links.provider_id is FK ON
  82. # DELETE CASCADE, so removing the row would unlink every bound
  83. # account and the links would not come back when the variables do.
  84. # The flag is cleared as well: with no config behind it, a provider
  85. # the API still refuses to edit or delete would be a dead end
  86. # reachable only through the database.
  87. released.is_enabled = False
  88. released.is_env_managed = False
  89. await db.commit()
  90. logger.info(
  91. "BAMBUDDY_OIDC_* is unset -- provider %r disabled and released to the UI.",
  92. released.name,
  93. )
  94. return
  95. # Identity is the name, which is unique on the table. Matching on the flag
  96. # instead meant an operator who named the env provider after one that
  97. # already existed hit that unique constraint during startup -- and this
  98. # function runs in the lifespan, so the app would not boot.
  99. existing = (await db.execute(select(OIDCProvider).where(OIDCProvider.name == config["name"]))).scalar_one_or_none()
  100. try:
  101. # The same schema the API uses, so env config cannot reach a state the
  102. # UI would have refused (notably the SEC-1 auto-link check).
  103. validated = OIDCProviderCreate(**config)
  104. except ValidationError as exc:
  105. # errors(include_input=False) strips the submitted values -- str(exc)
  106. # embeds input_value=... and would leak BAMBUDDY_OIDC_CLIENT_SECRET.
  107. logger.error(
  108. "BAMBUDDY_OIDC_* config rejected, provider not applied: %s",
  109. exc.errors(include_input=False),
  110. )
  111. return
  112. except Exception as exc: # noqa: BLE001 -- any rejection must be survivable
  113. # Log only the exception class, never str(exc): an unexpected error here
  114. # could carry a configured value in its message. Structural guarantee,
  115. # not one contingent on which exceptions the schema validators raise.
  116. logger.error("BAMBUDDY_OIDC_* config could not be applied: %s", type(exc).__name__)
  117. return
  118. if existing is None:
  119. existing = OIDCProvider(is_env_managed=True)
  120. db.add(existing)
  121. for field in _APPLIED_FIELDS:
  122. setattr(existing, field, getattr(validated, field))
  123. existing.client_secret = validated.client_secret
  124. existing.is_env_managed = True
  125. await db.flush() # the id is needed by the autologin sweep below
  126. if existing.is_autologin:
  127. await db.execute(
  128. update(OIDCProvider)
  129. .where(OIDCProvider.id != existing.id, OIDCProvider.is_autologin.is_(True))
  130. .values(is_autologin=False)
  131. )
  132. await db.commit()
  133. logger.info("Env-managed OIDC provider %r applied.", existing.name)