23 Commits
Author SHA1 Message Date
MarianandClaude Opus 4.8 9e783fbad6 fix(oidc): keep the local-login bypass lenient under strict env_bool
Promoting env_bool to strict rejection made BAMBUDDY_LOCAL_LOGIN=on raise
EnvOIDCConfigError uncaught on the login/forgot-password path -- a 500 on
the exact recovery endpoint the bypass exists to keep open. env_bool gains
a strict flag (default True for the startup OIDC reader); the local-login
caller opts out so an unrecognized value falls back to "off" instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q8EAf9Rj7ZHL92sPnXYxy
2026-08-01 09:17:33 +00:00
Marian 77c9bdd694 fix(oidc): reject an unrecognized boolean instead of guessing
_env_bool returned 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 -- the exact
opposite of what .env.example claimed. Unrecognized values now raise
EnvOIDCConfigError, caught in _apply_env_oidc_provider the same way a
bad DEFAULT_GROUP or a ValidationError already is: logged and left
running, never released on a typo.

Also promotes _env_bool to env_bool now that it has a call site in
auth.py, and corrects the boolean-parsing sentence in .env.example.
2026-08-01 09:17:33 +00:00
Marian c547505c64 fix(oidc): treat a blank optional env var as unset, not a refusal
BAMBUDDY_OIDC_SCOPES, _EMAIL_CLAIM and _ICON_URL fell back to their
default only when the key was absent, so `BAMBUDDY_OIDC_ICON_URL=` in
a compose file (as .env.example ships it, commented) reached the
schema validator as an empty string and got the whole provider
refused. default_group already treated blank as unset; these three
now follow the same rule.
2026-08-01 09:17:33 +00:00
MarianandClaude Opus 4.8 6eea61dc78 fix(oidc): survive a failing rollback in the never-raise handler too
The recovery rollback after a failed commit was itself unguarded, so a
rollback that raises on a wedged connection would still take the boot
down -- the exact failure the never-raise contract exists to prevent.
Suppressed; the caller's `async with` discards the session regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016q8EAf9Rj7ZHL92sPnXYxy
2026-07-31 13:18:21 +00:00
Marian be19461470 refactor(auth): reuse oidc_env's truthy-bool helper for BAMBUDDY_LOCAL_LOGIN
_local_login_env_bypass() re-inlined the same {"true", "1", "yes"} set
oidc_env._env_bool already enforces, so the typo-guard existed in two
places. oidc_env has no module-scope import of models (its model
imports are lazy inside apply_env_oidc_provider), so importing
_env_bool at module scope here is not a cycle -- confirmed by
importing backend.app.main and this module directly.
2026-07-31 13:12:44 +00:00
Marian f6e0d76731 docs(oidc): document issuer URL policy and name-collision adoption
.env.example described what the required vars do but not two sharp
edges: the issuer must be a public HTTPS URL (an in-cluster
http://keycloak:8080 is silently refused), and BAMBUDDY_OIDC_NAME
matches an existing UI-created provider by name and takes it over.
2026-07-31 13:11:41 +00:00
Marian c4b5d42f48 fix(oidc): log distinctly when env config adopts a UI-created provider
A name collision with a provider that was NOT already env-managed
overwrites its issuer, client id and secret in place and locks it
behind the env-managed 409 -- but it logged the same routine "applied"
line as an ordinary re-apply, giving no signal a UI provider was just
taken over. Adoption is now a WARNING with its own wording; a routine
re-apply of an already env-managed provider keeps the INFO line.
2026-07-31 13:11:15 +00:00
Marian 6dff1e9644 fix(oidc): make apply_env_oidc_provider never raise on DB errors
The db.execute/db.commit calls in the upsert and release paths sat
outside the try/except that only wrapped OIDCProviderCreate, so a
commit failure at startup (connection blip, WAL lock) propagated out
of the lifespan and took the instance down -- the exact outcome this
module exists to avoid. The body now runs inside a private
_apply_env_oidc_provider(), with the public entry point catching,
logging and rolling back on any exception.
2026-07-31 13:10:14 +00:00
Marian ed29319cf9 docs(oidc): drop the upgrade-path claim from the release-path rationale
The two-flagged-row state was never released, so no installation can carry it
in -- the reason to release every flagged row is not repair, it is that the
sweep's invariant is not enforced anywhere and scalar_one_or_none() turns a
broken one into a failed boot. Comment, test docstring and test name say that
instead.
2026-07-30 14:44:49 +00:00
Marian 3c679459e1 feat(oidc): set the default group from the environment, by name
Without it every account auto-created through the env provider fell back to
Viewers (routes/mfa.py), and because the provider is locked the UI could not
correct it either -- a real limitation for a declarative deployment running
BAMBUDDY_OIDC_AUTO_CREATE_USERS=true.

BAMBUDDY_OIDC_DEFAULT_GROUP names a group rather than an id: ids are handed out
per installation, so the same compose file would point at a different group on
the next deployment. The name is matched exactly, resolved against the database
before anything is written, and default_group_id joins _APPLIED_FIELDS so
dropping the variable clears the group again -- the environment is the whole
truth for this row.

A name that matches no group is refused rather than defaulted: silently landing
users in Viewers is the failure this variable exists to remove, and the API
already answers 422 for a default_group_id that does not exist. The refusal is
logged and survivable, and it says which of the two cases happened, because
they differ sharply -- an existing provider keeps running on its last good
config, while on a first boot nothing is created and no SSO button appears.

Raised by maziggy in review of #2625 as a scope decision; documented in
.env.example and in the companion wiki PR.
2026-07-30 14:43:23 +00:00
Marian ac2f4d058e fix(oidc): hide the icon buttons on the env-managed provider too
refresh-icon and remove-icon rendered outside the !provider.is_env_managed
block, and both routes answer 409 for that provider -- so a click could only
ever produce an error toast, which is the reason the comment right below them
gives for hiding everything else. Its icon comes from BAMBUDDY_OIDC_ICON_URL
and is re-applied on every boot.

Reported by maziggy in review of #2625.
2026-07-30 14:43:23 +00:00
Marian d64f5d9651 fix(oidc): release the row the env config managed before a rename
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.
2026-07-30 14:43:23 +00:00
MarianandClaude Opus 4.8 1116b43fbd fix(oidc): never log the client_secret when env config is rejected
apply_env_oidc_provider logged the raw Pydantic exception on rejection.
client_secret has max_length=512, so a longer value raises string_too_long
and str(exc) embeds input_value=..., leaking BAMBUDDY_OIDC_CLIENT_SECRET into
the logs (maziggy review, PR #2625).

Split the catch: ValidationError logs errors(include_input=False), which
strips submitted values; any other exception logs only its class name, never
str(exc). Rejection stays survivable — a bad config is still skipped and the
app still boots.

Adds two regression tests: an over-long secret is rejected without the value
reaching the log, and a non-ValidationError is survived without leaking its
message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 21:05:29 +00:00
Marian c163b3524a fix(oidc): identify the env provider by name, and release it when unconfigured
Two problems, both from using is_env_managed as the provider's identity.

An operator who names the env provider after one that already exists hit the
unique constraint on `name` during the insert. That happens inside the
lifespan, so the app did not boot -- from a function whose docstring promises
it never raises. The lookup now matches on the name, which is unique, so an
existing provider is adopted and updated instead of duplicated.

And removing the config left the row disabled but still flagged, so the API
went on refusing every edit and delete while nothing managed it any more: a
dead end reachable only through the database. The flag is now cleared as well,
handing the provider back to the UI. Re-adding the config finds the same row
by name, so the account links it carries survive the round trip.

Falls out of the same change: the issuer URL and client id can be rotated
under an unchanged name without orphaning those links.

Found by Marian asking what happens when you want to change the provider --
the answer was "you cannot, ever again".

Refs #2593
2026-07-28 21:05:29 +00:00
Marian f7c5efc7b2 docs: document the BAMBUDDY_OIDC_* variables in .env.example
Follows the BAMBUDDY_LOCAL_LOGIN block's style: what it is for, when it
activates, and the two things an operator cannot guess from the variable names
-- that removing the config disables rather than deletes the provider, because
deleting would permanently drop every account link, and that the UI shows it
read-only because startup would overwrite an edit anyway.

Also states why auto-link is refused without verified emails, since that is
the one setting that will be silently skipped if someone gets it wrong.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian 05aebac67b feat(oidc): show the env-managed provider as locked in settings
The API answers 409 to any write against this provider, so offering edit,
delete and the enable toggle would promise a change that cannot land -- the
operator would click, see nothing happen, and have no way to tell why. The
controls are hidden and a lock badge names the reason instead.

Reuses settings.environmentManagedLabel, the string the Home Assistant
env-managed fields already use: same situation, same wording, and no new key
to keep in parity across eleven locales.

is_env_managed is optional on the client type so a response from an older
backend still type-checks.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian 8d1b2b9027 chore(config): register BAMBUDDY_OIDC_* in the typo-guard
Unknown BAMBUDDY_* vars log "possible typo" on every boot, so a correct OIDC
config would have told its operator it was wrong, once per restart.

The test asserts against the reader's own variable list rather than a copied
one, so a thirteenth variable added later fails here instead of surfacing in
somebody's logs.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian ccc90ff34c feat(oidc): expose is_env_managed in the provider response
The frontend needs it to render the provider read-only. Without the flag the
UI would offer editable fields whose writes the API then refuses with 409 --
the change would look accepted right up until it wasn't.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian c9ee259807 feat(oidc): refuse API writes to the env-managed provider
Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so an edit
through the UI would be accepted and then silently reverted at the next
restart -- the operator would watch their change vanish with nothing
explaining why. A 409 says so instead.

Covers all four mutating routes, including the two icon ones: the icon comes
from BAMBUDDY_OIDC_ICON_URL and would be restored the same way. Extracted as
one helper rather than four copies of the same check, so a fifth route cannot
be added with the guard silently missing.

Locking it is safe because BAMBUDDY_LOCAL_LOGIN (#1589) remains the documented
recovery path if the provider itself becomes unusable. A test pins that
UI-created providers stay editable -- the lock must not leak onto them.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian e7a413e745 feat(oidc): apply the env provider during startup
Placed after init_db(): is_env_managed only exists once run_migrations has
added it, so an upsert before that would fail on every existing installation.

The wiring gets its own tests because the apply tests cannot cover it -- they
call apply_env_oidc_provider() directly, so deleting this call would leave the
feature dead with a fully green suite. Verified: removing the call fails the
three startup tests while all seven apply tests still pass.

They assert against the lifespan's source rather than running it. The function
is ~460 lines and starts printer connections, MQTT and schedulers; executing
it would exercise everything except the line in question. The docstring says
plainly that this proves the call exists and runs after migrations, and
proves nothing about its behaviour.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian 58602a3f1b feat(oidc): upsert the env-managed provider
The row is updated in place, never delete-recreated: user_oidc_links
references it with ON DELETE CASCADE, so recreating the provider would
silently unlink every account bound to it. For the same reason, removing the
variables disables the provider rather than deleting it -- the links would not
come back when the config does.

Config goes through OIDCProviderCreate, the schema the API already uses, so
the environment cannot reach a state the UI would have refused. That covers
the SEC-1 auto-link check: auto-link plus unverified email is an account
takeover, and it is rejected here exactly as it is in the UI.

Nothing raises. This runs during startup, so a typo in one variable must not
stop the app from booting -- a rejected config is logged and skipped, leaving
the previous provider untouched.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian d6ecd92480 feat(oidc): read BAMBUDDY_OIDC_* env config
A declarative deployment has no way to click through the settings UI, so one
provider can be configured entirely from the environment. This reads and
defaults only -- validity is decided later by the same OIDCProviderCreate
schema the API uses, so env config cannot bypass a check the UI enforces.

All four required vars or nothing, and an empty one counts as unset: a
provider missing its secret would otherwise be written to the database and
fail at authorize time, far from the typo in the compose file that caused it.
Booleans follow the project's existing spelling convention (true/1/yes), so an
unrecognised value leaves the documented default rather than guessing.

Refs #2593
2026-07-28 21:05:29 +00:00
Marian e3cada51ac feat(oidc): add is_env_managed column to oidc_providers
Marks the single provider that BAMBUDDY_OIDC_* environment variables define,
so a later change can upsert it on startup and refuse UI/API writes to it. The
row is never delete-recreated: user_oidc_links.provider_id is FK ON DELETE
CASCADE, so dropping the provider would take every account link with it.

The migration carries its own test rather than relying on the model test. A
table created from metadata already has the column, so that path never
exercises the ALTER; an installed instance gets it only through
run_migrations, and that is the path an upgrade actually takes. Covered:
the column appears on a pre-existing table, rows created before the upgrade
read as not env-managed, and re-running is a no-op because every boot replays
the whole migration set.

Refs #2593
2026-07-28 21:05:29 +00:00