From 579c0c22b2d2ca8c640a63344581bc3e4fbaefbc Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 11:02:07 +0200 Subject: [PATCH] Disconnect deletes the users the portal let in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local Disconnect deletes every portal-mapped user except the account the instance was enrolled as and superusers, so no inert rows stay behind. The confirm dialog says so, and Admin → Users refreshes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx --- backend/fluksio/api/routes/cloud.py | 16 +++++++-- backend/tests/test_cloud.py | 35 +++++++++++++++++++ docs/interface/portal.md | 3 +- frontend/src/client/sdk.gen.ts | 3 +- .../components/UserSettings/RemoteAccess.tsx | 6 ++-- 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/backend/fluksio/api/routes/cloud.py b/backend/fluksio/api/routes/cloud.py index b8f1fb2..029e342 100644 --- a/backend/fluksio/api/routes/cloud.py +++ b/backend/fluksio/api/routes/cloud.py @@ -281,19 +281,31 @@ def forget_remote_user(portal_sub: str) -> None: @router.delete( "", dependencies=[Depends(get_current_active_superuser)], response_model=Message ) -def disconnect(request: Request) -> Message: +def disconnect(request: Request, session: SessionDep) -> Message: """Sever the connection from this side. Unilateral and immediate: the config is the only reason portal tokens verify here, so deleting it ends remote access whatever the portal still - has on file. + has on file. The users the portal let in go with it. The account it was + enrolled as stays, and so does every superuser. """ task = getattr(request.app.state, "cloud_task", None) if task is not None: task.cancel() request.app.state.cloud_task = None request.app.state.cloud_connector = None + config = cloud_config.load() cloud_config.delete() + owner = config.local_user_id if config is not None else None + for user in session.exec( + select(User).where( + col(User.portal_sub).is_not(None), + User.is_superuser == False, # noqa: E712 + ) + ): + if str(user.id) != owner: + session.delete(user) + session.commit() return Message(message="Disconnected from the portal") diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index 095479e..fd5a9a5 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -93,6 +93,41 @@ def test_disconnecting_ends_remote_access( decode_token(portal_token(portal_key)) +def test_disconnecting_deletes_the_users_the_portal_let_in( + client: TestClient, + enrolled: User, + superuser_token_headers: dict[str, str], + db: Session, +) -> None: + """Nothing reaches them once the portal is gone; the rest stay.""" + member = User(email="member-dc@example.com", hashed_password="x", portal_sub="p-1") + boss = User( + email="boss-dc@example.com", + hashed_password="x", + portal_sub="p-2", + is_superuser=True, + ) + plain = create_random_user(db) + db.add(member) + db.add(boss) + db.commit() + member_id, boss_id = member.id, boss.id + + response = client.delete( + f"{settings.API_V1_STR}/cloud", headers=superuser_token_headers + ) + assert response.status_code == 200, response.text + db.expire_all() + assert db.get(User, member_id) is None + assert db.get(User, boss_id) is not None + assert db.get(User, plain.id) is not None + assert db.get(User, enrolled.id) is not None + assert cloud_config.load() is None + db.delete(db.get(User, boss_id)) + db.delete(db.get(User, plain.id)) + db.commit() + + def test_status_reports_not_enrolled( client: TestClient, superuser_token_headers: dict[str, str] ) -> None: diff --git a/docs/interface/portal.md b/docs/interface/portal.md index ed6c540..fa26941 100644 --- a/docs/interface/portal.md +++ b/docs/interface/portal.md @@ -83,7 +83,8 @@ proxy on that route must support WebSockets. delete or deactivate their account under **Admin → Users**. - To end all portal access from the instance, press **Disconnect**. This stops the connection and invalidates portal credentials locally, without requiring - the portal to be online. + the portal to be online. It also deletes the users the portal let in; the + account that enrolled and every superuser stay. - To replace the pairing, use **Connect with a new code**. Remote users and panels may need to pair again. diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 4c659a3..20e561b 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -469,7 +469,8 @@ export class CloudService { * * Unilateral and immediate: the config is the only reason portal tokens * verify here, so deleting it ends remote access whatever the portal still - * has on file. + * has on file. The users the portal let in go with it. The account it was + * enrolled as stays, and so does every superuser. * @returns Message Successful Response * @throws ApiError */ diff --git a/frontend/src/components/UserSettings/RemoteAccess.tsx b/frontend/src/components/UserSettings/RemoteAccess.tsx index 994534a..c736ec7 100644 --- a/frontend/src/components/UserSettings/RemoteAccess.tsx +++ b/frontend/src/components/UserSettings/RemoteAccess.tsx @@ -104,6 +104,7 @@ export function RemoteAccess() { setConfirmDisconnect(false) showSuccessToast("Disconnected from the portal") invalidate() + queryClient.invalidateQueries({ queryKey: ["users"] }) }, onError: handleError.bind(showErrorToast), }) @@ -290,8 +291,9 @@ export function RemoteAccess() { Disconnect from the portal? Remote access ends immediately and the portal's credentials stop - working here. Nothing on this instance is changed or deleted, and - you can connect again with a new code. + working here. The users the portal let in are deleted. The account + it was connected as and every superuser stay, flows and data are + untouched, and you can connect again with a new code.