From a82578f342b54fc107322a00fcf9898f38f83d2d Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Tue, 28 Jul 2026 18:02:14 +0000 Subject: [PATCH] fix: invalidate tokens on permission change (medium), optimize create_user query, fix ws reconnect race - update_permissions now calls blacklist_active_refresh_token and rotate_user_secret to immediately invalidate stale tokens - create_user uses returned id from tx.run_one instead of redundant SELECT - websocket reconnect explicitly closes old connection after token refresh to prevent onclose handler race condition --- lib/auth_users.py | 12 ++++++++---- webui/static/hoover/websocket.js | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/auth_users.py b/lib/auth_users.py index b1f64e8..ccdf54e 100644 --- a/lib/auth_users.py +++ b/lib/auth_users.py @@ -160,13 +160,13 @@ def create_user( db = get_db() with db.in_transaction() as tx: - tx.run_one(Q_INSERT_USER, (username, password_hash, jwt_secret)) + user_id = tx.run_one(Q_INSERT_USER, (username, password_hash, jwt_secret)) if permissions: for subsystem, level in permissions.items(): tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level)) return { - "id": db.query(Q_SELECT_USER_BY_NAME, (username,))[0]["id"], + "id": user_id, "username": username, "permissions": _get_permissions(username), } @@ -202,9 +202,11 @@ def update_password(username: str, old_password: str, new_password: str) -> bool def update_permissions(username: str, permissions: dict[str, str]) -> None: - """Update a user's permissions. + """Update a user's permissions and invalidate all existing tokens. - Replaces all existing permissions with the provided mapping. + Replaces all existing permissions with the provided mapping. Rotates the + JWT secret so that permission changes take effect immediately — existing + tokens with stale permissions are no longer valid. Args: username: The username. @@ -219,6 +221,8 @@ def update_permissions(username: str, permissions: dict[str, str]) -> None: tx.run(Q_DELETE_PERMISSIONS, (username,)) for subsystem, level in permissions.items(): tx.run(Q_UPSERT_PERMISSION, (username, subsystem, level)) + blacklist_active_refresh_token(username) + rotate_user_secret(username) def list_users() -> list[dict[str, Any]]: diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index a73890a..8545867 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -59,12 +59,14 @@ function _wsConnect() { if (_wsFailCount >= 3 && !_wsRefreshing) { _wsRefreshing = true; + const oldConn = _wsConn; tryRefreshToken().then(ok => { _wsRefreshing = false; if (ok) { _wsFailCount = 0; _wsReconnectMs = 0; _wsConn = null; + if (oldConn) oldConn.close(); setTimeout(_wsConnect, 100); } else { redirectLogin();