diff --git a/navi/auth/deps.py b/navi/auth/deps.py index e85dd4c..fff4936 100644 --- a/navi/auth/deps.py +++ b/navi/auth/deps.py @@ -17,96 +17,107 @@ async def _resolve_user(conn) -> User | None: - """Shared logic to resolve user from a connection object (Request or WebSocket).""" - if conn is None: - return None + """Shared logic to resolve user from a connection object (Request or WebSocket). - # Return cached user if already resolved this request - if hasattr(conn.state, "user") and conn.state.user is not None: - return conn.state.user - - # Auth not configured — treat as anonymous - if not settings.gnauth_client_id or not settings.gnauth_client_secret: - return None - - cookie_name = settings.navi_auth_cookie_name - session_id = conn.cookies.get(cookie_name) - if not session_id: - return None - - # Look up the auth session in DB + Any failure during resolution is silently treated as anonymous to avoid + crashing WebSocket upgrades or other dependency-injected paths. + """ try: - from navi.api.deps import get_session_store - except Exception: - # Avoid circular import during early bootstrap - return None - - store = get_session_store() - row = await _get_auth_session(store, session_id) - if row is None: - return None - - encryptor = get_encryptor() - access_token = encryptor.decrypt(row["access_token_enc"]) - expires_at = row["expires_at"] - - client = get_gauth_client() - - # Refresh if expired - if datetime.now(timezone.utc) > expires_at: - try: - refresh_token = encryptor.decrypt(row["refresh_token_enc"]) - token_set = await asyncio.to_thread(client.refresh_token, refresh_token) - access_token = token_set.access_token - # Update DB with new tokens - await _update_auth_session( - store, - session_id, - encryptor.encrypt(access_token), - encryptor.encrypt(token_set.refresh_token or refresh_token), - token_set.expires_at or datetime.now(timezone.utc), - ) - log.info("auth.token_refreshed", user_id=row["user_id"]) - except Exception: - log.warning("auth.refresh_failed", session_id=session_id[:8]) - # Refresh failed — treat as unauthenticated - await _delete_auth_session(store, session_id) + if conn is None: return None - # Fetch user from gnexus-auth - try: - auth_user = await asyncio.to_thread(client.fetch_user, access_token) + # Return cached user if already resolved this request + if hasattr(conn.state, "user") and conn.state.user is not None: + return conn.state.user + + # Auth not configured — treat as anonymous + if not settings.gnauth_client_id or not settings.gnauth_client_secret: + return None + + cookie_name = settings.navi_auth_cookie_name + session_id = conn.cookies.get(cookie_name) + if not session_id: + return None + + # Look up the auth session in DB + try: + from navi.api.deps import get_session_store + except Exception: + # Avoid circular import during early bootstrap + return None + + store = get_session_store() + row = await _get_auth_session(store, session_id) + if row is None: + return None + + encryptor = get_encryptor() + access_token = encryptor.decrypt(row["access_token_enc"]) + expires_at = row["expires_at"] + + client = get_gauth_client() + + # Refresh if expired + if datetime.now(timezone.utc) > expires_at: + try: + refresh_token = encryptor.decrypt(row["refresh_token_enc"]) + token_set = await asyncio.to_thread(client.refresh_token, refresh_token) + access_token = token_set.access_token + # Update DB with new tokens + await _update_auth_session( + store, + session_id, + encryptor.encrypt(access_token), + encryptor.encrypt(token_set.refresh_token or refresh_token), + token_set.expires_at or datetime.now(timezone.utc), + ) + log.info("auth.token_refreshed", user_id=row["user_id"]) + except Exception: + log.warning("auth.refresh_failed", session_id=session_id[:8]) + # Refresh failed — treat as unauthenticated + await _delete_auth_session(store, session_id) + return None + + # Fetch user from gnexus-auth + try: + auth_user = await asyncio.to_thread(client.fetch_user, access_token) + except Exception: + log.warning("auth.fetch_user_failed", session_id=session_id[:8]) + return None + + # Determine role from client-level role_ids + role = "user" + permissions: list[str] = [] + for access in auth_user.client_access_list: + if access.client_id == settings.gnauth_client_id: + if settings.gnauth_admin_role_slug in (access.role_ids or []): + role = "admin" + permissions = list(access.permission_ids or []) + break + + # Upsert into navi_users + await _upsert_navi_user(store, auth_user.user_id, auth_user.email, auth_user.profile.get("display_name"), role, permissions) + + user = User( + id=auth_user.user_id, + email=auth_user.email, + display_name=auth_user.profile.get("display_name") or auth_user.email, + avatar_url=auth_user.avatar_url, + role=role, + permissions=permissions, + ) + + # Update last_used_at + await _touch_auth_session(store, session_id) + conn.state.user = user + return user except Exception: - log.warning("auth.fetch_user_failed", session_id=session_id[:8]) + # Any unexpected failure during auth resolution should not crash the + # request — treat as anonymous so WebSocket upgrades and REST calls + # degrade gracefully. + log.warning("auth.resolve_failed", exc_info=True) return None - # Determine role from client-level role_ids - role = "user" - permissions: list[str] = [] - for access in auth_user.client_access_list: - if access.client_id == settings.gnauth_client_id: - if settings.gnauth_admin_role_slug in (access.role_ids or []): - role = "admin" - permissions = list(access.permission_ids or []) - break - - # Upsert into navi_users - await _upsert_navi_user(store, auth_user.user_id, auth_user.email, auth_user.profile.get("display_name"), role, permissions) - - user = User( - id=auth_user.user_id, - email=auth_user.email, - display_name=auth_user.profile.get("display_name") or auth_user.email, - avatar_url=auth_user.avatar_url, - role=role, - permissions=permissions, - ) - - # Update last_used_at - await _touch_auth_session(store, session_id) - conn.state.user = user - return user - async def get_current_user(request: Request) -> User | None: """Resolve the current user from the auth session cookie for REST requests.""" diff --git a/navi/memory/_ddl.py b/navi/memory/_ddl.py index fbb3444..53c4b49 100644 --- a/navi/memory/_ddl.py +++ b/navi/memory/_ddl.py @@ -17,6 +17,9 @@ # Migrate unique constraint from (category, key) to (user_id, category, key) "DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'memory_facts_category_key_key') THEN ALTER TABLE memory_facts DROP CONSTRAINT memory_facts_category_key_key; END IF; END $$;", "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'memory_facts_user_cat_key') THEN ALTER TABLE memory_facts ADD CONSTRAINT memory_facts_user_cat_key UNIQUE (user_id, category, key); END IF; END $$;", + # Ensure memory_summary has unique constraint on (id, user_id) for ON CONFLICT + "DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'memory_summary_id_user_key') THEN ALTER TABLE memory_summary ADD CONSTRAINT memory_summary_id_user_key UNIQUE (id, user_id); END IF; END $$;", + """CREATE TABLE IF NOT EXISTS memory_facts ( id TEXT PRIMARY KEY, user_id TEXT REFERENCES navi_users(id) ON DELETE CASCADE,