fix(cloud): carry Bambu Cloud credentials across the auth on/off boundary

get_stored_token() reads the global Settings rows when auth is disabled and
User.cloud_token when it is enabled, so completing /auth/setup switched which
store the /cloud/* routes consult without moving the token. An account linked
before enabling auth was stranded: build_authenticated_cloud() returned None,
get_filament_info() skipped its cloud phase and answered 200 from local
fallbacks, and /cloud/devices began returning 401 -- all silently.

setup_auth() now migrates the global token onto the owning admin and deletes
the global rows; disable_auth() mirrors the hand-off back. Neither guesses:
setup migrates only when it creates the admin or exactly one exists, disable
declines to overwrite an existing global token. Region survives both hops.

Instances that already crossed the transition must re-link once.
This commit is contained in:
maziggy
2026-07-10 08:15:35 +02:00
parent 77f8a3a3ff
commit e6136b660b
4 changed files with 305 additions and 0 deletions
+40
View File
@@ -295,6 +295,38 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
detail="Failed to create admin user",
)
if request.auth_enabled:
# Enabling auth flips cloud-credential storage from the global
# Settings rows to User.cloud_token. Carry any token linked while
# auth was off across to the owning admin, or /cloud/* silently
# degrades to local presets with no indication anything broke
# (#2530). Only migrate when there is exactly one obvious owner:
# handing another admin's session a Bambu credential is not a
# guess worth making.
from backend.app.api.routes.cloud import (
get_stored_token,
migrate_global_cloud_token_to_user,
)
if admin_created:
cloud_owner = admin_user
elif len(existing_admin_users) == 1:
cloud_owner = existing_admin_users[0]
else:
cloud_owner = None
if cloud_owner is not None:
if await migrate_global_cloud_token_to_user(db, cloud_owner):
logger.info("Migrated global Bambu Cloud credentials to admin '%s'", cloud_owner.username)
else:
global_token, _, _ = await get_stored_token(db, None)
if global_token:
logger.warning(
"A Bambu Cloud account is linked globally but %s admins exist; "
"leaving it unassigned. Re-link the account from Settings after login.",
len(existing_admin_users),
)
# Set auth enabled and mark setup as completed
await set_auth_enabled(db, request.auth_enabled)
await set_setup_completed(db, True)
@@ -349,6 +381,14 @@ async def disable_auth(
)
try:
# Mirror of the migration in setup_auth: with auth off the cloud routes
# read the global Settings rows and never look at User.cloud_token, so
# hand this admin's credential over rather than stranding it (#2530).
from backend.app.api.routes.cloud import migrate_user_cloud_token_to_global
if await migrate_user_cloud_token_to_global(db, user):
logger.info("Migrated Bambu Cloud credentials from admin '%s' to global storage", user.username)
await set_auth_enabled(db, False)
await db.commit()
logger.info("Authentication disabled by admin user: %s", user.username)
+63
View File
@@ -250,6 +250,69 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
await db.commit()
async def migrate_global_cloud_token_to_user(db: AsyncSession, user: User) -> bool:
"""Move a globally-stored cloud token onto ``user`` (auth being enabled).
``get_stored_token`` reads the global ``Settings`` rows when auth is off and
``User.cloud_token`` when it's on. Enabling auth therefore switches which
column the cloud routes consult — without this migration the token linked
before setup is stranded in ``Settings``, ``build_authenticated_cloud``
returns ``None``, and every ``/cloud/*`` route silently degrades (#2530).
The global rows are deleted after the copy so the credential isn't left at
rest in a table nothing reads any more. Does **not** commit — the caller
owns the transaction. Returns True when a token was actually migrated.
"""
token, email, region = await get_stored_token(db, None)
if not token:
return False
user.cloud_token = token
user.cloud_email = email
user.cloud_region = _normalise_region(region)
result = await db.execute(
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
)
for setting in result.scalars().all():
await db.delete(setting)
return True
async def migrate_user_cloud_token_to_global(db: AsyncSession, user: User) -> bool:
"""Move ``user``'s cloud token into global storage (auth being disabled).
The mirror of :func:`migrate_global_cloud_token_to_user`: once auth is off,
``get_stored_token`` stops consulting ``User.cloud_token`` entirely, so the
admin who turns auth off would otherwise lose their own cloud link.
Refuses to overwrite an existing global token — a stale row from a previous
no-auth stint is still someone's credential, and clobbering it silently is
worse than leaving this admin to re-link. Does **not** commit. Returns True
when a token was actually migrated.
"""
if not user.cloud_token:
return False
existing, _, _ = await get_stored_token(db, None)
if existing:
return False
for key, value in [
(CLOUD_TOKEN_KEY, user.cloud_token),
(CLOUD_EMAIL_KEY, user.cloud_email),
(CLOUD_REGION_KEY, _normalise_region(user.cloud_region)),
]:
if value is None:
continue
db.add(Settings(key=key, value=value))
user.cloud_token = None
user.cloud_email = None
user.cloud_region = None
return True
def _assert_api_key_can_access_cloud(api_key: APIKey) -> None:
"""Reject API keys that aren't authorised to read cloud data.