Просмотр исходного кода

fix(auth): taking away live updates or camera access through a group ends them

Only minting a WebSocket or camera token checked websocket:connect or
camera:view. After a group change took the permission away (or a user
left the granting group, or a key lost its status access), open
dashboards kept their live connection and could reconnect for an hour,
open camera views kept running, and Cam Wall, overlay and Home
Assistant tokens kept working until they expired. The permission is now
checked at WebSocket connect and on every scope refresh (which every
group permission or membership change now triggers), on every camera
token use, in the 30-second camera stream re-check, and in the Cam Wall
and overlay feeds; a principal that lost it is refused or closed.
maziggy 1 неделя назад
Родитель
Сommit
be915fa308

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Taking away live updates or camera access through a group now ends them** — Only creating a dashboard's live connection or a camera link checked the permission for it. When an admin removed that permission from a group, removed a user from the group that granted it, or turned off an API key's status access, a dashboard already open kept receiving live updates and could reconnect for up to an hour, and an open camera view kept running, its link opened new views and snapshots, and a Cam Wall or Home Assistant camera token kept working until it expired. Those connections and views are now closed and the links refused once the permission is gone; the dashboard falls back to refreshing on its own as it does for users without live updates.
 - **An open camera view stops once the viewer may no longer watch** — A live camera stream checked its link once, when it started, and then ran for as long as the tab stayed open. After a password change or reset, removing someone's two-factor authentication, deactivating a user, a backup restore, disabling or expiring an API key, revoking a Cam Wall or overlay token, a group change that takes the printer away, or turning authentication on, a view already open kept showing live video. Streams now check every 30 seconds that whoever opened them may still watch that printer, and end if not. A view that is still allowed keeps running, including past the hour its link is valid for.
 - **A tab whose live connection was closed by the server now recovers instead of freezing** — When the server closes the live-update connection because of sign-in (your session ended, authentication was just turned on, or a temporary check failed), the page used to stop updating and stay on screen until reloaded. It now re-checks your sign-in: a tab whose session ended goes to the login page, and one that is still signed in reconnects. While its connection keeps being refused or can't be made it waits longer between attempts, up to five minutes when the server closes an accepted connection again and up to 30 seconds otherwise, instead of retrying every three seconds.
 - **Signing a user out everywhere now also closes their open dashboards** — A dashboard's live connection is checked only when it connects. Changing your password, a password reset link, an admin removing someone's two-factor authentication, deactivating a user and restoring a backup all make earlier sign-ins invalid, but a dashboard already open on the account, possibly someone else's, kept receiving live updates until it disconnected, and the link it connected with stayed valid for an hour to connect again. Those live dashboard connections are now closed and their links removed; your own tab reconnects after signing in again. Turning authentication on likewise closes the dashboards opened while it was off, which kept every printer, and disabling an API key, or one expiring, closes the dashboards it opened.

+ 8 - 2
backend/app/api/routes/api_keys.py

@@ -190,9 +190,15 @@ async def update_api_key(
         # Its camera and WebSocket tokens go with it; the scope refresh below
         # closes its open dashboards.
         await drop_api_key_tokens(db, [api_key.id])
-    if data.printer_ids is not None or data.enabled is not None or data.expires_at is not None:
+    if (
+        data.printer_ids is not None
+        or data.enabled is not None
+        or data.expires_at is not None
+        or data.can_read_status is not None
+    ):
         # Sockets opened with this key may now see fewer printers, or none
-        # (#1727). Committed first so the refresh reads the new row.
+        # (#1727), or not connect at all (can_read_status grants
+        # websocket:connect). Committed first so the refresh reads the new row.
         await db.commit()
         await ws_manager.refresh_printer_scopes()
 

+ 8 - 8
backend/app/api/routes/groups.py

@@ -331,7 +331,9 @@ async def update_group(
 
     await db.commit()
     await db.refresh(group)
-    if scope_changed:
+    if scope_changed or group_data.permissions is not None:
+        # Printers, or websocket:connect itself (a socket of a user who lost it
+        # is closed)
         await ws_manager.refresh_printer_scopes()
 
     return _group_response(
@@ -361,14 +363,13 @@ async def delete_group(
             detail="Cannot delete system groups",
         )
 
-    restricted = bool(group.restrict_printers)
     # SQLite doesn't enforce the FK cascade
     await db.execute(delete(group_printers).where(group_printers.c.group_id == group_id))
     await db.execute(delete(group_locations).where(group_locations.c.group_id == group_id))
     await db.delete(group)
     await db.commit()
-    if restricted:
-        await ws_manager.refresh_printer_scopes()
+    # Its members may lose printers, or websocket:connect itself
+    await ws_manager.refresh_printer_scopes()
 
 
 @router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -407,8 +408,7 @@ async def add_user_to_group(
 
     group.users.append(user)
     await db.commit()
-    if group.restrict_printers:
-        await ws_manager.refresh_printer_scopes()
+    await ws_manager.refresh_printer_scopes()
 
 
 @router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -447,5 +447,5 @@ async def remove_user_from_group(
 
     group.users.remove(user)
     await db.commit()
-    if group.restrict_printers:
-        await ws_manager.refresh_printer_scopes()
+    # They may lose printers, or websocket:connect itself
+    await ws_manager.refresh_printer_scopes()

+ 20 - 0
backend/app/api/routes/websocket.py

@@ -23,6 +23,7 @@ from sqlalchemy import select
 
 from backend.app.core.auth import (
     is_auth_enabled,
+    principal_may_connect,
     principal_printer_scope,
     usable_api_key_prefix,
     verify_websocket_token_principal,
@@ -65,6 +66,17 @@ async def _api_key_prefix(api_key_id: int) -> str | None:
         return None
 
 
+async def _still_may_connect(principal: str | None, api_key_id: int | None) -> bool:
+    """websocket:connect still held (False also when it can't be read): a group
+    or key change that took it away while the socket joined closed nothing."""
+    try:
+        async with async_session() as db:
+            return await principal_may_connect(db, principal, api_key_id)
+    except Exception:  # SEC-AUTH-EXC: a failed read refuses the socket (fail-closed)
+        logger.warning("WebSocket permission re-check failed", exc_info=True)
+        return False
+
+
 async def _auth_turned_on() -> bool:
     """Whether authentication is on now (True also when it can't be read)."""
     try:
@@ -134,11 +146,18 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
         # with every printer.
         try:
             async with async_session() as db:
+                may_connect = await principal_may_connect(db, principal, api_key_id)
                 printer_scope = await principal_printer_scope(db, principal, api_key_id)
         except Exception:  # SEC-AUTH-EXC: scope lookup failure → refuse connect (fail-closed)
             logger.error("WebSocket printer scope lookup failed; refusing connection", exc_info=True)
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             return
+        if not may_connect:
+            # The token was minted while its principal held websocket:connect;
+            # a group or key change has taken it away since.
+            logger.info("WebSocket connect refused: websocket:connect no longer held")
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
 
     # Token verified (or auth disabled); now safe to admit the connection.
     logger.info("WebSocket client connecting (principal=%s)", principal if principal else "<anonymous>")
@@ -195,6 +214,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
         (principal and await _user_id_of(principal) != principal_user_id)
         or (api_key_id is not None and await _api_key_prefix(api_key_id) != api_key_prefix)
         or (auth_required and await _token_principal(token) != (principal, api_key_id))
+        or (auth_required and not await _still_may_connect(principal, api_key_id))
         or (not auth_required and await _auth_turned_on())
     ):
         logger.info("WebSocket connect refused: its session ended while it connected")

+ 51 - 5
backend/app/core/auth.py

@@ -985,6 +985,43 @@ async def usable_api_key_prefix(db: AsyncSession, api_key_id: int) -> str | None
     return api_key.key_prefix
 
 
+async def principal_holds(
+    db: AsyncSession, username: str | None, api_key_id: int | None, permission: Permission
+) -> bool:
+    """Whether a token's principal still holds the permission minting it needed.
+
+    A group change can take it away while the token is still valid (an hour,
+    or months for a long-lived one) and a connection or stream is open. A key
+    is checked as at the mint (its scope flags and its owner's permissions); a
+    user must exist, be active and hold it.
+    """
+    if api_key_id is not None:
+        api_key = (await db.execute(select(APIKey).where(APIKey.id == api_key_id))).scalar_one_or_none()
+        if api_key is None:
+            return False
+        try:
+            await authorize_api_key(db, api_key, [permission.value])
+        except HTTPException:
+            return False
+        return True
+    if username:
+        user = await get_user_by_username(db, username)
+        return user is not None and user.is_active and user.has_permission(permission.value)
+    return False
+
+
+async def user_id_holds(db: AsyncSession, user_id: int, permission: Permission) -> bool:
+    """principal_holds for the owner of a long-lived token, named by id."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
+    user = result.scalar_one_or_none()
+    return user is not None and user.is_active and user.has_permission(permission.value)
+
+
+async def principal_may_connect(db: AsyncSession, username: str | None, api_key_id: int | None) -> bool:
+    """Whether a WebSocket token's principal still holds websocket:connect."""
+    return await principal_holds(db, username, api_key_id, Permission.WEBSOCKET_CONNECT)
+
+
 async def principal_printer_scope(db: AsyncSession, username: str | None, api_key_id: int | None) -> PrinterScope:
     """Printer scope of the principal a token was minted for (#1727).
 
@@ -1055,6 +1092,9 @@ async def camera_stream_access(token: str) -> tuple[PrinterScope, StreamOpener]
         )
         row = result.scalar_one_or_none()
         if row is not None:
+            # Minting it needed camera:view; a group or key change can take it away.
+            if not await principal_holds(db, row.username, row.api_key_id, Permission.CAMERA_VIEW):
+                return None
             scope = await principal_printer_scope(db, row.username, row.api_key_id)
             prefix = await usable_api_key_prefix(db, row.api_key_id) if row.api_key_id is not None else None
             minted = row.expires_at.replace(tzinfo=None) - timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
@@ -1071,7 +1111,7 @@ async def camera_stream_access(token: str) -> tuple[PrinterScope, StreamOpener]
         from backend.app.services.long_lived_tokens import STREAM_SCOPES, verify_token as verify_long_lived
 
         record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
-        if record is None:
+        if record is None or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW):
             return None
         scope = await resolve_user_id_printer_scope(db, record.user_id)
         return scope, StreamOpener(
@@ -1100,7 +1140,8 @@ async def stream_opener_still_allowed(opener: StreamOpener, printer_id: int) ->
     change or reset, 2FA removal, restore), and they must still exist and be
     active; a key must still be usable and the same key; a long-lived token
     must not be revoked or expired; a stream opened while auth was off ends
-    once it is on. In every case the printer must still be in scope.
+    once it is on. In every case camera:view must still be held (by the
+    token's owner for a long-lived one) and the printer must still be in scope.
     """
     from backend.app.models.long_lived_token import LongLivedToken
     from backend.app.services.long_lived_tokens import _is_expired
@@ -1120,16 +1161,19 @@ async def stream_opener_still_allowed(opener: StreamOpener, printer_id: int) ->
                     or record.user_id != opener.long_lived_user_id
                     or record.lookup_prefix != opener.long_lived_prefix
                     or _is_expired(record, datetime.now(timezone.utc))
+                    or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW)
                 ):
                     return False
                 scope = await resolve_user_id_printer_scope(db, record.user_id)
             elif opener.api_key_id is not None:
                 if await usable_api_key_prefix(db, opener.api_key_id) != opener.api_key_prefix:
                     return False
+                if not await principal_holds(db, None, opener.api_key_id, Permission.CAMERA_VIEW):
+                    return False
                 scope = await principal_printer_scope(db, None, opener.api_key_id)
             elif opener.username:
                 user = await get_user_by_username(db, opener.username)
-                if user is None or not user.is_active:
+                if user is None or not user.is_active or not user.has_permission(Permission.CAMERA_VIEW.value):
                     return False
                 changed = user.password_changed_at
                 if changed is not None and changed.replace(tzinfo=None) > opener.started_at:
@@ -1160,7 +1204,8 @@ async def verify_camwall_token(token: str) -> PrinterScope | None:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
         record = await verify_long_lived(db, token, scope="camwall")
-        if record is None:
+        # Minting it needed camera:view; a group change can take it away.
+        if record is None or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW):
             return None
         return await resolve_user_id_printer_scope(db, record.user_id)
 
@@ -1181,7 +1226,8 @@ async def verify_overlay_token(token: str) -> PrinterScope | None:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
         record = await verify_long_lived(db, token, scope="overlay")
-        if record is None:
+        # Minting it needed camera:view; a group change can take it away.
+        if record is None or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW):
             return None
         return await resolve_user_id_printer_scope(db, record.user_id)
 

+ 11 - 1
backend/app/core/websocket.py

@@ -151,7 +151,12 @@ class ConnectionManager:
         is in a group, so open dashboards stop (or start) receiving those
         printers' events without a reconnect.
         """
-        from backend.app.core.auth import is_auth_enabled, principal_printer_scope, usable_api_key_prefix
+        from backend.app.core.auth import (
+            is_auth_enabled,
+            principal_may_connect,
+            principal_printer_scope,
+            usable_api_key_prefix,
+        )
         from backend.app.core.database import async_session
         from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
 
@@ -180,6 +185,11 @@ class ConnectionManager:
                             connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
                             orphaned.append(connection)
                             continue
+                    if not await principal_may_connect(db, username, api_key_id):
+                        # websocket:connect was taken away (a group or key change)
+                        connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
+                        orphaned.append(connection)
+                        continue
                     connection.state.bambuddy_printer_scope = await principal_printer_scope(db, username, api_key_id)
         except Exception:  # SEC-AUTH-EXC: refresh failed → fail closed (empty scope, then disconnect to re-auth)
             # The old scopes may be wider than what was just granted, so they

+ 79 - 0
backend/tests/integration/test_camera_stream_recheck.py

@@ -169,3 +169,82 @@ async def test_a_stream_opened_as_the_session_ends_does_not_outlive_it(admin, pr
 
     response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=opener)
     assert await _ended(response.body_iterator)
+
+
+@pytest.fixture
+async def viewer(async_client: AsyncClient, admin) -> tuple[int, int, dict]:
+    """A non-admin with camera:view through a group: (group id, user id, admin headers)."""
+    login = await async_client.post("/api/v1/auth/login", json={"username": "camadmin", "password": "AdminPass1!"})
+    headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
+    group = await async_client.post(
+        "/api/v1/groups/", headers=headers, json={"name": "Cams", "permissions": ["camera:view", "printers:read"]}
+    )
+    user = await async_client.post(
+        "/api/v1/users/",
+        headers=headers,
+        json={"username": "viewer", "password": "Userpass1!", "role": "user", "group_ids": [group.json()["id"]]},
+    )
+    return group.json()["id"], user.json()["id"], headers
+
+
+async def _take_camera_view(client: AsyncClient, group: int, headers: dict) -> None:
+    response = await client.patch(f"/api/v1/groups/{group}", headers=headers, json={"permissions": ["printers:read"]})
+    assert response.status_code == 200, response.text
+
+
+async def test_losing_camera_view_ends_a_users_stream_and_token(async_client: AsyncClient, viewer, printer):
+    group, _, headers = viewer
+    token = await auth.create_camera_stream_token(username="viewer")
+    stream = await _open(printer, await _opener(token))
+
+    await _take_camera_view(async_client, group, headers)
+
+    assert await _ended(stream)
+    assert await auth.camera_stream_access(token) is None  # no new stream or snapshot either
+
+
+async def test_a_key_losing_status_access_ends_its_stream(admin, printer, db_session):
+    key = APIKey(name="cam2", key_hash="h", key_prefix="bb_cam2", printer_ids=None)
+    db_session.add(key)
+    await db_session.commit()
+    token = await auth.create_camera_stream_token(api_key_id=key.id)
+    stream = await _open(printer, await _opener(token))
+
+    await db_session.execute(text("UPDATE api_keys SET can_read_status = 0 WHERE id = :id"), {"id": key.id})
+    await db_session.commit()
+
+    assert await _ended(stream)
+    assert await auth.camera_stream_access(token) is None
+
+
+async def test_a_cam_wall_token_whose_owner_lost_camera_view_stops(
+    async_client: AsyncClient, viewer, printer, db_session
+):
+    """It would otherwise stream for months, until it expires or is revoked."""
+    from backend.app.services.long_lived_tokens import create_token
+
+    group, user, headers = viewer
+    created = await create_token(db_session, user_id=user, name="wall", expires_in_days=30, scope="camwall")
+    stream = await _open(printer, await _opener(created.plaintext))
+
+    await _take_camera_view(async_client, group, headers)
+
+    assert await _ended(stream)
+    assert await auth.camera_stream_access(created.plaintext) is None
+
+
+@pytest.mark.parametrize("kind", ["camwall", "overlay"])
+async def test_the_cam_wall_and_overlay_feeds_refuse_a_token_whose_owner_lost_camera_view(
+    async_client: AsyncClient, viewer, db_session, kind
+):
+    """They list printers and name the file being printed; minting needed camera:view."""
+    from backend.app.services.long_lived_tokens import create_token
+
+    group, user, headers = viewer
+    created = await create_token(db_session, user_id=user, name=kind, expires_in_days=30, scope=kind)
+    verify = auth.verify_camwall_token if kind == "camwall" else auth.verify_overlay_token
+    assert await verify(created.plaintext) is not None
+
+    await _take_camera_view(async_client, group, headers)
+
+    assert await verify(created.plaintext) is None

+ 172 - 0
backend/tests/integration/test_ws_connect_permission.py

@@ -0,0 +1,172 @@
+"""Taking websocket:connect away ends the live connection it allowed.
+
+Only minting a WebSocket token checked the permission: the handshake and the
+scope refresh did not. A user whose group lost it kept their open dashboard
+(their printers' live status and every event of no printer), and their token
+reconnected for an hour.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import WebSocketDisconnect
+from httpx import AsyncClient
+
+from backend.app.core import database
+from backend.app.core.printer_scope import ALL_PRINTERS
+from backend.app.core.websocket import ws_manager
+from backend.app.models.api_key import APIKey
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+@pytest.fixture
+async def admin(async_client: AsyncClient) -> dict:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "wsadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "wsadmin", "password": "AdminPass1!"})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _carol_in_group(client: AsyncClient, admin: dict) -> tuple[int, int]:
+    group = await client.post(
+        "/api/v1/groups/", headers=admin, json={"name": "Live", "permissions": ["websocket:connect", "printers:read"]}
+    )
+    assert group.status_code == 201, group.text
+    user = await client.post(
+        "/api/v1/users/",
+        headers=admin,
+        json={"username": "carol", "password": "Userpass1!", "role": "user", "group_ids": [group.json()["id"]]},
+    )
+    assert user.status_code == 201, user.text
+    return group.json()["id"], user.json()["id"]
+
+
+def _socket(username: str | None, user_id: int | None, key_id: int | None = None, prefix: str | None = None):
+    socket = MagicMock()
+    socket.state = SimpleNamespace(
+        bambuddy_scope_principal=(username if username else "", key_id),
+        bambuddy_principal_user_id=user_id,
+        bambuddy_api_key_prefix=prefix,
+        bambuddy_printer_scope=ALL_PRINTERS,
+    )
+    socket.close = AsyncMock()
+    return socket
+
+
+@pytest.mark.parametrize("keeps", [False, True])
+async def test_a_group_losing_the_permission_closes_its_users_sockets(async_client: AsyncClient, admin, keeps):
+    group, carol = await _carol_in_group(async_client, admin)
+    socket = _socket("carol", carol)
+    ws_manager.active_connections.append(socket)
+    permissions = ["websocket:connect", "printers:read"] if keeps else ["printers:read"]
+    try:
+        with patch("backend.app.core.database.async_session", database.async_session):
+            response = await async_client.patch(
+                f"/api/v1/groups/{group}", headers=admin, json={"permissions": permissions}
+            )
+
+        assert response.status_code == 200, response.text
+        if keeps:
+            socket.close.assert_not_awaited()
+        else:
+            socket.close.assert_awaited_once_with(code=4401)
+    finally:
+        if socket in ws_manager.active_connections:
+            ws_manager.active_connections.remove(socket)
+
+
+async def test_a_user_removed_from_the_granting_group_loses_the_socket(async_client: AsyncClient, admin):
+    group, carol = await _carol_in_group(async_client, admin)
+    socket = _socket("carol", carol)
+    ws_manager.active_connections.append(socket)
+    try:
+        response = await async_client.delete(f"/api/v1/groups/{group}/users/{carol}", headers=admin)
+
+        assert response.status_code == 204, response.text
+        socket.close.assert_awaited_once_with(code=4401)
+    finally:
+        if socket in ws_manager.active_connections:
+            ws_manager.active_connections.remove(socket)
+
+
+async def _connect(principal: tuple):
+    from backend.app.api.routes import websocket as ws_route
+
+    socket = MagicMock()
+    socket.state = SimpleNamespace()
+    socket.accept = AsyncMock()
+    socket.close = AsyncMock()
+    socket.send_json = AsyncMock()
+    socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
+    with (
+        patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
+        patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(return_value=principal)),
+        patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
+        patch.object(ws_route, "async_session", database.async_session),
+    ):
+        await ws_route.websocket_endpoint(socket, token="t")
+    return socket
+
+
+async def test_a_token_whose_user_lost_the_permission_is_refused(async_client: AsyncClient, admin):
+    """Minted while the user had it, still valid for an hour."""
+    group, _ = await _carol_in_group(async_client, admin)
+    await async_client.patch(f"/api/v1/groups/{group}", headers=admin, json={"permissions": ["printers:read"]})
+
+    socket = await _connect(("carol", None))
+
+    socket.close.assert_awaited_once_with(code=4401)
+    socket.accept.assert_not_awaited()
+
+
+async def test_a_token_of_a_key_without_status_access_is_refused(async_client: AsyncClient, admin, db_session):
+    key = APIKey(name="k", key_hash="h", key_prefix="bb_nostat", can_read_status=False)
+    db_session.add(key)
+    await db_session.commit()
+
+    socket = await _connect(("", key.id))
+
+    socket.close.assert_awaited_once_with(code=4401)
+    socket.accept.assert_not_awaited()
+
+
+async def test_a_key_losing_status_access_closes_its_sockets(async_client: AsyncClient, admin, db_session):
+    key = APIKey(name="k", key_hash="h", key_prefix="bb_stat")
+    db_session.add(key)
+    await db_session.commit()
+    socket = _socket(None, None, key_id=key.id, prefix="bb_stat")
+    ws_manager.active_connections.append(socket)
+    try:
+        with patch("backend.app.core.database.async_session", database.async_session):
+            response = await async_client.patch(
+                f"/api/v1/api-keys/{key.id}", headers=admin, json={"can_read_status": False}
+            )
+
+        assert response.status_code == 200, response.text
+        socket.close.assert_awaited_once_with(code=4401)
+    finally:
+        if socket in ws_manager.active_connections:
+            ws_manager.active_connections.remove(socket)
+
+
+async def test_the_permission_taken_while_a_socket_joins_closes_it(async_client: AsyncClient, admin):
+    """The group change's refresh ran before the socket was listed."""
+    from backend.app.api.routes import websocket as ws_route
+
+    await _carol_in_group(async_client, admin)
+    calls = []
+    real = ws_route.principal_may_connect
+
+    async def held_at_connect_then_gone(db, username, key_id):
+        calls.append(1)
+        return await real(db, username, key_id) if len(calls) == 1 else False
+
+    with patch.object(ws_route, "principal_may_connect", held_at_connect_then_gone):
+        socket = await _connect(("carol", None))
+
+    socket.close.assert_awaited_once_with(code=4401)
+    assert socket not in ws_manager.active_connections

+ 5 - 1
backend/tests/unit/test_printer_scope.py

@@ -118,11 +118,15 @@ class TestScopeRefresh:
         socket.close = AsyncMock()
         mgr.active_connections = [socket]
 
+        scope_lookup = AsyncMock(side_effect=RuntimeError("db down"))
         with (
             patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=True)),
-            patch("backend.app.core.auth.principal_printer_scope", AsyncMock(side_effect=RuntimeError("db down"))),
+            # Still allowed to connect, so the refresh reaches the scope lookup.
+            patch("backend.app.core.auth.principal_may_connect", AsyncMock(return_value=True)),
+            patch("backend.app.core.auth.principal_printer_scope", scope_lookup),
         ):
             await mgr.refresh_printer_scopes()
 
+        scope_lookup.assert_awaited_once()
         assert socket.state.bambuddy_printer_scope == PrinterScope(frozenset())
         socket.close.assert_awaited_once_with(code=4401)