Parcourir la source

fix(api-keys): a deleted key's connections and links don't pass to the next key

SQLite gives a deleted API key's id to the next key created. Open
WebSockets opened with the key, and the camera and WebSocket tokens it
minted, were tied to it by id and took the new key's printers. Deleting a
key, or a user with keys, now closes those sockets and drops the tokens;
a socket whose key is gone or replaced is closed at the next scope
refresh; a WebSocket of a deleted key is refused; kiosk-bootstrap --force
gives the new key a fresh id; tokens of keys deleted before are removed
at start.
maziggy il y a 1 semaine
Parent
commit
6a627a85e1

+ 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
+- **A deleted API key's open connections and tokens no longer pass to the next key** — SQLite gives a deleted key's id to the next key created. Live dashboards opened with the key stayed connected, and the camera and WebSocket links it created stayed valid for up to an hour; both were tied to the key by id and took the new key's printers. Deleting a key, or a user with keys, now closes those connections and removes the links, and links of keys deleted before are removed at start.
 - **A printer added after a delete no longer takes over the deleted printer's print, notifications, camera and plate references** — SQLite gives a deleted printer's id to the next printer created, and Bambuddy kept much of this under that id until the next restart.
   - **Prints and billing:** the new printer took over the owner of a print still running when the old one was deleted. Its prints were treated as started from Bambuddy, so the kill switch let them run, and its first finished print was credited to that user. A job sent to the old printer that hadn't started yet could do the same for a print with the same file name. A new printer that was already printing when added could also take over the old print's filament tracking, charging its filament to the deleted printer's spools.
   - **Gates and notifications:** the plate-clear prompt could block the queue on the new printer. Pending "bed cooled" and offline notifications fired under its name, and the first print's milestone notifications could be skipped.

+ 20 - 3
backend/app/api/routes/api_keys.py

@@ -1,15 +1,17 @@
 import logging
 
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes.notifications import forget_app_senders
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import ensure_printers_exist
 from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
 from backend.app.models.user import User
 from backend.app.schemas.api_key import (
     APIKeyCreate,
@@ -189,6 +191,18 @@ async def update_api_key(
     return api_key
 
 
+async def drop_api_key_tokens(db: AsyncSession, key_ids) -> None:
+    """Delete the camera and WebSocket tokens deleted keys minted (media tokens
+    never record the key).
+
+    They name the key by id, which SQLite gives to the next key created: a
+    token of a key limited to one printer would get the new key's printers.
+    """
+    key_ids = list(key_ids)
+    if key_ids:
+        await db.execute(delete(AuthEphemeralToken).where(AuthEphemeralToken.api_key_id.in_(key_ids)))
+
+
 @router.delete("/{key_id}")
 async def delete_api_key(
     key_id: int,
@@ -203,8 +217,11 @@ async def delete_api_key(
         raise HTTPException(status_code=404, detail="API key not found")
 
     await db.delete(api_key)
-    # Sockets opened with this key lose their printers (#1727)
+    await drop_api_key_tokens(db, [key_id])
     await db.commit()
-    await ws_manager.refresh_printer_scopes()
+    # Its sockets are closed, not just given no printers: their scope is looked
+    # up by key id, which SQLite gives to the next key created.
+    await ws_manager.close_api_keys([key_id])
+    forget_app_senders([key_id])
 
     return {"message": "API key deleted"}

+ 9 - 0
backend/app/api/routes/notifications.py

@@ -515,6 +515,15 @@ def _app_sender(caller: ScopedCaller) -> tuple[str, str]:
     return "anonymous", "app"
 
 
+def forget_app_senders(api_key_ids=(), user_ids=()) -> None:
+    """Drop deleted keys' and users' message counts: SQLite gives an id to the
+    next key or user created, which would start out throttled."""
+    for key_id in api_key_ids:
+        _app_message_times.pop(f"key:{key_id}", None)
+    for user_id in user_ids:
+        _app_message_times.pop(f"user:{user_id}", None)
+
+
 def _check_app_message_rate(key: str) -> None:
     times = _app_message_times[key]
     cutoff = time.monotonic() - APP_MESSAGE_WINDOW_SECONDS

+ 8 - 0
backend/app/api/routes/users.py

@@ -558,6 +558,10 @@ async def delete_user(
     # silently degrading the keys to anonymous (and locking them out of
     # /cloud/* — but the rest of the API would still accept them, which is
     # exactly the orphan-key state the CASCADE was meant to prevent).
+    from backend.app.api.routes.api_keys import drop_api_key_tokens
+
+    key_ids = (await db.execute(select(APIKey.id).where(APIKey.user_id == user_id))).scalars().all()
+    await drop_api_key_tokens(db, key_ids)
     await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
 
     # Drop OIDC links, MFA state, and long-lived camera-stream tokens
@@ -597,6 +601,10 @@ async def delete_user(
     forget_print_creator(user_id)
     slice_dispatch.forget_owner(user_id)
     await ws_manager.close_user(user_id)
+    await ws_manager.close_api_keys(key_ids)
+    from backend.app.api.routes.notifications import forget_app_senders
+
+    forget_app_senders(key_ids, [user_id])
     await ws_manager.refresh_printer_scopes()
 
     for file_id in doomed_library_file_ids:

+ 27 - 2
backend/app/api/routes/websocket.py

@@ -25,6 +25,7 @@ from backend.app.core.auth import is_auth_enabled, principal_printer_scope, veri
 from backend.app.core.database import async_session
 from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
 from backend.app.core.websocket import ws_manager
+from backend.app.models.api_key import APIKey
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 
@@ -49,6 +50,18 @@ async def _user_id_of(username: str) -> int | None:
         return None
 
 
+async def _api_key_prefix(api_key_id: int) -> str | None:
+    """The key's prefix, which tells it from a later key given its id; None when
+    the key is gone (also when it can't be read)."""
+    try:
+        async with async_session() as db:
+            row = await db.execute(select(APIKey.key_prefix).where(APIKey.id == api_key_id))
+            return row.scalar_one_or_none()
+    except Exception:  # SEC-AUTH-EXC: a failed lookup refuses the socket (fail-closed)
+        logger.warning("WebSocket API key lookup failed for %s", api_key_id, exc_info=True)
+        return None
+
+
 @router.websocket("/ws")
 async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(default=None)) -> None:
     """WebSocket endpoint for real-time updates.
@@ -118,6 +131,9 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
     # after: a rename or delete commits before it closes the user's sockets by
     # id, so one landing in between is seen by the second check, and one after
     # it finds this socket listed with its id.
+    #
+    # Same for an API key deleted since: its scope is looked up by key id on
+    # every refresh, and SQLite gives the id to the next key created.
     principal_user_id: int | None = None
     if principal:
         principal_user_id = await _user_id_of(principal)
@@ -125,12 +141,19 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
             logger.info("WebSocket connect refused: user %s is gone", principal)
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             return
+    api_key_prefix = await _api_key_prefix(api_key_id) if api_key_id is not None else None
+    if api_key_id is not None and api_key_prefix is None:
+        logger.info("WebSocket connect refused: API key %s is gone", api_key_id)
+        await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+        return
     # Stamped before connect() puts the socket in the broadcast list, so no
     # broadcast can reach it unfiltered (ws_manager refuses printer-bound
     # messages to a socket without a scope anyway).
     websocket.state.bambuddy_printer_scope = printer_scope
     websocket.state.bambuddy_scope_principal = (principal, api_key_id)
     websocket.state.bambuddy_principal_user_id = principal_user_id
+    # A scope refresh closes the socket if a different key has the id by then.
+    websocket.state.bambuddy_api_key_prefix = api_key_prefix
     # Stash on connection state for any future per-message permission
     # logic; today the message handlers are read-only and only respond
     # to the requesting socket, so the stash is informational. The
@@ -139,8 +162,10 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
     # touching every call site.
     websocket.state.bambuddy_principal = principal
     await ws_manager.connect(websocket)
-    if principal and await _user_id_of(principal) != principal_user_id:
-        logger.info("WebSocket connect refused: user %s was renamed or deleted while connecting", principal)
+    if (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
+    ):
+        logger.info("WebSocket connect refused: its user or API key went while it connected")
         await ws_manager.disconnect(websocket)
         await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
         return

+ 13 - 4
backend/app/cli.py

@@ -53,10 +53,6 @@ async def kiosk_bootstrap(
                 f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
             )
 
-        if existing:
-            await db.delete(existing)
-            await db.flush()
-
         full_key, key_hash, key_prefix = generate_api_key()
         row = APIKey(
             name=name,
@@ -83,6 +79,19 @@ async def kiosk_bootstrap(
             expires_at=None,
         )
         db.add(row)
+        if existing:
+            from backend.app.api.routes.api_keys import drop_api_key_tokens
+
+            # The new key is added before the old one goes, so it gets a new id:
+            # SQLite would give it the old one's, and the old (perhaps leaked)
+            # key's links and open dashboards would take its printers, all of
+            # them even if an admin had limited or disabled the old key. This
+            # runs outside the server, so those dashboards stay open with the
+            # scope they had until the server next refreshes scopes, which
+            # closes sockets whose key is gone.
+            await db.flush()
+            await drop_api_key_tokens(db, [existing.id])
+            await db.delete(existing)
 
         # Mark first-run setup as completed so the kiosk URL loads directly
         # instead of being force-redirected to /setup by AuthContext. Without

+ 14 - 4
backend/app/core/database.py

@@ -364,19 +364,29 @@ async def _drop_deleted_printers_from_limits(conn) -> None:
         await drop_virtual_printer(conn, vp_id)
 
 
-async def _drop_ephemeral_tokens_of_missing_users(conn) -> None:
-    """Delete one-time tokens pending under a username no user has (both databases).
+async def _drop_ephemeral_tokens_of_missing_owners(conn) -> None:
+    """Delete one-time tokens of a user or API key that is gone (both databases).
 
     User deletes left them (reset links, 2FA and OIDC steps, camera, WebSocket
     and slicer tokens), and they are looked up by username: whoever is given the
     name next would take them over. The logout list (revoked_jti) is kept, and
-    tokens of no user (an API key's, or auth off) are left alone.
+    tokens of no user (an API key's, or auth off) are left alone here. An API
+    key's camera and WebSocket tokens name the key by id, which SQLite
+    gives to the next key created: those of a deleted key go too.
     """
     from sqlalchemy import delete, func, select
 
+    from backend.app.models.api_key import APIKey
     from backend.app.models.auth_ephemeral import AuthEphemeralToken
     from backend.app.models.user import User
 
+    await conn.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.api_key_id.is_not(None),
+            AuthEphemeralToken.api_key_id.not_in(select(APIKey.id)),
+        )
+    )
+
     await conn.execute(
         delete(AuthEphemeralToken).where(
             AuthEphemeralToken.token_type != "revoked_jti",
@@ -518,7 +528,7 @@ async def init_db():
         # earlier deletes left pointing at nothing. Not a migration.
         await _repair_sqlite_orphans(conn)
         await _drop_deleted_printers_from_limits(conn)
-        await _drop_ephemeral_tokens_of_missing_users(conn)
+        await _drop_ephemeral_tokens_of_missing_owners(conn)
 
     # Re-encrypt any legacy plaintext OIDC client_secret / TOTP secret rows
     # that exist from before the encryption key was configured.

+ 30 - 3
backend/app/core/websocket.py

@@ -112,10 +112,17 @@ class ConnectionManager:
     async def close_user(self, user_id: int):
         """Close a deleted user's sockets, which would otherwise keep receiving
         the messages of whoever is given their id next."""
+        await self._close(lambda c: getattr(c.state, "bambuddy_principal_user_id", None) == user_id)
+
+    async def close_api_keys(self, key_ids):
+        """Close the sockets opened with deleted API keys. Their scope is looked
+        up by key id on every refresh: SQLite gives the id to the next key."""
+        key_ids = set(key_ids)
+        await self._close(lambda c: getattr(c.state, "bambuddy_scope_principal", (None, None))[1] in key_ids)
+
+    async def _close(self, matches):
         async with self._lock:
-            theirs = [
-                c for c in self.active_connections if getattr(c.state, "bambuddy_principal_user_id", None) == user_id
-            ]
+            theirs = [c for c in self.active_connections if matches(c)]
             for connection in theirs:
                 self.active_connections.remove(connection)
         for connection in theirs:
@@ -139,6 +146,11 @@ class ConnectionManager:
             connections = list(self.active_connections)
         if not connections:
             return
+        from sqlalchemy import select
+
+        from backend.app.models.api_key import APIKey
+
+        orphaned = []
         try:
             async with async_session() as db:
                 auth_enabled = await is_auth_enabled(db)
@@ -147,6 +159,18 @@ class ConnectionManager:
                         connection.state.bambuddy_printer_scope = ALL_PRINTERS
                         continue
                     username, api_key_id = getattr(connection.state, "bambuddy_scope_principal", (None, None))
+                    if api_key_id is not None:
+                        # The key is looked up by id, which SQLite gives to the
+                        # next key created: a socket whose key is gone, or whose
+                        # id another key now has, is closed, not re-scoped (a key
+                        # rotated from the command line, which can't close it).
+                        prefix = (
+                            await db.execute(select(APIKey.key_prefix).where(APIKey.id == api_key_id))
+                        ).scalar_one_or_none()
+                        if prefix is None or prefix != getattr(connection.state, "bambuddy_api_key_prefix", prefix):
+                            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
@@ -160,6 +184,9 @@ class ConnectionManager:
                     await connection.close(code=4401)
                 except Exception:  # noqa: BLE001 -- already gone; disconnect() cleans it up
                     pass
+            return
+        if orphaned:
+            await self._close(lambda c: c in orphaned)
 
     async def send_printer_status(self, printer_id: int, status: dict):
         """Send printer status update to all clients."""

+ 230 - 0
backend/tests/integration/test_api_key_id_reuse.py

@@ -0,0 +1,230 @@
+"""A deleted API key leaves nothing that the next key given its id would take.
+
+SQLite gives a deleted key's id to the next key created. Open WebSockets opened
+with the key, and the camera and WebSocket tokens it minted, are tied to
+the key by id: they took the new key's printers.
+"""
+
+from datetime import datetime, timedelta, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import WebSocketDisconnect
+from httpx import AsyncClient
+from sqlalchemy import select, text
+
+from backend.app.core.websocket import ws_manager
+from backend.app.models.api_key import APIKey
+from backend.app.models.auth_ephemeral import AuthEphemeralToken
+
+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": "keyadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "keyadmin", "password": "AdminPass1!"})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _key(db, user_id: int | None = None) -> int:
+    key = APIKey(name="k", key_hash="h", key_prefix="bb_k", printer_ids=[1], user_id=user_id)
+    db.add(key)
+    await db.commit()
+    return key.id
+
+
+async def _tokens(db, key_id: int) -> None:
+    expires = datetime.now(timezone.utc) + timedelta(hours=1)
+    db.add_all(
+        [
+            AuthEphemeralToken(
+                token=f"{kind}-{key_id}", token_type=kind, username="", api_key_id=key_id, expires_at=expires
+            )
+            for kind in ("camera_stream", "websocket")  # media tokens never record the key
+        ]
+    )
+    await db.commit()
+
+
+async def _token_keys(db) -> list:
+    rows = await db.execute(
+        select(AuthEphemeralToken.api_key_id)
+        .where(AuthEphemeralToken.api_key_id.is_not(None))
+        .execution_options(populate_existing=True)
+    )
+    return sorted(rows.scalars().all())
+
+
+def _socket(api_key_id: int | None) -> MagicMock:
+    socket = MagicMock()
+    socket.state = SimpleNamespace(
+        bambuddy_scope_principal=("", api_key_id), bambuddy_principal_user_id=None, bambuddy_principal=""
+    )
+    socket.accept = AsyncMock()
+    socket.close = AsyncMock()
+    socket.send_json = AsyncMock()
+    socket.receive_json = AsyncMock(side_effect=WebSocketDisconnect())
+    return socket
+
+
+async def test_deleting_a_key_closes_its_sockets_and_drops_its_tokens(async_client: AsyncClient, admin, db_session):
+    gone, kept = await _key(db_session), await _key(db_session)
+    await _tokens(db_session, gone)
+    await _tokens(db_session, kept)
+    theirs, other = _socket(gone), _socket(kept)
+    ws_manager.active_connections.extend([theirs, other])
+    try:
+        response = await async_client.delete(f"/api/v1/api-keys/{gone}", headers=admin)
+
+        assert response.status_code == 200, response.text
+        assert await _token_keys(db_session) == [kept] * 2
+        theirs.close.assert_awaited_once_with(code=4401)
+        assert theirs not in ws_manager.active_connections
+        other.close.assert_not_awaited()
+    finally:
+        for socket in (theirs, other):
+            if socket in ws_manager.active_connections:
+                ws_manager.active_connections.remove(socket)
+
+
+async def test_deleting_a_user_does_the_same_for_their_keys(async_client: AsyncClient, admin, db_session):
+    created = await async_client.post(
+        "/api/v1/users/", headers=admin, json={"username": "keyowner", "password": "Userpass1!", "role": "user"}
+    )
+    owner = created.json()["id"]
+    key = await _key(db_session, user_id=owner)
+    await _tokens(db_session, key)
+    theirs = _socket(key)
+    ws_manager.active_connections.append(theirs)
+    try:
+        response = await async_client.delete(f"/api/v1/users/{owner}", headers=admin)
+
+        assert response.status_code == 204, response.text
+        assert await _token_keys(db_session) == []
+        theirs.close.assert_awaited_once_with(code=4401)
+    finally:
+        if theirs in ws_manager.active_connections:
+            ws_manager.active_connections.remove(theirs)
+
+
+async def _connect(key_id: int, connect=None) -> MagicMock:
+    from backend.app.api.routes import websocket as ws_route
+    from backend.app.core import database
+    from backend.app.core.printer_scope import ALL_PRINTERS
+
+    socket = _socket(None)
+    socket.state = SimpleNamespace()
+    with (
+        patch.object(ws_route, "is_auth_enabled", AsyncMock(return_value=True)),
+        patch.object(ws_route, "verify_websocket_token_principal", AsyncMock(return_value=("", key_id))),
+        patch.object(ws_route, "principal_printer_scope", AsyncMock(return_value=ALL_PRINTERS)),
+        patch.object(ws_route, "async_session", database.async_session),
+        patch.object(ws_manager, "connect", connect or ws_manager.connect),
+    ):
+        await ws_route.websocket_endpoint(socket, token="t")
+    return socket
+
+
+async def test_a_websocket_of_a_deleted_key_is_refused(async_client: AsyncClient):
+    socket = await _connect(9999)
+
+    socket.close.assert_awaited_once_with(code=4401)
+    socket.accept.assert_not_awaited()
+
+
+async def test_a_key_deleted_while_its_websocket_joins_closes_it(async_client: AsyncClient, db_session):
+    key = await _key(db_session)
+    joined = ws_manager.connect
+
+    async def join_while_deleted(websocket):
+        await db_session.execute(text("DELETE FROM api_keys WHERE id = :id"), {"id": key})
+        await db_session.commit()
+        await ws_manager.close_api_keys([key])  # what the delete does after its commit
+        await joined(websocket)
+
+    socket = await _connect(key, join_while_deleted)
+
+    socket.close.assert_awaited_once_with(code=4401)
+    assert socket not in ws_manager.active_connections
+
+
+async def test_the_startup_cleanup_drops_tokens_of_keys_that_are_gone(db_session):
+    from backend.app.core.database import _drop_ephemeral_tokens_of_missing_owners
+
+    kept = await _key(db_session)
+    await _tokens(db_session, kept)
+    await _tokens(db_session, kept + 50)  # deleted before deletes took them along
+
+    await _drop_ephemeral_tokens_of_missing_owners(await db_session.connection())
+    await db_session.commit()
+
+    assert await _token_keys(db_session) == [kept] * 2
+
+
+async def test_a_deleted_keys_message_rate_goes_too(async_client: AsyncClient, admin, db_session):
+    """The next key given the id would start out throttled."""
+    from backend.app.api.routes import notifications
+
+    key = await _key(db_session)
+    notifications._app_message_times[f"key:{key}"].extend([1.0] * notifications.APP_MESSAGE_LIMIT)
+    try:
+        response = await async_client.delete(f"/api/v1/api-keys/{key}", headers=admin)
+
+        assert response.status_code == 200, response.text
+        assert f"key:{key}" not in notifications._app_message_times
+    finally:
+        notifications._app_message_times.pop(f"key:{key}", None)
+
+
+@pytest.mark.parametrize("replaced", [False, True])
+async def test_a_scope_refresh_closes_a_socket_whose_key_is_gone(async_client: AsyncClient, db_session, replaced):
+    """Rotated from the command line, or an id that came free later: the socket
+    was re-scoped by id, taking the printers of whichever key had it next."""
+    from backend.app.core import database
+
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "keyadmin2", "admin_password": "AdminPass1!"},
+    )
+    key = await _key(db_session)
+    prefix = (await db_session.execute(select(APIKey.key_prefix).where(APIKey.id == key))).scalar_one()
+    socket = _socket(key)
+    socket.state.bambuddy_api_key_prefix = prefix
+    ws_manager.active_connections.append(socket)
+    await db_session.execute(text("DELETE FROM api_keys WHERE id = :id"), {"id": key})
+    await db_session.commit()
+    if replaced:  # a new key given the freed id
+        db_session.add(APIKey(id=key, name="new", key_hash="h2", key_prefix="bb_new", printer_ids=None))
+        await db_session.commit()
+    try:
+        with patch("backend.app.core.database.async_session", database.async_session):
+            await ws_manager.refresh_printer_scopes()
+
+        socket.close.assert_awaited_once_with(code=4401)
+        assert socket not in ws_manager.active_connections
+    finally:
+        if socket in ws_manager.active_connections:
+            ws_manager.active_connections.remove(socket)
+
+
+async def test_a_deleted_users_message_rate_goes_too(async_client: AsyncClient, admin):
+    """The next user given the id would start out throttled."""
+    from backend.app.api.routes import notifications
+
+    created = await async_client.post(
+        "/api/v1/users/", headers=admin, json={"username": "sender", "password": "Userpass1!", "role": "user"}
+    )
+    user = created.json()["id"]
+    notifications._app_message_times[f"user:{user}"].extend([1.0] * notifications.APP_MESSAGE_LIMIT)
+    try:
+        response = await async_client.delete(f"/api/v1/users/{user}", headers=admin)
+
+        assert response.status_code == 204, response.text
+        assert f"user:{user}" not in notifications._app_message_times
+    finally:
+        notifications._app_message_times.pop(f"user:{user}", None)

+ 2 - 2
backend/tests/integration/test_recreated_username.py

@@ -141,7 +141,7 @@ async def test_a_renamed_users_old_name_leaves_nothing_pending(async_client: Asy
 
 async def test_the_startup_cleanup_drops_tokens_of_users_that_are_gone(db_session):
     """Deleted before deletes took them along."""
-    from backend.app.core.database import _drop_ephemeral_tokens_of_missing_users
+    from backend.app.core.database import _drop_ephemeral_tokens_of_missing_owners
 
     db_session.add(User(username="Dave", password_hash="x", role="user"))
     await db_session.commit()
@@ -154,7 +154,7 @@ async def test_the_startup_cleanup_drops_tokens_of_users_that_are_gone(db_sessio
     )  # an API key's: no user
     await db_session.commit()
 
-    await _drop_ephemeral_tokens_of_missing_users(await db_session.connection())
+    await _drop_ephemeral_tokens_of_missing_owners(await db_session.connection())
     await db_session.commit()
 
     assert await _kinds(db_session, "ghost") == ["revoked_jti"]

+ 34 - 0
backend/tests/unit/test_cli.py

@@ -169,3 +169,37 @@ async def test_bootstrap_custom_name(session_maker):
         validated = await _validate_api_key(db, key)
         assert validated is not None
         assert validated.name == "custom-kiosk-name"
+
+
+@pytest.mark.asyncio
+@pytest.mark.unit
+async def test_rotating_drops_the_old_keys_links(session_maker):
+    """The new key gets a new id, and the old key's camera and WebSocket links go.
+
+    Deleted first, SQLite gave the new key the old one's id: the old (perhaps
+    leaked) key's links and open dashboards took the new key's printers, all of
+    them even when an admin had limited or disabled the old key.
+    """
+    from datetime import datetime, timedelta, timezone
+
+    from backend.app.models.auth_ephemeral import AuthEphemeralToken
+
+    await kiosk_bootstrap(DEFAULT_KIOSK_KEY_NAME, force=False, session_maker=session_maker, ensure_schema=False)
+    async with session_maker() as db:
+        old_id = (await db.execute(select(APIKey.id))).scalar_one()
+        expires = datetime.now(timezone.utc) + timedelta(hours=1)
+        db.add_all(
+            [
+                AuthEphemeralToken(
+                    token=f"{kind}-old", token_type=kind, username="", api_key_id=old_id, expires_at=expires
+                )
+                for kind in ("camera_stream", "websocket")
+            ]
+        )
+        await db.commit()
+
+    await kiosk_bootstrap(DEFAULT_KIOSK_KEY_NAME, force=True, session_maker=session_maker, ensure_schema=False)
+
+    async with session_maker() as db:
+        assert (await db.execute(select(APIKey.id))).scalar_one() != old_id
+        assert (await db.execute(select(AuthEphemeralToken.token))).scalars().all() == []