mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
apply_env_oidc_provider matches the provider by BAMBUDDY_OIDC_NAME but never cleared is_env_managed from the row it managed previously. Renaming the variable therefore left two flagged rows, and both consequences are reachable by ordinary config edits: the old row stayed enabled with a stale issuer and secret on the login page while _refuse_if_env_managed answered 409 to every attempt to edit, disable or delete it -- the dead end reachable only through the database that the release path exists to prevent -- and unsetting the variables later hit scalar_one_or_none() on two rows, so MultipleResultsFound propagated out of the lifespan and the app stopped booting. The upsert now sweeps the flag off every other row, the same shape the autologin sweep one block down already uses: disable and release rather than delete, for the same cascade reason as everywhere else in this branch. The release path releases every flagged row it finds instead of exactly one -- the sweep should keep that at one, but a release path that dies with MultipleResultsFound the moment that invariant breaks is a second way to lose the boot, and the query costs the same either way. Releasing now clears is_autologin as well. Without it a released row keeps a latent autologin claim: update_oidc_provider only re-runs the exclusivity sweep when a request sets is_autologin=True, so merely re-enabling the row in the UI would silently make it the autologin target again. Reported by maziggy in review of #2625, with the rename reproduction.
180 lines
7.7 KiB
Python
180 lines
7.7 KiB
Python
"""Read the single OIDC provider defined by BAMBUDDY_OIDC_* env vars (#2593).
|
|
|
|
A declarative deployment (compose, Helm, GitOps) has no way to click through
|
|
the settings UI, so one provider can be configured entirely from the
|
|
environment. This module only reads and defaults; validity is decided by the
|
|
same OIDCProviderCreate schema the API uses, so env config cannot bypass a
|
|
check the UI enforces.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# All four or nothing: a provider missing its secret would be written to the
|
|
# database and then fail at authorize time, long after the operator could
|
|
# connect the failure to a typo in their compose file.
|
|
_REQUIRED = (
|
|
"BAMBUDDY_OIDC_NAME",
|
|
"BAMBUDDY_OIDC_ISSUER_URL",
|
|
"BAMBUDDY_OIDC_CLIENT_ID",
|
|
"BAMBUDDY_OIDC_CLIENT_SECRET",
|
|
)
|
|
|
|
_TRUTHY = {"true", "1", "yes"}
|
|
|
|
|
|
def _env_bool(key: str, default: bool) -> bool:
|
|
value = os.environ.get(key)
|
|
return default if value is None else value.strip().lower() in _TRUTHY
|
|
|
|
|
|
def read_env_oidc_config() -> dict | None:
|
|
"""The provider's fields from the environment, or None if it isn't configured.
|
|
|
|
An empty required var counts as unset -- `BAMBUDDY_OIDC_CLIENT_SECRET=` in
|
|
a compose file is a forgotten value, not an intentional empty secret.
|
|
"""
|
|
if not all(os.environ.get(key) for key in _REQUIRED):
|
|
return None
|
|
|
|
return {
|
|
"name": os.environ["BAMBUDDY_OIDC_NAME"],
|
|
"issuer_url": os.environ["BAMBUDDY_OIDC_ISSUER_URL"],
|
|
"client_id": os.environ["BAMBUDDY_OIDC_CLIENT_ID"],
|
|
"client_secret": os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"],
|
|
"scopes": os.environ.get("BAMBUDDY_OIDC_SCOPES", "openid email profile"),
|
|
"is_enabled": _env_bool("BAMBUDDY_OIDC_ENABLED", True),
|
|
"auto_create_users": _env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False),
|
|
"auto_link_existing_accounts": _env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False),
|
|
"email_claim": os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM", "email"),
|
|
"require_email_verified": _env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
|
|
"icon_url": os.environ.get("BAMBUDDY_OIDC_ICON_URL"),
|
|
"is_autologin": _env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
|
|
}
|
|
|
|
|
|
# Everything the schema validates and the model stores, except client_secret --
|
|
# that one goes through the property so it is encrypted at rest.
|
|
_APPLIED_FIELDS = (
|
|
"name",
|
|
"issuer_url",
|
|
"client_id",
|
|
"scopes",
|
|
"is_enabled",
|
|
"auto_create_users",
|
|
"auto_link_existing_accounts",
|
|
"email_claim",
|
|
"require_email_verified",
|
|
"icon_url",
|
|
"is_autologin",
|
|
)
|
|
|
|
|
|
async def apply_env_oidc_provider(db: AsyncSession) -> None:
|
|
"""Upsert the env-managed provider, or release it when the config is gone.
|
|
|
|
Never raises: this runs during startup, and a typo in one variable must not
|
|
stop the app from booting. A rejected config is logged and skipped.
|
|
"""
|
|
# Imported here rather than at module scope: app.core is imported by the
|
|
# models themselves, so a top-level import would be a cycle.
|
|
from backend.app.models.oidc_provider import OIDCProvider
|
|
from backend.app.schemas.auth import OIDCProviderCreate
|
|
|
|
config = read_env_oidc_config()
|
|
|
|
if config is None:
|
|
# Nothing to look up by name any more, so the previously managed rows are
|
|
# found by the flag -- and then released. All of them: an install
|
|
# upgraded from a version that did not sweep the flag on rename carries
|
|
# two, and scalar_one_or_none() would raise MultipleResultsFound out of
|
|
# the lifespan instead of booting.
|
|
released_rows = (
|
|
(await db.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))).scalars().all()
|
|
)
|
|
for released in released_rows:
|
|
# Disabled, never deleted: user_oidc_links.provider_id is FK ON
|
|
# DELETE CASCADE, so removing the row would unlink every bound
|
|
# account and the links would not come back when the variables do.
|
|
# The flag is cleared as well: with no config behind it, a provider
|
|
# the API still refuses to edit or delete would be a dead end
|
|
# reachable only through the database.
|
|
released.is_enabled = False
|
|
released.is_env_managed = False
|
|
# Cleared too, or the released row keeps a latent autologin claim:
|
|
# update_oidc_provider only re-runs the exclusivity sweep when a
|
|
# request sets is_autologin=True, so re-enabling this row in the UI
|
|
# would silently make it the autologin target again.
|
|
released.is_autologin = False
|
|
logger.info(
|
|
"BAMBUDDY_OIDC_* is unset -- provider %r disabled and released to the UI.",
|
|
released.name,
|
|
)
|
|
if released_rows:
|
|
await db.commit()
|
|
return
|
|
|
|
# Identity is the name, which is unique on the table. Matching on the flag
|
|
# instead meant an operator who named the env provider after one that
|
|
# already existed hit that unique constraint during startup -- and this
|
|
# function runs in the lifespan, so the app would not boot.
|
|
existing = (await db.execute(select(OIDCProvider).where(OIDCProvider.name == config["name"]))).scalar_one_or_none()
|
|
|
|
try:
|
|
# The same schema the API uses, so env config cannot reach a state the
|
|
# UI would have refused (notably the SEC-1 auto-link check).
|
|
validated = OIDCProviderCreate(**config)
|
|
except ValidationError as exc:
|
|
# errors(include_input=False) strips the submitted values -- str(exc)
|
|
# embeds input_value=... and would leak BAMBUDDY_OIDC_CLIENT_SECRET.
|
|
logger.error(
|
|
"BAMBUDDY_OIDC_* config rejected, provider not applied: %s",
|
|
exc.errors(include_input=False),
|
|
)
|
|
return
|
|
except Exception as exc: # noqa: BLE001 -- any rejection must be survivable
|
|
# Log only the exception class, never str(exc): an unexpected error here
|
|
# could carry a configured value in its message. Structural guarantee,
|
|
# not one contingent on which exceptions the schema validators raise.
|
|
logger.error("BAMBUDDY_OIDC_* config could not be applied: %s", type(exc).__name__)
|
|
return
|
|
|
|
if existing is None:
|
|
existing = OIDCProvider(is_env_managed=True)
|
|
db.add(existing)
|
|
for field in _APPLIED_FIELDS:
|
|
setattr(existing, field, getattr(validated, field))
|
|
existing.client_secret = validated.client_secret
|
|
existing.is_env_managed = True
|
|
await db.flush() # the id is needed by the sweeps below
|
|
|
|
# Renaming BAMBUDDY_OIDC_NAME matches nothing, so the row managed until now
|
|
# stays behind. Left flagged it would keep a stale issuer and secret on the
|
|
# login page while the API refuses every edit, disable and delete on it
|
|
# (409) -- the dead end reachable only through the database that the release
|
|
# path exists to prevent -- and the next release would find two rows and
|
|
# take the boot down with MultipleResultsFound. Released, not deleted, for
|
|
# the same cascade reason as everywhere else.
|
|
await db.execute(
|
|
update(OIDCProvider)
|
|
.where(OIDCProvider.id != existing.id, OIDCProvider.is_env_managed.is_(True))
|
|
.values(is_env_managed=False, is_enabled=False, is_autologin=False)
|
|
)
|
|
|
|
if existing.is_autologin:
|
|
await db.execute(
|
|
update(OIDCProvider)
|
|
.where(OIDCProvider.id != existing.id, OIDCProvider.is_autologin.is_(True))
|
|
.values(is_autologin=False)
|
|
)
|
|
await db.commit()
|
|
logger.info("Env-managed OIDC provider %r applied.", existing.name)
|