Merge branch 'dev' into feature/queue-item-eta

This commit is contained in:
MartinNYHC
2026-08-02 08:09:53 +02:00
committed by GitHub
23 changed files with 1957 additions and 43 deletions
+61
View File
@@ -66,3 +66,64 @@ LOG_TO_FILE=true
# LDAP is governed by its own ldap_enabled toggle and is not affected.
# Leave unset for normal operation.
# BAMBUDDY_LOCAL_LOGIN=true
# --- OIDC provider from the environment (#2593) ------------------------------
# Defines ONE OIDC provider declaratively, for deployments that are managed by
# compose files or GitOps and never touch the settings UI. Providers created in
# the UI are unaffected and keep working alongside this one.
#
# Activates only when all four required vars below are set; an empty value
# counts as unset. The provider is written on startup and re-applied on every
# boot, so the UI shows it as read-only and the API refuses to change it -- an
# edit there would be reverted at the next restart anyway.
#
# Removing the vars DISABLES the provider rather than deleting it: accounts
# linked to it would otherwise lose their link permanently. Re-adding the vars
# enables it again with those links intact.
#
# If you lock yourself out, BAMBUDDY_LOCAL_LOGIN=true above is the way back in.
#
# Required:
# BAMBUDDY_OIDC_NAME=Keycloak
# BAMBUDDY_OIDC_ISSUER_URL=https://sso.example.com/realms/main
# BAMBUDDY_OIDC_CLIENT_ID=bambuddy
# BAMBUDDY_OIDC_CLIENT_SECRET=your-client-secret
#
# Optional, shown with their defaults:
# BAMBUDDY_OIDC_SCOPES=openid email profile
# BAMBUDDY_OIDC_ENABLED=true
# BAMBUDDY_OIDC_AUTO_CREATE_USERS=false
# BAMBUDDY_OIDC_AUTO_LINK_EXISTING=false
# BAMBUDDY_OIDC_EMAIL_CLAIM=email
# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=true
# BAMBUDDY_OIDC_ICON_URL=
# BAMBUDDY_OIDC_AUTOLOGIN=false
# BAMBUDDY_OIDC_DEFAULT_GROUP=
#
# Booleans accept true/1/yes or false/0/no (case-insensitive). Blank or unset
# uses the default; any other value is rejected and the provider is skipped.
#
# DEFAULT_GROUP is the group new users land in when AUTO_CREATE_USERS is on;
# without it they get Viewers. It matches a group NAME exactly (case-sensitive)
# -- group ids are assigned per install, so the same compose file would point at
# a different group on every deployment. A name that matches no group is
# refused: the provider is left as it was and the reason is logged, rather than
# quietly creating under-privileged users the locked UI could not correct. On a
# FIRST boot that means no provider is created at all and no SSO button appears
# -- create the group first. Removing the variable clears the group again.
#
# AUTO_LINK_EXISTING binds an OIDC identity to an existing local account with
# the same email address. With EMAIL_CLAIM=email it is refused unless
# REQUIRE_EMAIL_VERIFIED=true, because an identity provider that does not
# verify addresses would let anyone claim someone else's account. The whole
# config is then skipped and logged; the app still starts.
#
# ISSUER_URL must be https:// and publicly reachable -- private, loopback,
# link-local, numeric-encoded and IPv4-mapped hosts are rejected. An in-cluster
# URL like http://keycloak:8080 is refused with a single log line and no SSO
# button; use the externally-reachable HTTPS issuer URL instead.
#
# NAME is matched against the existing providers on every boot: setting it to
# the name of one you already created in the UI ADOPTS and OVERWRITES it (its
# issuer, client id and secret are replaced and it becomes read-only). Pick a
# name that doesn't collide unless that takeover is intended.
+1
View File
@@ -15,6 +15,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests.
### Added
- **Configure an OIDC provider from environment variables (#2593, contributor @munin92)** — Single Sign-On could only be set up by clicking through Settings → Authentication, which does not exist as a step in a deployment driven by a compose file, a Helm chart or a GitOps repository: every fresh install of the same stack needed someone to log in and re-enter the same issuer, client ID and secret by hand. One provider can now be defined entirely from `BAMBUDDY_OIDC_*` variables. It is written on startup and re-applied on every boot, so the file in your repository is the source of truth, and providers created in the UI keep working alongside it, untouched. Only the four required variables — name, issuer URL, client ID and client secret — activate it, and all four or none: a half-configured provider is refused at boot with a log line naming what was wrong, rather than appearing on the login page and failing when somebody clicks it. Values are validated by exactly the same rules the settings UI enforces, including the one that refuses account auto-linking on an unverified email address, so the environment cannot reach a configuration the UI would have rejected. A typo never stops the app from booting and never takes a working provider down: the previous configuration keeps running and the reason is logged. In the UI the provider is shown with a lock badge and no edit, delete or enable controls, because startup would overwrite any change at the next restart. Removing the variables disables the provider instead of deleting it — deleting would permanently unlink every account bound to it — and re-adding them brings it back with those links intact. `BAMBUDDY_LOCAL_LOGIN=true` remains the way back in if a misconfigured provider ever locks you out. All thirteen variables are documented in `.env.example`; wiki updated. Covered by backend and frontend tests.
- **Keep the AMS slots the slicer picked (#2700, contributor @Striker72rus)** — Bambu Studio and OrcaSlicer resolve which physical AMS tray feeds each filament themselves, right before sending. Bambuddy threw that away: a queue-mode virtual printer worked the mapping out again at dispatch time, from the filament type and colour baked into the 3MF. That is usually the better answer — it is computed against the printer's live trays and it respects **Prefer lowest filament** and the AMS-backup gate that goes with it — but it has nothing to go on when the match isn't unique. Two spools of the same red PLA, and the slot you deliberately chose in the slicer is a coin toss. A new per-virtual-printer **Save AMS mapping** toggle keeps the slicer's pick instead: the print dispatches to exactly those trays, and the mapping is stored on the archive so a reprint can reuse the same physical spools — a **Mapping** button in the print modal selects every slot from it in one click, and the archive card and queue row say so. Because a tray number only means something on the AMS it was resolved against, the mapping records its printer and is only ever offered on that same printer; a model-based ("Any [model]") virtual printer has no fixed printer and is unaffected. Off by default, so nothing changes for existing virtual printers until you turn it on, and **Force color match** still wins for the print being dispatched when both are on. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **P2S/X2D accessory fans: left auxiliary cooling and chamber exhaust (#2691, contributor @gzimbric, requested in #2660)** — The P2S and X2D have two fans Bambuddy could not show or drive. The **left auxiliary part cooling fan** had no tile and no control at all, because the printer only reports it inside its air-duct data and never in the ordinary fan fields Bambuddy was reading. The **chamber exhaust fan** had the opposite problem: its tile appeared on every P2S whether or not the fan was fitted, so owners of a base machine had a control that did nothing. Both are add-on kits on the P2S and fitted at the factory on the X2D. Both tiles now appear only when the printer itself reports the hardware, so a base P2S looks exactly as it does today and a kitted one gains the fans it actually has. The left auxiliary fan is set from the same speed popover as the others, and the enclosure fan is labelled **Exhaust** on the P2S and X2D — matching the printer's own screen and Bambu Studio — while every other enclosed model keeps **Chamber Fan**. The confirmation message after changing a speed uses the same name as the tile that was clicked. The four tiles are ordered part cooling, left auxiliary, auxiliary, exhaust, so they read left to right in the same order as the physical fans. Both fields are also published through the status endpoint, the WebSocket feed and the MQTT relay, so external automations can read them. Translated in all locales; wiki updated. Covered by backend and frontend tests, including the case where the printer sends a partial fan report — a tile must not disappear mid-print just because one update didn't mention it.
- **Live print progress in the browser tab (#2693, contributor @Chachigo, requested in #1041)** — Watching a print meant keeping the Bambuddy tab in view, or switching back to it every few minutes. Enable **Print progress in tab** under Settings → Appearance and the tab title becomes `42% · Bambuddy` while the favicon turns into a progress ring in your theme accent colour, both updating live over the WebSocket the rest of the UI already uses. With several printers running, the tab follows the one finishing soonest, tie-broken by highest progress; title and favicon return to their defaults as soon as nothing is printing or the toggle goes off. Off by default, and stored per browser (like the light/dark toggle) so a wall-mounted dashboard and a laptop can each have their own setting. Translated in all locales; wiki updated. Covered by frontend tests.
+1 -1
View File
@@ -6,7 +6,7 @@
<p align="center">
<strong>Your printers. No cloud. Your rules.</strong><br>
Self-hosted command center for Bambu Lab &mdash; from one A1 to a 40-printer farm.
Self-hosted command center for Bambu Lab &mdash; from one A1 to an entire print farm.
</p>
<p align="center">
+6 -1
View File
@@ -35,6 +35,7 @@ from backend.app.core.auth import (
security,
)
from backend.app.core.database import async_session, get_db
from backend.app.core.oidc_env import env_bool
from backend.app.core.permissions import ALL_PERMISSIONS
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
from backend.app.models.group import Group
@@ -122,7 +123,11 @@ def _local_login_env_bypass() -> bool:
an install whose SSO provider is unreachable. Accepted truthy values:
``true``, ``1``, ``yes`` (case-insensitive).
"""
return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
# strict=False: this runs on the login/forgot-password request path, not at
# startup. An unrecognized value must fall back to "off" (the safe default),
# never raise -- a 500 on the recovery endpoint is the opposite of what this
# bypass is for.
return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False)
def _get_client_ip(request: Request) -> str:
+16
View File
@@ -1404,6 +1404,18 @@ async def create_oidc_provider(
return _build_provider_response(provider)
def _refuse_if_env_managed(provider: OIDCProvider) -> None:
"""Startup rewrites this provider from BAMBUDDY_OIDC_* on every boot, so an
edit here would be accepted and then silently reverted at the next restart.
BAMBUDDY_LOCAL_LOGIN (#1589) remains the recovery path if it becomes
unusable, so refusing outright cannot lock anyone out."""
if provider.is_env_managed:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This OIDC provider is managed by environment variables and cannot be modified.",
)
@router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
async def update_oidc_provider(
provider_id: int,
@@ -1426,6 +1438,7 @@ async def update_oidc_provider(
provider = result2.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
_refuse_if_env_managed(provider)
if body.default_group_id is not None:
grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
@@ -1503,6 +1516,7 @@ async def delete_oidc_provider(
provider = result2.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
_refuse_if_env_managed(provider)
await db.delete(provider)
await db.commit()
@@ -1571,6 +1585,7 @@ async def delete_oidc_provider_icon(
provider = result.scalar_one_or_none()
if provider is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
_refuse_if_env_managed(provider)
# Setting deferred columns is safe — no read happens, just a write.
provider.icon_url = None
@@ -1603,6 +1618,7 @@ async def refresh_oidc_provider_icon(
provider = result.scalar_one_or_none()
if provider is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
_refuse_if_env_managed(provider)
if not provider.icon_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
+19
View File
@@ -135,6 +135,25 @@ _INTENTIONAL_UNSETTINGS = {
"LOG_DIR", # config.py (above)
"LOG_LEVEL", # main.py logging setup
"BUG_REPORT_RELAY_URL", # config.py (above)
# #1589 — api/routes/auth.py reads this on the login path. Unregistered it
# logged "possible typo" at every boot, telling an operator who is locked
# out and following the documented recovery that the variable is not real.
"BAMBUDDY_LOCAL_LOGIN",
# #2593 — core/oidc_env.py reads these directly; they are not Settings
# fields because they map to an OIDCProvider row, not to app config.
"BAMBUDDY_OIDC_NAME",
"BAMBUDDY_OIDC_ISSUER_URL",
"BAMBUDDY_OIDC_CLIENT_ID",
"BAMBUDDY_OIDC_CLIENT_SECRET",
"BAMBUDDY_OIDC_SCOPES",
"BAMBUDDY_OIDC_ENABLED",
"BAMBUDDY_OIDC_AUTO_CREATE_USERS",
"BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
"BAMBUDDY_OIDC_EMAIL_CLAIM",
"BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
"BAMBUDDY_OIDC_ICON_URL",
"BAMBUDDY_OIDC_AUTOLOGIN",
"BAMBUDDY_OIDC_DEFAULT_GROUP",
}
_known_settings_fields = {f.upper() for f in settings.model_fields}
+8
View File
@@ -3808,6 +3808,14 @@ async def run_migrations(conn):
else:
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
# Migration: Add is_env_managed column to oidc_providers (#2593). Marks the
# provider upserted from BAMBUDDY_OIDC_* env vars on startup. Postgres
# rejects ``DEFAULT 0`` for BOOLEAN columns.
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT 0")
else:
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT false")
# Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
# the start-watchdog reverted the row from 'printing' back to 'pending' so a
# printer that never actually starts stops being retried forever. INTEGER
+278
View File
@@ -0,0 +1,278 @@
"""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 contextlib
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"}
_FALSY = {"false", "0", "no"}
class EnvOIDCConfigError(Exception):
"""A BAMBUDDY_OIDC_* value the reader cannot interpret. Only ever carries a
boolean variable's name and value -- booleans are not secret, so the message
is safe to log in full (unlike client_secret, which never reaches here)."""
def env_bool(key: str, default: bool, *, strict: bool = True) -> bool:
"""Parse a boolean env var. Absent or blank -> default (empty == unset).
strict (the default): an unrecognized non-empty value raises
EnvOIDCConfigError, so a typo is refused loudly rather than silently read as
the wrong thing. strict=False: an unrecognized value falls back to the
default instead -- for a caller on a request path where a raise would be a
500, not a skipped startup config (see _local_login_env_bypass).
"""
value = os.environ.get(key)
if value is None or value.strip() == "":
return default # absent or blank == unset -> default, per the module's promise
norm = value.strip().lower()
if norm in _TRUTHY:
return True
if norm in _FALSY:
return False
if strict:
raise EnvOIDCConfigError(f"{key}={value!r} is not a recognized boolean (use true/1/yes or false/0/no)")
return default
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. Blank
means blank *after* stripping, and the surviving value is stripped too: a
Kubernetes Secret written as a block scalar (``stringData: secret: |``) or
created from a file carries a trailing newline that nothing downstream
rejects -- max_length is the only bound the schema puts on these four. An
issuer_url with a trailing newline is stored and enabled, and then fails
with httpx.InvalidURL on the first click of the SSO button, which is the
authorize-time failure the all-or-nothing rule above exists to prevent.
"""
required = {key: (os.environ.get(key) or "").strip() for key in _REQUIRED}
if not all(required.values()):
return None
return {
"name": required["BAMBUDDY_OIDC_NAME"],
"issuer_url": required["BAMBUDDY_OIDC_ISSUER_URL"],
"client_id": required["BAMBUDDY_OIDC_CLIENT_ID"],
"client_secret": required["BAMBUDDY_OIDC_CLIENT_SECRET"],
"scopes": (os.environ.get("BAMBUDDY_OIDC_SCOPES") or "").strip() or "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") or "").strip() or "email",
"require_email_verified": env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
"icon_url": (os.environ.get("BAMBUDDY_OIDC_ICON_URL") or "").strip() or None,
"is_autologin": env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
# A name, not an id: ids are assigned per install, so the same compose
# file would point at a different group on every deployment. Resolved
# against the database in apply_env_oidc_provider -- the reader has no
# session and stays dumb.
"default_group": (os.environ.get("BAMBUDDY_OIDC_DEFAULT_GROUP") or "").strip() or None,
}
# 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",
# Written on every boot, so a group that is no longer declared is cleared:
# the environment is the whole truth for this row, and the API lock means
# a lingering value could not be removed in the UI either.
"default_group_id",
)
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 -- or a
DB error on commit -- must not stop the app from booting. A rejected
config is logged and skipped.
"""
try:
await _apply_env_oidc_provider(db)
except Exception as exc: # noqa: BLE001 -- startup must survive any failure here
# Never str(exc): a DB error message can echo a configured value. Class only.
logger.error("BAMBUDDY_OIDC_* could not be applied: %s", type(exc).__name__)
# A commit may have half-applied; roll back so the shared session is
# left clean for the rest of startup. Suppressed because rollback on a
# wedged connection can itself raise -- and the whole point here is that
# nothing in this path takes the boot down. The session is discarded by
# the caller's `async with` regardless.
with contextlib.suppress(Exception):
await db.rollback()
async def _apply_env_oidc_provider(db: AsyncSession) -> None:
# 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.group import Group
from backend.app.models.oidc_provider import OIDCProvider
from backend.app.schemas.auth import OIDCProviderCreate
try:
config = read_env_oidc_config()
except EnvOIDCConfigError as exc:
# Same disposition as a ValidationError or an unmatched DEFAULT_GROUP:
# log clearly and leave any running provider as it was. Safe to log the
# full message -- EnvOIDCConfigError only ever carries a boolean var.
logger.error("BAMBUDDY_OIDC_* config rejected, provider not applied: %s", exc)
return
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: the upsert's sweep
# should keep that at one, but scalar_one_or_none() would raise
# MultipleResultsFound out of the lifespan the moment it isn't, and
# losing the boot is too steep a price for an invariant check.
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()
# Resolved before anything is written, so a name that matches no group
# leaves the running provider untouched. Refused rather than defaulted:
# falling back would put every auto-created user in Viewers (routes/mfa.py)
# for as long as the typo lives, and the API answers 422 for a
# default_group_id that does not exist -- env config gets the same answer.
group_name = config.pop("default_group", None)
if group_name is not None:
group = (await db.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
if group is None:
# Spelled out because the two cases differ sharply: an existing
# provider keeps running on its last good config, while on a first
# boot nothing is created at all and the login page has no SSO
# button until the name matches.
logger.error(
"BAMBUDDY_OIDC_DEFAULT_GROUP=%r matches no group, provider not applied (%s).",
group_name,
"previous config left running" if existing is not None else "no provider created",
)
return
config["default_group_id"] = group.id
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
# Computed before `existing` is reassigned below: a freshly-created row is
# not an adoption, and a found row that was already env-managed is a
# routine re-apply -- only a found row that the UI created is an adoption.
adopted_ui_provider = existing is not None and not existing.is_env_managed
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()
if adopted_ui_provider:
logger.warning(
"Env-managed OIDC provider %r adopted an existing UI-created provider of the "
"same name; its issuer, client and secret are now managed by BAMBUDDY_OIDC_*.",
existing.name,
)
else:
logger.info("Env-managed OIDC provider %r applied.", existing.name)
+8
View File
@@ -6995,6 +6995,14 @@ async def lifespan(app: FastAPI):
await init_db()
# After migrations, so the is_env_managed column exists. Never raises --
# a bad BAMBUDDY_OIDC_* value is logged and skipped rather than blocking
# startup (see apply_env_oidc_provider).
from backend.app.core.oidc_env import apply_env_oidc_provider
async with async_session() as oidc_db:
await apply_env_oidc_provider(oidc_db)
# Register an app-scoped httpx client for Bambu Cloud services so
# per-request BambuCloudService instances reuse the same connection pool
# (important for routes like /cloud/filament-info that chain many
+4
View File
@@ -128,6 +128,10 @@ class OIDCProvider(Base):
# authorize-URL fetch fails or times out, and ``/login?fallback=local``
# plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
# Marks the single provider defined by BAMBUDDY_OIDC_* env vars. Upserted on
# startup; UI/API writes to it are rejected. Never delete-recreated (user_oidc_links
# FK is ON DELETE CASCADE).
is_env_managed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
@property
def has_icon(self) -> bool:
+3
View File
@@ -527,6 +527,9 @@ class OIDCProviderResponse(BaseModel):
icon_url: str | None = None
default_group_id: int | None = None
is_autologin: bool = False # #1589
# #2593 — the UI renders this provider read-only; without the flag it would
# offer editable fields whose writes the API then refuses with 409.
is_env_managed: bool = False
# Set explicitly in the route handler from `icon_content_type is not None`
# rather than `@computed_field` (project policy) or `icon_data is not None`
# (would trigger an async lazy-load on the deferred BLOB column).
@@ -96,6 +96,35 @@ class TestLocalLoginGate:
)
assert response.status_code == 200, response.text
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unrecognized_env_value_does_not_500_the_login_path(
self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
):
"""The recovery bypass reads BAMBUDDY_LOCAL_LOGIN on the request path, so
an unrecognized value (BAMBUDDY_LOCAL_LOGIN=on) must fall back to "off",
never raise -- env_bool is strict for the startup OIDC reader but lenient
here. A raise would 500 the very endpoint the bypass exists to keep open."""
await _enable_auth(async_client, "gateonval")
await _set_setting(db_session, "local_login_enabled", "false")
monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "on")
response = await async_client.post(
"/api/v1/auth/login",
json={"username": "gateonval", "password": "GatePass1!"},
)
# Bypass stays off (same 401 as no env var), and crucially not a 500.
assert response.status_code == 401, response.text
def test_the_bypass_var_is_registered_in_the_typo_guard(self):
"""config.py logs "possible typo" for any unregistered BAMBUDDY_* var.
Unregistered, this one tells an operator who is locked out and following
the documented recovery that the variable they just set is not real --
while the same line lists every BAMBUDDY_OIDC_* var as legitimate."""
from backend.app.core.config import _INTENTIONAL_UNSETTINGS
assert "BAMBUDDY_LOCAL_LOGIN" in _INTENTIONAL_UNSETTINGS
@pytest.mark.asyncio
@pytest.mark.integration
async def test_forgot_password_rejected_when_local_disabled(
@@ -0,0 +1,801 @@
"""Upserting the env-managed OIDC provider (#2593).
Startup applies BAMBUDDY_OIDC_* to the database. The row is updated in place,
never delete-recreated: user_oidc_links.provider_id is FK ON DELETE CASCADE, so
recreating the provider would silently unlink every account bound to it.
"""
from __future__ import annotations
import logging
import os
import pytest
from sqlalchemy import select
from backend.app.core.oidc_env import apply_env_oidc_provider
from backend.app.models.oidc_provider import OIDCProvider
REQUIRED = {
"BAMBUDDY_OIDC_NAME": "Keycloak",
"BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
"BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
"BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
}
ALL_VARS = (
*REQUIRED,
"BAMBUDDY_OIDC_SCOPES",
"BAMBUDDY_OIDC_ENABLED",
"BAMBUDDY_OIDC_AUTO_CREATE_USERS",
"BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
"BAMBUDDY_OIDC_EMAIL_CLAIM",
"BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
"BAMBUDDY_OIDC_ICON_URL",
"BAMBUDDY_OIDC_AUTOLOGIN",
"BAMBUDDY_OIDC_DEFAULT_GROUP",
)
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
def _configure(monkeypatch, **overrides):
for key, value in REQUIRED.items():
monkeypatch.setenv(key, value)
for key, value in overrides.items():
monkeypatch.setenv(key, value)
async def _env_provider(db_session) -> OIDCProvider | None:
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
return result.scalar_one_or_none()
@pytest.mark.asyncio
async def test_creates_the_provider_from_env(db_session, monkeypatch):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None
assert provider.name == "Keycloak"
assert provider.client_id == "bambuddy"
assert provider.is_env_managed is True
assert provider.client_secret == "s3cr3t" # property decrypts
@pytest.mark.asyncio
async def test_a_changed_var_updates_the_same_row(db_session, monkeypatch):
"""The id must survive: user_oidc_links references it with ON DELETE
CASCADE, so a delete-recreate would unlink every bound account."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider.id == original_id
assert provider.client_id == "rotated"
@pytest.mark.asyncio
async def test_removing_the_env_config_disables_but_keeps_the_row(db_session, monkeypatch):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
# Looked up by name, not by the flag: releasing the provider clears the flag,
# and the point of this test is that the ROW survives either way.
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
provider = result.scalar_one_or_none()
assert provider is not None, "deleting would cascade away every account link"
assert provider.id == original_id
assert provider.is_enabled is False
@pytest.mark.asyncio
async def test_env_autologin_clears_it_on_other_providers(db_session, monkeypatch):
"""Only one provider may be the autologin target; the env one wins."""
ui_provider = OIDCProvider(
name="UI provider",
issuer_url="https://other.example.com",
client_id="ui",
is_autologin=True,
)
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
await apply_env_oidc_provider(db_session)
await db_session.refresh(ui_provider)
assert (await _env_provider(db_session)).is_autologin is True
assert ui_provider.is_autologin is False
@pytest.mark.asyncio
async def test_a_ui_provider_is_otherwise_left_alone(db_session, monkeypatch):
ui_provider = OIDCProvider(name="UI provider", issuer_url="https://other.example.com", client_id="ui")
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
await db_session.refresh(ui_provider)
assert ui_provider.is_env_managed is False
assert ui_provider.is_enabled is True
assert ui_provider.client_id == "ui"
@pytest.mark.asyncio
async def test_an_unsafe_auto_link_config_is_skipped_not_raised(db_session, monkeypatch):
"""auto-link + unverified email is the SEC-1 account-takeover shape. The
schema rejects it for the UI, and env config must not be a way around that
-- but a bad variable must not stop the app from booting either."""
_configure(
monkeypatch,
BAMBUDDY_OIDC_AUTO_LINK_EXISTING="true",
BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED="false",
)
await apply_env_oidc_provider(db_session)
assert await _env_provider(db_session) is None
@pytest.mark.asyncio
async def test_a_rejected_config_never_logs_the_client_secret(db_session, monkeypatch, caplog):
"""client_secret has max_length=512, so an over-long value raises
string_too_long. The rejection must be logged without the value: str(exc)
embeds input_value=..., which would leak the secret (no-secrets-in-logs)."""
secret = "S3CR3T" * 100 # > 512 chars -> ValidationError on client_secret
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET=secret)
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
assert await _env_provider(db_session) is None # rejected, not booted-through
assert "rejected" in caplog.text # the rejection was actually logged
assert secret not in caplog.text
assert "S3CR3T" not in caplog.text # not even a fragment of the value
# --- an unrecognized boolean is rejected, not guessed --------------------------
# `_env_bool` used to return the default for anything outside {true,1,yes}, so
# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
# BAMBUDDY_OIDC_ENABLED=on silently disabled the provider. Strict parsing
# refuses the config instead -- through the same clean path a bad
# DEFAULT_GROUP or a ValidationError already uses, so a typo never releases a
# provider that was running fine.
@pytest.mark.asyncio
async def test_an_unrecognized_require_email_verified_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original = await _env_provider(db_session)
original_id, original_enabled = original.id, original.is_enabled
monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "on")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a typo must not release the provider"
assert provider.id == original_id
assert provider.is_enabled == original_enabled
assert provider.is_env_managed is True
assert "rejected" in caplog.text
assert "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED" in caplog.text
@pytest.mark.asyncio
async def test_an_unrecognized_enabled_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original = await _env_provider(db_session)
original_id, original_enabled = original.id, original.is_enabled
monkeypatch.setenv("BAMBUDDY_OIDC_ENABLED", "on")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a typo must not release the provider"
assert provider.id == original_id
assert provider.is_enabled == original_enabled
assert provider.is_env_managed is True
assert "rejected" in caplog.text
assert "BAMBUDDY_OIDC_ENABLED" in caplog.text
@pytest.mark.asyncio
async def test_a_non_validation_error_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
"""The generic except branch handles anything that isn't a ValidationError
(e.g. a library call raising mid-construction). It must not stop boot and,
since such a message could carry a configured value, must log only the
exception class -- never str(exc)."""
# oidc_env imports OIDCProviderCreate inside the function (to avoid an
# import cycle), so patch it at its source module, not on oidc_env.
import backend.app.schemas.auth as auth_schemas
def _raise(**_kwargs):
raise RuntimeError("boom leaked-secret")
monkeypatch.setattr(auth_schemas, "OIDCProviderCreate", _raise)
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session) # must not raise
assert await _env_provider(db_session) is None
assert "could not be applied" in caplog.text
assert "RuntimeError" in caplog.text # class is logged...
assert "leaked-secret" not in caplog.text # ...but nothing from the message
@pytest.mark.asyncio
async def test_a_commit_failure_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
"""The upsert's db.execute/db.commit calls sit outside the inner
ValidationError guard -- a Postgres blip or a SQLite WAL lock at startup
must not propagate out of the lifespan either. Only the exception class
may be logged, never str(exc), since a DB error message can echo a
configured value."""
async def _raise_on_commit():
raise RuntimeError("database is locked")
monkeypatch.setattr(db_session, "commit", _raise_on_commit)
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session) # must not raise
assert "could not be applied" in caplog.text
assert "RuntimeError" in caplog.text # class is logged...
assert "leaked-secret" not in caplog.text # ...but nothing from the message
@pytest.mark.asyncio
async def test_a_failing_rollback_is_also_survivable(db_session, monkeypatch, caplog):
"""The handler rolls back after a failed commit -- but rollback on a wedged
connection can raise too, and 'never raises' has to hold for that as well
or the boot dies on the recovery path. The rollback is suppressed."""
async def _raise_on_commit():
raise RuntimeError("database is locked")
async def _raise_on_rollback():
raise RuntimeError("connection is closed")
monkeypatch.setattr(db_session, "commit", _raise_on_commit)
monkeypatch.setattr(db_session, "rollback", _raise_on_rollback)
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session) # must not raise, even here
assert "could not be applied" in caplog.text
assert "leaked-secret" not in caplog.text
@pytest.mark.asyncio
async def test_applying_twice_without_changes_is_a_no_op(db_session, monkeypatch):
"""Every boot re-applies; the second run must not create a second row."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
await apply_env_oidc_provider(db_session)
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
assert len(result.scalars().all()) == 1
# --- identity is the name, not the flag ---------------------------------------
# The provider is looked up by BAMBUDDY_OIDC_NAME, which is unique on the table.
# Matching on is_env_managed instead made three things impossible: adopting a
# provider that already carries the name (the insert hit the unique constraint
# and took startup down with it), releasing the provider when the config goes
# away, and finding it again afterwards.
@pytest.mark.asyncio
async def test_a_name_collision_adopts_the_existing_provider(db_session, monkeypatch):
"""An operator who names the env provider after one they created in the UI
must not end up with an app that refuses to boot."""
ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
original_id = ui_provider.id
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None
assert provider.id == original_id, "adopted, not duplicated"
assert provider.client_id == "bambuddy"
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
assert len(result.scalars().all()) == 1
@pytest.mark.asyncio
async def test_adopting_a_ui_provider_logs_a_distinct_warning(db_session, monkeypatch, caplog):
"""Overwriting a UI-created provider in place is a bigger deal than a
routine re-apply -- it must not be silent at the same INFO level."""
ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
_configure(monkeypatch)
with caplog.at_level(logging.INFO):
await apply_env_oidc_provider(db_session)
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert any("adopted" in r.message for r in warnings)
@pytest.mark.asyncio
async def test_a_routine_reapply_does_not_log_an_adoption_warning(db_session, monkeypatch, caplog):
"""The same provider re-applying on the next boot is not an adoption --
it was already env-managed."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
caplog.clear()
with caplog.at_level(logging.INFO):
await apply_env_oidc_provider(db_session)
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert not any("adopted" in r.message for r in warnings)
@pytest.mark.asyncio
async def test_removing_the_config_releases_the_provider_to_the_ui(db_session, monkeypatch):
"""Nothing manages it any more, so the API must stop refusing edits and
deletes -- otherwise the row is a dead end only reachable via the database."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
provider = result.scalar_one()
assert provider.is_enabled is False
assert provider.is_env_managed is False
@pytest.mark.asyncio
async def test_restoring_the_config_finds_the_same_row_again(db_session, monkeypatch):
"""The account links hang off this row; a second provider would orphan them."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider.id == original_id
assert provider.is_enabled is True
@pytest.mark.asyncio
async def test_the_issuer_and_client_can_change_under_the_same_name(db_session, monkeypatch):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://sso.example.com/realms/other")
monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider.id == original_id
assert provider.issuer_url == "https://sso.example.com/realms/other"
assert provider.client_id == "rotated"
# --- a rename must not leave the old row managed -------------------------------
# Identity is the name, so renaming BAMBUDDY_OIDC_NAME matches nothing and
# creates a second row. Leaving the flag on the first one is what makes that
# fatal: it stays enabled with a stale issuer and secret on the login page, the
# API refuses every edit/disable/delete on it (409), and the release path's
# scalar_one_or_none() then raises MultipleResultsFound out of the lifespan --
# the app stops booting. Both states are reachable by ordinary config edits.
async def _env_managed(db_session) -> list[OIDCProvider]:
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
return list(result.scalars().all())
@pytest.mark.asyncio
async def test_renaming_the_provider_releases_the_row_it_managed_before(db_session, monkeypatch):
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
await apply_env_oidc_provider(db_session)
old_id = (await _env_provider(db_session)).id
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
managed = await _env_managed(db_session)
assert [p.name for p in managed] == ["Authentik"], "exactly one row may carry the flag"
old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
# Released, not deleted -- user_oidc_links.provider_id cascades.
assert old.is_env_managed is False
assert old.is_enabled is False, "a stale issuer must not stay on the login page"
assert old.is_autologin is False
@pytest.mark.asyncio
async def test_boot_survives_removing_the_config_after_a_rename(db_session, monkeypatch):
"""The MultipleResultsFound path: rename, then unset. Must not raise."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session) # must not raise
assert await _env_managed(db_session) == []
names = (await db_session.execute(select(OIDCProvider.name))).scalars().all()
assert sorted(names) == ["Authentik", "Keycloak"], "both rows survive, both released"
@pytest.mark.asyncio
async def test_every_managed_row_is_released_not_just_one(db_session, monkeypatch):
"""The upsert's sweep should keep this at one row. Should is not enforced by
the schema, and the cost of being wrong is the whole release path raising
MultipleResultsFound out of the lifespan -- so it releases what it finds."""
for name in ("Keycloak", "Authentik"):
stale = OIDCProvider(
name=name,
issuer_url="https://sso.example.com/realms/main",
client_id="bambuddy",
is_env_managed=True,
)
stale.client_secret = "s3cr3t"
db_session.add(stale)
await db_session.commit()
await apply_env_oidc_provider(db_session) # no vars set -> release path
assert await _env_managed(db_session) == []
@pytest.mark.asyncio
async def test_releasing_the_provider_clears_autologin(db_session, monkeypatch):
"""is_enabled and is_env_managed alone leave a UI-editable row carrying a
latent autologin claim: update_oidc_provider only runs the exclusivity
sweep when a request sets is_autologin=True, so merely re-enabling this row
makes it the autologin target again."""
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).is_autologin is True
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
released = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))).scalar_one()
assert released.is_autologin is False
# --- default group by name -----------------------------------------------------
# Group ids are not stable across installs, so a declarative deployment cannot
# name one by id. Without this, every auto-created user falls back to Viewers
# (routes/mfa.py) and the env lock means the UI cannot correct the provider.
async def _group(db_session, name: str):
from backend.app.models.group import Group
group = Group(name=name, description=f"Test group {name}")
db_session.add(group)
await db_session.commit()
return group
@pytest.mark.asyncio
async def test_the_default_group_is_resolved_by_name(db_session, monkeypatch):
group = await _group(db_session, "Operators")
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).default_group_id == group.id
@pytest.mark.asyncio
async def test_an_unknown_group_name_is_rejected_rather_than_defaulted(db_session, monkeypatch, caplog):
"""Silently falling back to Viewers is how a typo mints under-privileged
users for weeks. The API answers 400 for a default_group_id that does not
exist; env config gets the same answer, logged and survivable."""
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Nope")
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
assert await _env_provider(db_session) is None
assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
assert "Nope" in caplog.text
@pytest.mark.asyncio
async def test_an_unknown_group_name_leaves_the_previous_provider_intact(db_session, monkeypatch):
"""Rejection happens before the upsert, so the running config survives a
bad edit -- the provider keeps working until the operator fixes the name."""
group = await _group(db_session, "Operators")
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
await apply_env_oidc_provider(db_session)
monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Typo")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None
assert provider.default_group_id == group.id
@pytest.mark.asyncio
async def test_no_group_variable_leaves_the_default_group_unset(db_session, monkeypatch):
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).default_group_id is None
@pytest.mark.asyncio
async def test_removing_the_group_variable_clears_the_default_group(db_session, monkeypatch):
"""The environment is the whole truth for this row; a group that is no
longer declared must not linger, since the lock blocks removing it in the UI."""
await _group(db_session, "Operators")
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
await apply_env_oidc_provider(db_session)
monkeypatch.delenv("BAMBUDDY_OIDC_DEFAULT_GROUP")
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).default_group_id is None
@pytest.mark.asyncio
async def test_an_empty_group_variable_counts_as_unset(db_session, monkeypatch):
"""Same rule the required vars follow: an empty value in a compose file is
a forgotten value, not a request to reject the config."""
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None
assert provider.default_group_id is None
# --- blank optional strings count as unset, not a refusal ---------------------
# `.env.example` ships `# BAMBUDDY_OIDC_ICON_URL=` commented out, so uncommenting
# it must not take the provider down -- same rule default_group already follows.
@pytest.mark.asyncio
async def test_a_blank_scopes_still_creates_the_provider(db_session, monkeypatch):
_configure(monkeypatch, BAMBUDDY_OIDC_SCOPES="")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a blank optional var must not refuse the whole provider"
assert provider.scopes == "openid email profile"
@pytest.mark.asyncio
async def test_a_blank_email_claim_still_creates_the_provider(db_session, monkeypatch):
_configure(monkeypatch, BAMBUDDY_OIDC_EMAIL_CLAIM="")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a blank optional var must not refuse the whole provider"
assert provider.email_claim == "email"
@pytest.mark.asyncio
async def test_a_blank_icon_url_still_creates_the_provider(db_session, monkeypatch):
_configure(monkeypatch, BAMBUDDY_OIDC_ICON_URL="")
await apply_env_oidc_provider(db_session)
provider = await _env_provider(db_session)
assert provider is not None, "a blank optional var must not refuse the whole provider"
assert provider.icon_url is None
# --- account links and collision behavior ------------------------------------
@pytest.mark.asyncio
async def test_renaming_to_match_a_ui_provider_adopts_it_and_releases_the_old_row(db_session, monkeypatch):
"""New name collides with existing UI provider: env config adopts that row,
old env-managed row is released. Identity is the name, so the collision is
resolved by matching the new name against the table."""
# Start with env-managed "Keycloak"
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
old_id = (await _env_provider(db_session)).id
# Add a UI provider named "Authentik"
ui_provider = OIDCProvider(name="Authentik", issuer_url="https://auth.example.com", client_id="ui-client")
ui_provider.client_secret = "ui-secret"
db_session.add(ui_provider)
await db_session.commit()
ui_id = ui_provider.id
# Rename env provider to "Authentik" — matches the UI provider
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
# The UI provider is adopted and becomes env-managed
provider = await _env_provider(db_session)
assert provider.id == ui_id, "adopted the UI provider"
assert provider.name == "Authentik"
assert provider.client_id == "bambuddy" # updated from env
assert provider.is_env_managed is True
# The old Keycloak row is released
old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
assert old.name == "Keycloak"
assert old.is_env_managed is False
assert old.is_enabled is False
@pytest.mark.asyncio
async def test_account_links_survive_a_provider_rename(db_session, monkeypatch):
"""The provider row is never deleted, only updated: user_oidc_links FK
ON DELETE CASCADE must not be triggered by a rename."""
from backend.app.models.oidc_provider import UserOIDCLink
from backend.app.models.user import User
# Create a user and link it to the env-managed provider
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
provider_id = (await _env_provider(db_session)).id
user = User(username="testuser", email="test@example.com")
db_session.add(user)
await db_session.flush()
link = UserOIDCLink(
user_id=user.id,
provider_id=provider_id,
provider_user_id="oidc-sub-12345",
provider_email="test@idp.example.com",
)
db_session.add(link)
await db_session.commit()
# Rename the env provider
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
# The link still exists, pointing to the old row (which is now released)
result = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_id == provider_id))
links = result.scalars().all()
assert len(links) == 1
assert links[0].provider_user_id == "oidc-sub-12345"
@pytest.mark.asyncio
async def test_renaming_with_autologin_updates_the_exclusivity_sweep(db_session, monkeypatch):
"""When renamed env config has autologin=true, the sweep clears autologin
from other rows. The old row is released (autologin cleared there too)."""
# Setup: env provider "Keycloak" with autologin
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
await apply_env_oidc_provider(db_session)
old_id = (await _env_provider(db_session)).id
assert (await _env_provider(db_session)).is_autologin is True
# Another UI provider also has autologin
ui_provider = OIDCProvider(name="UI", issuer_url="https://ui.example.com", client_id="ui")
ui_provider.client_secret = "secret"
ui_provider.is_autologin = True
db_session.add(ui_provider)
await db_session.commit()
# Rename env provider to "Authentik" with autologin=true
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
# New row is the autologin target
new_provider = await _env_provider(db_session)
assert new_provider.name == "Authentik"
assert new_provider.is_autologin is True
# Old row is released and autologin cleared
old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
assert old.is_env_managed is False
assert old.is_autologin is False
# UI provider autologin is cleared (only env-managed can be autologin now)
await db_session.refresh(ui_provider)
assert ui_provider.is_autologin is False
@pytest.mark.asyncio
async def test_group_name_matching_is_case_sensitive(db_session, monkeypatch, caplog):
"""Group name is resolved by exact match; 'operators' != 'Operators'."""
await _group(db_session, "Operators") # capital O
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="operators") # lowercase
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
# Config is rejected
assert await _env_provider(db_session) is None
assert "operators" in caplog.text
assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
@pytest.mark.asyncio
async def test_group_name_rejection_does_not_log_the_secret(db_session, monkeypatch, caplog):
"""Group resolution happens before schema validation, so the secret is
not yet in scope, but verify it's not leaked by the error path."""
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="NonExistent")
secret = os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"]
with caplog.at_level(logging.ERROR):
await apply_env_oidc_provider(db_session)
# Config is rejected but secret is safe
assert await _env_provider(db_session) is None
assert secret not in caplog.text
@pytest.mark.asyncio
async def test_restoring_env_config_after_rename_then_unset_finds_the_original_row(db_session, monkeypatch):
"""Rename Keycloak → Authentik, unset everything, restore Keycloak.
Must re-enable the original row, not create a new one."""
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
original_id = (await _env_provider(db_session)).id
# Rename to Authentik
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
await apply_env_oidc_provider(db_session)
assert (await _env_provider(db_session)).name == "Authentik"
# Unset everything
for key in ALL_VARS:
monkeypatch.delenv(key, raising=False)
await apply_env_oidc_provider(db_session)
# Restore the original Keycloak config
_configure(monkeypatch)
await apply_env_oidc_provider(db_session)
# Same row, re-enabled
provider = await _env_provider(db_session)
assert provider.id == original_id
assert provider.name == "Keycloak"
assert provider.is_enabled is True
assert provider.is_env_managed is True
@@ -0,0 +1,137 @@
"""The env-managed provider is read-only through the API (#2593).
Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so a UI edit
would silently disappear at the next restart -- the operator would see their
change accepted and then reverted, with nothing explaining why. Refusing the
write is the honest answer.
Locking it out is safe because BAMBUDDY_LOCAL_LOGIN (#1589) is the documented
recovery path if the provider itself becomes unusable.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from backend.app.models.oidc_provider import OIDCProvider
from backend.tests.integration.test_mfa_api import _auth_header, _setup_and_login
async def _env_managed_provider(db_session) -> int:
provider = OIDCProvider(
name="Env Keycloak",
issuer_url="https://sso.example.com/realms/main",
client_id="bambuddy",
icon_url="https://sso.example.com/logo.png",
is_env_managed=True,
)
provider.client_secret = "s3cr3t"
db_session.add(provider)
await db_session.commit()
await db_session.refresh(provider)
return provider.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_put_is_refused(async_client: AsyncClient, db_session):
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockput", "envlockput123")
response = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"name": "hijacked"},
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_is_refused(async_client: AsyncClient, db_session):
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockdel", "envlockdel123")
response = await async_client.delete(
f"/api/v1/auth/oidc/providers/{provider_id}",
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_icon_delete_is_refused(async_client: AsyncClient, db_session):
"""The icon is part of the env config too -- BAMBUDDY_OIDC_ICON_URL."""
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockicondel", "envlockicondel123")
response = await async_client.delete(
f"/api/v1/auth/oidc/providers/{provider_id}/icon",
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_icon_refresh_is_refused(async_client: AsyncClient, db_session):
provider_id = await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockiconref", "envlockiconref123")
response = await async_client.post(
f"/api/v1/auth/oidc/providers/{provider_id}/icon/refresh",
headers=_auth_header(token),
)
assert response.status_code == 409
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_ui_provider_is_still_editable(async_client: AsyncClient):
"""The lock must not leak onto providers the operator created themselves --
they coexist with the env one and stay fully editable."""
token = await _setup_and_login(async_client, "envlockui", "envlockui123")
created = await async_client.post(
"/api/v1/auth/oidc/providers",
json={
"name": "UI provider",
"issuer_url": "https://other.example.com",
"client_id": "ui",
"client_secret": "ui-secret",
"scopes": "openid",
"is_enabled": True,
"auto_create_users": False,
},
headers=_auth_header(token),
)
provider_id = created.json()["id"]
response = await async_client.put(
f"/api/v1/auth/oidc/providers/{provider_id}",
json={"name": "Renamed"},
headers=_auth_header(token),
)
assert response.status_code == 200
assert response.json()["name"] == "Renamed"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_response_says_which_provider_is_env_managed(async_client: AsyncClient, db_session):
"""The frontend needs this to render the lock; without it the UI would show
editable fields whose writes the API then refuses."""
await _env_managed_provider(db_session)
token = await _setup_and_login(async_client, "envlockflag", "envlockflag123")
response = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
assert response.status_code == 200
providers = response.json()
assert any(p["is_env_managed"] for p in providers)
@@ -0,0 +1,43 @@
"""The env provider is applied on startup, not merely appliable (#2593).
test_oidc_env_apply.py calls apply_env_oidc_provider() directly, so it stays
green even if nothing ever calls it -- deleting the lifespan call would leave
the feature dead with a fully passing suite. These tests pin the call site.
They read the lifespan's source rather than running it: the function is ~460
lines and starts printer connections, MQTT and schedulers, so executing it
here would test everything except the one line in question. That makes this a
wiring check, not a behavioural one -- it proves the call exists and runs
after migrations, and deliberately proves nothing about what it does. The
behaviour is covered by test_oidc_env_apply.py.
"""
from __future__ import annotations
import inspect
from backend.app.main import lifespan
def _lifespan_source() -> str:
return inspect.getsource(lifespan)
def test_lifespan_applies_the_env_oidc_provider():
assert "apply_env_oidc_provider(" in _lifespan_source()
def test_it_runs_after_the_migrations():
"""is_env_managed does not exist until run_migrations has added it, so an
upsert before init_db() would fail on every existing installation."""
source = _lifespan_source()
assert source.index("await init_db()") < source.index("apply_env_oidc_provider(")
def test_the_apply_call_is_awaited():
"""apply_env_oidc_provider is a coroutine; calling it without await would
return an un-awaited coroutine and silently apply nothing."""
source = _lifespan_source()
call = source.index("apply_env_oidc_provider(")
line_start = source.rindex("\n", 0, call) + 1
assert source[line_start:call].strip().endswith("await")
@@ -0,0 +1,126 @@
"""The is_env_managed column has to reach databases that already exist (#2593).
The model test covers a table freshly created from metadata, which is not how
an upgrade arrives: an installed instance has an oidc_providers table without
the column, and only run_migrations adds it there. Every boot re-runs the whole
migration set, so adding it twice must be a no-op rather than an error.
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from backend.app.core.database import Base, run_migrations
def _register_all_models():
from backend.app.models import ( # noqa: F401
ams_history,
ams_label,
api_key,
archive,
color_catalog,
external_link,
filament,
group,
kprofile_note,
library,
maintenance,
notification,
notification_template,
print_log,
print_queue,
printer,
project,
project_bom,
settings,
slot_preset,
smart_plug,
smart_plug_energy_snapshot,
spool,
spool_assignment,
spool_catalog,
spool_k_profile,
spool_usage_history,
spoolbuddy_device,
user,
user_email_pref,
virtual_printer,
)
@pytest.fixture(autouse=True)
def force_sqlite_dialect(monkeypatch):
"""Force the SQLite branch regardless of test env settings."""
from backend.app.core import database as database_module, db_dialect
monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
@pytest.fixture
async def engine():
"""A database as it stands before this change: every table created from the
models, then the new column dropped again -- the model already declares it,
so only removing it reproduces what an installed instance actually has."""
_register_all_models()
eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.execute(text("ALTER TABLE oidc_providers DROP COLUMN is_env_managed"))
yield eng
await eng.dispose()
async def _columns(conn) -> set[str]:
rows = await conn.execute(text("PRAGMA table_info(oidc_providers)"))
return {r[1] for r in rows}
@pytest.mark.asyncio
async def test_migration_adds_the_column_to_an_existing_table(engine):
async with engine.connect() as conn:
assert "is_env_managed" not in await _columns(conn)
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert "is_env_managed" in await _columns(conn)
@pytest.mark.asyncio
async def test_existing_rows_default_to_not_env_managed(engine):
"""A provider created through the UI before the upgrade must not come back
locked -- is_env_managed decides whether the API refuses to edit it."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO oidc_providers"
" (id, name, issuer_url, client_id, client_secret, scopes, is_enabled,"
" auto_create_users, auto_link_existing_accounts, email_claim,"
" require_email_verified)"
" VALUES (1, 'UI provider', 'https://sso.example', 'app', 'enc',"
" 'openid email profile', 1, 0, 0, 'email', 1)"
)
)
await run_migrations(conn)
async with engine.connect() as conn:
row = await conn.execute(text("SELECT is_env_managed FROM oidc_providers WHERE id = 1"))
assert not row.scalar()
@pytest.mark.asyncio
async def test_it_is_idempotent(engine):
"""Every boot re-runs the migration set."""
for _ in range(2):
async with engine.begin() as conn:
await run_migrations(conn)
async with engine.connect() as conn:
assert "is_env_managed" in await _columns(conn)
@@ -0,0 +1,12 @@
import pytest
from backend.app.models.oidc_provider import OIDCProvider
@pytest.mark.asyncio
async def test_is_env_managed_defaults_false(db_session):
p = OIDCProvider(name="x", issuer_url="https://i", client_id="c", client_secret="s")
db_session.add(p)
await db_session.commit()
await db_session.refresh(p)
assert p.is_env_managed is False
+248
View File
@@ -0,0 +1,248 @@
"""BAMBUDDY_OIDC_* reader (#2593).
The reader is deliberately dumb: it maps env vars to field names and applies
defaults. Whether the resulting provider is *valid* is decided later, by the
same OIDCProviderCreate schema the API uses, so env config cannot bypass a
check the UI enforces.
"""
from __future__ import annotations
import pytest
from backend.app.core.oidc_env import EnvOIDCConfigError, env_bool, read_env_oidc_config
REQUIRED = {
"BAMBUDDY_OIDC_NAME": "Keycloak",
"BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
"BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
"BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
}
OPTIONAL = (
"BAMBUDDY_OIDC_SCOPES",
"BAMBUDDY_OIDC_ENABLED",
"BAMBUDDY_OIDC_AUTO_CREATE_USERS",
"BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
"BAMBUDDY_OIDC_EMAIL_CLAIM",
"BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
"BAMBUDDY_OIDC_ICON_URL",
"BAMBUDDY_OIDC_AUTOLOGIN",
"BAMBUDDY_OIDC_DEFAULT_GROUP",
)
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
for key in (*REQUIRED, *OPTIONAL):
monkeypatch.delenv(key, raising=False)
def _set_required(monkeypatch):
for key, value in REQUIRED.items():
monkeypatch.setenv(key, value)
def test_returns_none_when_nothing_is_configured():
assert read_env_oidc_config() is None
@pytest.mark.parametrize("missing", sorted(REQUIRED))
def test_returns_none_when_any_single_required_var_is_missing(monkeypatch, missing):
"""All four or nothing -- a half-configured provider must not reach the
database, where it would fail at authorize time instead of at startup."""
_set_required(monkeypatch)
monkeypatch.delenv(missing)
assert read_env_oidc_config() is None
@pytest.mark.parametrize("raw", ["", " ", "\n", " \t\n "])
@pytest.mark.parametrize("key", sorted(REQUIRED))
def test_an_empty_required_var_counts_as_unset(monkeypatch, key, raw):
"""`BAMBUDDY_OIDC_CLIENT_SECRET=` in a compose file is a forgotten value,
not an intentional empty secret -- and neither is one holding only
whitespace, which the optional vars have always treated as unset."""
_set_required(monkeypatch)
monkeypatch.setenv(key, raw)
assert read_env_oidc_config() is None
@pytest.mark.parametrize("key", sorted(REQUIRED))
def test_a_required_var_is_stripped(monkeypatch, key):
"""A Kubernetes Secret written as a block scalar carries a trailing
newline, and the schema bounds these four by max_length only -- so an
unstripped issuer_url reaches the database, enables the SSO button and
then raises httpx.InvalidURL on the first click, long after startup could
have refused it."""
_set_required(monkeypatch)
monkeypatch.setenv(key, f" {REQUIRED[key]}\n")
cfg = read_env_oidc_config()
field = {
"BAMBUDDY_OIDC_NAME": "name",
"BAMBUDDY_OIDC_ISSUER_URL": "issuer_url",
"BAMBUDDY_OIDC_CLIENT_ID": "client_id",
"BAMBUDDY_OIDC_CLIENT_SECRET": "client_secret",
}[key]
assert cfg[field] == REQUIRED[key]
def test_reads_the_required_vars(monkeypatch):
_set_required(monkeypatch)
cfg = read_env_oidc_config()
assert cfg["name"] == "Keycloak"
assert cfg["issuer_url"] == "https://sso.example.com/realms/main"
assert cfg["client_id"] == "bambuddy"
assert cfg["client_secret"] == "s3cr3t"
def test_applies_the_documented_defaults(monkeypatch):
_set_required(monkeypatch)
cfg = read_env_oidc_config()
assert cfg["scopes"] == "openid email profile"
assert cfg["is_enabled"] is True
assert cfg["auto_create_users"] is False
assert cfg["auto_link_existing_accounts"] is False
assert cfg["email_claim"] == "email"
assert cfg["require_email_verified"] is True
assert cfg["icon_url"] is None
assert cfg["is_autologin"] is False
@pytest.mark.parametrize("raw", ["true", "TRUE", "True", "1", "yes", "YES", " yes "])
def test_booleans_accept_the_project_truthy_spellings(monkeypatch, raw):
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
assert read_env_oidc_config()["auto_create_users"] is True
@pytest.mark.parametrize("raw", ["false", "FALSE", "False", "0", "no", "NO"])
def test_falsy_values_are_false(monkeypatch, raw):
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
assert read_env_oidc_config()["auto_create_users"] is False
@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
def test_an_unrecognized_boolean_is_rejected(monkeypatch, raw):
"""Only the documented spellings are accepted; an unrecognised value must
not silently turn a flag on or off -- it must refuse the whole config
instead of guessing (M-R4 strict boolean parsing)."""
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
with pytest.raises(EnvOIDCConfigError, match="BAMBUDDY_OIDC_AUTO_CREATE_USERS"):
read_env_oidc_config()
def test_a_boolean_default_of_true_can_be_turned_off(monkeypatch):
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "false")
assert read_env_oidc_config()["require_email_verified"] is False
# --- env_bool, tested directly ------------------------------------------------
# The reader-level tests above pin the contract through read_env_oidc_config;
# these exercise the helper itself so its default/blank/reject behavior is
# proven independently of any particular BAMBUDDY_OIDC_* field.
@pytest.mark.parametrize("raw", ["false", "FALSE", "0", "no", "NO"])
def test_env_bool_falsy_values_are_false(monkeypatch, raw):
monkeypatch.setenv("SOME_FLAG", raw)
assert env_bool("SOME_FLAG", True) is False
@pytest.mark.parametrize("default", [True, False])
def test_env_bool_absent_is_the_given_default(monkeypatch, default):
monkeypatch.delenv("SOME_FLAG", raising=False)
assert env_bool("SOME_FLAG", default) is default
@pytest.mark.parametrize("raw", ["", " "])
@pytest.mark.parametrize("default", [True, False])
def test_env_bool_blank_is_the_given_default(monkeypatch, raw, default):
monkeypatch.setenv("SOME_FLAG", raw)
assert env_bool("SOME_FLAG", default) is default
@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
def test_env_bool_rejects_an_unrecognized_value(monkeypatch, raw):
monkeypatch.setenv("SOME_FLAG", raw)
with pytest.raises(EnvOIDCConfigError, match="SOME_FLAG"):
env_bool("SOME_FLAG", True)
@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
@pytest.mark.parametrize("default", [True, False])
def test_env_bool_lenient_falls_back_to_default_on_unrecognized(monkeypatch, raw, default):
"""strict=False (the request-path callers like BAMBUDDY_LOCAL_LOGIN): an
unrecognized value must return the default, never raise -- a raise there
would 500 a live endpoint rather than skip a startup config."""
monkeypatch.setenv("SOME_FLAG", raw)
assert env_bool("SOME_FLAG", default, strict=False) is default
def test_optional_strings_override_their_defaults(monkeypatch):
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", "openid profile groups")
monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", "mail")
monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", "https://sso.example.com/logo.png")
cfg = read_env_oidc_config()
assert cfg["scopes"] == "openid profile groups"
assert cfg["email_claim"] == "mail"
assert cfg["icon_url"] == "https://sso.example.com/logo.png"
@pytest.mark.parametrize("raw", ["", " "])
def test_a_blank_scopes_is_unset(monkeypatch, raw):
"""`BAMBUDDY_OIDC_SCOPES=` in a compose file is a forgotten value, not a
request for a provider with no scopes -- same rule as default_group."""
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", raw)
assert read_env_oidc_config()["scopes"] == "openid email profile"
@pytest.mark.parametrize("raw", ["", " "])
def test_a_blank_email_claim_is_unset(monkeypatch, raw):
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", raw)
assert read_env_oidc_config()["email_claim"] == "email"
@pytest.mark.parametrize("raw", ["", " "])
def test_a_blank_icon_url_is_unset(monkeypatch, raw):
"""Uncommenting `# BAMBUDDY_OIDC_ICON_URL=` in .env.example must not take
the provider down -- the reader must still return a config, not refuse it."""
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", raw)
cfg = read_env_oidc_config()
assert cfg is not None, "a blank optional var must not refuse the whole provider"
assert cfg["icon_url"] is None
def test_the_default_group_is_read_as_a_name(monkeypatch):
"""A name, not an id: group ids differ per install, so an id in a compose
file would point at whatever group happened to be created third."""
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Operators")
cfg = read_env_oidc_config()
assert cfg["default_group"] == "Operators"
assert "default_group_id" not in cfg, "resolution needs the database, not the reader"
@pytest.mark.parametrize("raw", ["", " "])
def test_a_blank_default_group_is_unset(monkeypatch, raw):
_set_required(monkeypatch)
monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", raw)
assert read_env_oidc_config()["default_group"] is None
def test_every_var_the_reader_knows_is_registered_in_the_typo_guard():
"""An unregistered BAMBUDDY_* var logs "possible typo" at every boot, which
would tell operators their correct config is wrong. Asserted against the
reader's own vars rather than a copied list, so a var added later is caught
here instead of in someone's logs."""
from backend.app.core.config import _INTENTIONAL_UNSETTINGS
unregistered = {v for v in (*REQUIRED, *OPTIONAL) if v not in _INTENTIONAL_UNSETTINGS}
assert not unregistered
@@ -325,3 +325,94 @@ describe('OIDCProviderSettings', () => {
});
});
});
describe('env-managed provider (#2593)', () => {
const envManagedProvider = {
...mockProviders[0],
id: 2,
name: 'EnvIdP',
is_env_managed: true,
};
it('marks the provider as environment managed', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([envManagedProvider]))
);
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('EnvIdP')).toBeInTheDocument();
});
expect(screen.getByText(/Environment Managed/i)).toBeInTheDocument();
});
it('offers no edit or delete control for it', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([envManagedProvider]))
);
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('EnvIdP')).toBeInTheDocument();
});
// Startup rewrites this row from the environment on every boot, and the API
// answers 409 — offering the controls would promise an edit that cannot land.
expect(screen.queryByTestId('edit-provider-2')).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-provider-2')).not.toBeInTheDocument();
});
it('offers no icon controls for it either', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () =>
HttpResponse.json([
{ ...envManagedProvider, icon_url: 'https://idp.example.com/i.png', has_icon: true },
])
)
);
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('EnvIdP')).toBeInTheDocument();
});
// Both icon routes answer 409 for an env-managed provider, so a click could
// only ever produce an error toast — the same reason the rest are hidden.
expect(screen.queryByTestId('refresh-icon-2')).not.toBeInTheDocument();
expect(screen.queryByTestId('remove-icon-2')).not.toBeInTheDocument();
});
it('still offers them for a UI-created provider', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () =>
HttpResponse.json([{ ...mockProviders[0], is_env_managed: false }])
)
);
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('TestIdP')).toBeInTheDocument();
});
expect(screen.getByTestId('edit-provider-1')).toBeInTheDocument();
expect(screen.getByTestId('delete-provider-1')).toBeInTheDocument();
});
it('hides the enable/disable toggle for env-managed providers', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () =>
HttpResponse.json([
{ ...mockProviders[0], id: 2, name: 'EnvIdP', is_enabled: true, is_env_managed: true },
{ ...mockProviders[0], id: 3, name: 'UiIdP', is_enabled: true, is_env_managed: false },
])
)
);
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('EnvIdP')).toBeInTheDocument();
expect(screen.getByText('UiIdP')).toBeInTheDocument();
});
// The toggle carries no testid, so it is counted: two cards are rendered and
// exactly one switch may exist — the UI provider's. Enabling the env-managed
// one would be reverted by the next boot, and the API answers 409.
expect(screen.getAllByRole('switch')).toHaveLength(1);
});
});
+5
View File
@@ -3548,6 +3548,11 @@ export interface OIDCProvider {
// #1589: when true, the LoginPage redirects unauthenticated visitors
// straight to this provider on mount. At most one provider may carry this.
is_autologin: boolean;
// #2593: defined by BAMBUDDY_OIDC_* and rewritten from the environment on
// every boot. The API answers 409 to any write, so the settings UI must not
// offer edit/delete controls that cannot succeed. Optional so a response
// from an older backend still type-checks.
is_env_managed?: boolean;
}
export interface OIDCProviderCreate {
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff } from 'lucide-react';
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff, Lock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { Group, OIDCProvider, OIDCProviderCreate } from '../api/client';
@@ -386,6 +386,11 @@ export function OIDCProviderSettings() {
<X className="w-3 h-3" /> {t('common.disabled')}
</span>
)}
{provider.is_env_managed && (
<span className="flex items-center gap-1 text-xs text-bambu-green">
<Lock className="w-3 h-3" /> {t('settings.environmentManagedLabel')}
</span>
)}
</div>
<div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
<ExternalLink className="w-3 h-3" />
@@ -393,45 +398,59 @@ export function OIDCProviderSettings() {
</div>
</div>
<div className="flex items-center gap-2">
{provider.icon_url && (
<Button
variant="secondary"
size="sm"
onClick={() => refreshIconMutation.mutate(provider.id)}
disabled={refreshIconMutation.isPending}
title={t('settings.oidc.refreshIcon')}
data-testid={`refresh-icon-${provider.id}`}
>
<RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
</Button>
{/* #2593: startup rewrites the env-managed row from BAMBUDDY_OIDC_*
and the API answers 409, so offering any of these would promise
a change that cannot land -- the icon routes included, where the
click only ever produced an error toast. */}
{!provider.is_env_managed && (
<>
{provider.icon_url && (
<Button
variant="secondary"
size="sm"
onClick={() => refreshIconMutation.mutate(provider.id)}
disabled={refreshIconMutation.isPending}
title={t('settings.oidc.refreshIcon')}
data-testid={`refresh-icon-${provider.id}`}
>
<RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
</Button>
)}
{provider.has_icon && (
<Button
variant="secondary"
size="sm"
onClick={() => removeIconMutation.mutate(provider.id)}
disabled={removeIconMutation.isPending}
title={t('settings.oidc.removeIcon')}
data-testid={`remove-icon-${provider.id}`}
>
<ImageOff className="w-4 h-4" />
</Button>
)}
<Toggle
checked={provider.is_enabled}
onChange={() => toggleEnabled(provider)}
disabled={updateMutation.isPending}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
data-testid={`edit-provider-${provider.id}`}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setDeleteTarget(provider)}
data-testid={`delete-provider-${provider.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</>
)}
{provider.has_icon && (
<Button
variant="secondary"
size="sm"
onClick={() => removeIconMutation.mutate(provider.id)}
disabled={removeIconMutation.isPending}
title={t('settings.oidc.removeIcon')}
data-testid={`remove-icon-${provider.id}`}
>
<ImageOff className="w-4 h-4" />
</Button>
)}
<Toggle
checked={provider.is_enabled}
onChange={() => toggleEnabled(provider)}
disabled={updateMutation.isPending}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CbDmTKuP.js"></script>
<script type="module" crossorigin src="/assets/index-CCCWDEkl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
</head>
<body>