From f47af6c8505aadf2d104d07a751d036e5d9aa6c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 18:47:50 +0000 Subject: [PATCH 1/2] fix: clear stale connectionId after impersonation changes Root cause: When an admin uses 'View as' to impersonate a user, the backend correctly returns only the connections visible to the target user. However, the frontend's useConnectionManager hook had an early return when connectionId was set, without validating that the connection was actually in the new list. This caused the UI to keep a stale connectionId from before the impersonation, resulting in 403 errors when chat/agent/editor APIs were called with a connection the effective user cannot access. Fix: 1. In useConnectionManager.js: Added validation that connectionId exists in the connections array. If not, clear it and auto-select from valid options. 2. In all section components using useConnectionManager: - Added isLoading check to prevent rendering during connection refresh - Added selectedConnection validation alongside connectionId check - This ensures we never render a section with a stale connection that's not in the effective user's accessible list Affected sections: - AgentChatSection - EditorSection - DashboardsSection - SchemaSection - SchemaDocsSection - SlowQueriesSection - DigestSection - CompanyKnowledgeSection - MonitorSection Co-authored-by: Venkat SF --- src/components/sections/AgentChatSection.jsx | 23 ++++++++++++--- .../sections/CompanyKnowledgeSection.jsx | 16 ++++++++-- src/components/sections/DashboardsSection.jsx | 16 ++++++++-- src/components/sections/DigestSection.jsx | 29 ++++++++++++++++--- src/components/sections/EditorSection.jsx | 16 ++++++++-- src/components/sections/MonitorSection.jsx | 17 +++++++++-- src/components/sections/SchemaDocsSection.jsx | 16 ++++++++-- src/components/sections/SchemaSection.jsx | 16 ++++++++-- .../sections/SectionEmpty.module.css | 8 +++++ .../sections/SlowQueriesSection.jsx | 23 +++++++++++++-- .../sections/SlowQueriesSection.module.css | 11 +++++++ src/lib/hooks/useConnectionManager.js | 20 +++++++++++-- 12 files changed, 180 insertions(+), 31 deletions(-) diff --git a/src/components/sections/AgentChatSection.jsx b/src/components/sections/AgentChatSection.jsx index de4fd44..4f08c53 100644 --- a/src/components/sections/AgentChatSection.jsx +++ b/src/components/sections/AgentChatSection.jsx @@ -3,10 +3,25 @@ import { useAuth } from '@/hooks/useAuth' import AgentChatPanel from '@/components/AgentChat/AgentChatPanel' export default function AgentChatSection() { - const { connectionId, selectedConnection } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() const { username } = useAuth() - if (!connectionId) { + // Wait for the connection list to load before rendering anything. Without this, + // we might render the agent panel with a stale connectionId from before an + // impersonation change — the new user's connection list hasn't loaded yet, so + // selectedConnection is undefined, but connectionId is still the old value. + if (isLoading) { + return ( +
+ Loading connections… +
+ ) + } + + // Either no connection selected, or the selected connectionId is not in the + // current user's connections (stale after impersonation). Both cases mean the + // user needs to pick a valid connection before chatting. + if (!connectionId || !selectedConnection) { return (
Select a database connection to chat with the DeepSQL Agent. @@ -20,8 +35,8 @@ export default function AgentChatSection() { ) } diff --git a/src/components/sections/CompanyKnowledgeSection.jsx b/src/components/sections/CompanyKnowledgeSection.jsx index 9ed0bc2..0b69702 100644 --- a/src/components/sections/CompanyKnowledgeSection.jsx +++ b/src/components/sections/CompanyKnowledgeSection.jsx @@ -1,12 +1,22 @@ -import { Building2 } from 'lucide-react' +import { Building2, Loader2 } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import CompanyKnowledgePanel from '@/components/company-knowledge/CompanyKnowledgePanel' import styles from './SectionEmpty.module.css' export default function CompanyKnowledgeSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/DashboardsSection.jsx b/src/components/sections/DashboardsSection.jsx index c2476c6..efc770c 100644 --- a/src/components/sections/DashboardsSection.jsx +++ b/src/components/sections/DashboardsSection.jsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react' -import { LayoutDashboard } from 'lucide-react' +import { LayoutDashboard, Loader2 } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import { useSetImmersive } from '@/lib/stores/useNavStore' import DashboardsHome from './DashboardsHome' @@ -10,7 +10,7 @@ import emptyStyles from './SectionEmpty.module.css' // dashboard, a focused full-bleed builder workspace (sidebar hidden via the // nav store's immersive flag). export default function DashboardsSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() const setImmersive = useSetImmersive() const [open_, setOpen] = useState(null) // null = gallery; 'new' | dashboard object = workspace @@ -22,7 +22,17 @@ export default function DashboardsSection() { // Safety: never leave the app in immersive mode when this section unmounts. useEffect(() => () => setImmersive(false), [setImmersive]) - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/DigestSection.jsx b/src/components/sections/DigestSection.jsx index e8f89a5..8bdcaf4 100644 --- a/src/components/sections/DigestSection.jsx +++ b/src/components/sections/DigestSection.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Newspaper, RefreshCw, Settings, Check, Clock, AlertCircle, Zap } from 'lucide-react' +import { Loader2, Newspaper, RefreshCw, Settings, Check, Clock, AlertCircle, Zap } from 'lucide-react' import { slackDigestAPI, digestPreferencesAPI } from '@/lib/api/client' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import DigestPreferencesPanel from './DigestPreferencesPanel' @@ -165,7 +165,7 @@ function DigestSection({ section }) { const DIGEST_PREFS_AUTOPEN_KEY = 'deepsql.digestPrefs.autoOpened.v1' export default function DigestFeedSection() { - const { connectionId, selectedConnection } = useConnectionManager() + const { connectionId, selectedConnection, isLoading: connectionsLoading } = useConnectionManager() const [digests, setDigests] = useState([]) const [loading, setLoading] = useState(false) const [triggering, setTriggering] = useState(false) @@ -257,6 +257,26 @@ export default function DigestFeedSection() { } } + // Wait for connection list to load first + if (connectionsLoading) { + return ( +
+
+
+ + DB Digest +
+
+
+
+ + Loading connections… +
+
+
+ ) + } + return (
{/* Top bar */} @@ -309,7 +329,8 @@ export default function DigestFeedSection() {
)} - {!error && !loading && !connectionId && ( + {/* Either no connection or stale connectionId not in the effective user's list */} + {!error && !loading && (!connectionId || !selectedConnection) && (

No connection selected

@@ -317,7 +338,7 @@ export default function DigestFeedSection() {
)} - {!error && !loading && !!connectionId && digests.length === 0 && ( + {!error && !loading && !!connectionId && !!selectedConnection && digests.length === 0 && (

No digests yet

diff --git a/src/components/sections/EditorSection.jsx b/src/components/sections/EditorSection.jsx index 9229c20..a3e1fee 100644 --- a/src/components/sections/EditorSection.jsx +++ b/src/components/sections/EditorSection.jsx @@ -1,12 +1,22 @@ import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import SqlRunnerTab from '@/components/tabs/Core/SqlRunnerTab' -import { Code2 } from 'lucide-react' +import { Code2, Loader2 } from 'lucide-react' import styles from './SectionEmpty.module.css' export default function EditorSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection selected, or stale connectionId not in the current user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/MonitorSection.jsx b/src/components/sections/MonitorSection.jsx index 61022fa..6fe8685 100644 --- a/src/components/sections/MonitorSection.jsx +++ b/src/components/sections/MonitorSection.jsx @@ -1,12 +1,12 @@ import { useEffect, useState } from 'react' -import { BarChart2 } from 'lucide-react' +import { BarChart2, Loader2 } from 'lucide-react' import { slowQueriesAPI } from '@/lib/api/client' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import AnalyticsTab from '@/components/tabs/Monitoring/AnalyticsTab' import styles from './SectionEmpty.module.css' export default function MonitorSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading: connectionsLoading } = useConnectionManager() const [hasData, setHasData] = useState(null) // null = loading const [loading, setLoading] = useState(true) @@ -27,7 +27,18 @@ export default function MonitorSection() { .finally(() => setLoading(false)) }, [connectionId]) - if (!connectionId) { + // Wait for connection list to load first + if (connectionsLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/SchemaDocsSection.jsx b/src/components/sections/SchemaDocsSection.jsx index b54deba..19f97a0 100644 --- a/src/components/sections/SchemaDocsSection.jsx +++ b/src/components/sections/SchemaDocsSection.jsx @@ -1,4 +1,4 @@ -import { FileText } from 'lucide-react' +import { FileText, Loader2 } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import { useSetActiveSection } from '@/lib/stores/useNavStore' import { useCompanyKnowledgeStore } from '@/lib/stores/useCompanyKnowledgeStore' @@ -7,11 +7,21 @@ import styles from './SectionEmpty.module.css' import workspaceStyles from './TopLevelSection.module.css' export default function SchemaDocsSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() const setActiveSection = useSetActiveSection() const setLinkedFilters = useCompanyKnowledgeStore((state) => state.setLinkedFilters) - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/SchemaSection.jsx b/src/components/sections/SchemaSection.jsx index ea310fb..81488d7 100644 --- a/src/components/sections/SchemaSection.jsx +++ b/src/components/sections/SchemaSection.jsx @@ -1,12 +1,22 @@ -import { Network } from 'lucide-react' +import { Network, Loader2 } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import BrainWorkspace from '@/components/tabs/Brain/BrainWorkspace' import styles from './SectionEmpty.module.css' export default function SchemaSection() { - const { connectionId } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() - if (!connectionId) { + if (isLoading) { + return ( +
+ +

Loading connections…

+
+ ) + } + + // Either no connection or stale connectionId not in the effective user's list + if (!connectionId || !selectedConnection) { return (
diff --git a/src/components/sections/SectionEmpty.module.css b/src/components/sections/SectionEmpty.module.css index 6c9435b..db096c1 100644 --- a/src/components/sections/SectionEmpty.module.css +++ b/src/components/sections/SectionEmpty.module.css @@ -72,6 +72,14 @@ background: #f9fafb; } +.spin { + animation: sectionSpin 1s linear infinite; +} + +@keyframes sectionSpin { + to { transform: rotate(360deg); } +} + .pills { display: flex; gap: 8px; diff --git a/src/components/sections/SlowQueriesSection.jsx b/src/components/sections/SlowQueriesSection.jsx index 44af4c0..7cfbe58 100644 --- a/src/components/sections/SlowQueriesSection.jsx +++ b/src/components/sections/SlowQueriesSection.jsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react' -import { Activity, FileText, LineChart, Settings, Users } from 'lucide-react' +import { Activity, FileText, LineChart, Loader2, Settings, Users } from 'lucide-react' import { useConnectionManager } from '@/lib/hooks/useConnectionManager' import { useSlowLogSourceConfig } from '@/lib/hooks/queries' import QueryTrendsTab from '@/components/tabs/Performance/QueryTrendsTab' @@ -31,7 +31,7 @@ const LOG_SOURCE_HELP = { * is a single empty state whose CTA opens SlowQuerySourceModal. */ export default function SlowQueriesSection() { - const { connectionId, selectedConnection } = useConnectionManager() + const { connectionId, selectedConnection, isLoading } = useConnectionManager() const [tab, setTab] = useState('trends') const tabRefs = useRef({}) @@ -59,6 +59,22 @@ export default function SlowQueriesSection() { const logSourceQ = useSlowLogSourceConfig(connectionId) const hasLogSource = Boolean(logSourceQ.data?.id) + // Wait for connection list to load before rendering anything + if (isLoading) { + return ( +
+
+
Performance
+

Slow queries & workload

+
+
+ + Loading connections… +
+
+ ) + } + return (
@@ -70,7 +86,8 @@ export default function SlowQueriesSection() {

- {!connectionId ? ( + {/* Either no connection or stale connectionId not in the effective user's list */} + {(!connectionId || !selectedConnection) ? (
Select a database connection to see performance analytics.
diff --git a/src/components/sections/SlowQueriesSection.module.css b/src/components/sections/SlowQueriesSection.module.css index 3b356ad..3f89f5f 100644 --- a/src/components/sections/SlowQueriesSection.module.css +++ b/src/components/sections/SlowQueriesSection.module.css @@ -141,3 +141,14 @@ border-color: #d1d5db; color: #111827; } + +.spinIcon { + animation: spin 1s linear infinite; + display: inline-block; + vertical-align: middle; + margin-right: 8px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/src/lib/hooks/useConnectionManager.js b/src/lib/hooks/useConnectionManager.js index 0f497a0..59a76e2 100644 --- a/src/lib/hooks/useConnectionManager.js +++ b/src/lib/hooks/useConnectionManager.js @@ -56,6 +56,11 @@ export function useConnectionManager() { // selected and a pin that only ran on a blank slate would never actually be the // default — which is the whole feature. It applies once per page load // (`pinAppliedThisLoad`), so switching connections mid-session still sticks. + // + // Critical: if `connectionId` is set but NOT in the connections list (e.g. after + // "View as" switches to a user without access to that connection), clear it and + // select a valid one. Otherwise the UI keeps a stale connection the effective user + // cannot access, and chat/agent calls 403. useEffect(() => { if (connections.length === 0) return @@ -69,12 +74,23 @@ export function useConnectionManager() { } } - if (connectionId) return + // Validate the selected connection is actually in the current user's list. + // When impersonation changes, the connection list is for the target user, but + // connectionId might still be a connection the admin had selected. Clear it + // so the user must pick from their own list (or gets auto-selected below). + if (connectionId) { + const stillValid = connections.some((c) => c.id === connectionId) + if (stillValid) return + // The selected connection is not in the list — clear it and fall through + // to auto-select from the valid options. + localStorage.removeItem('selectedConnectionId') + setConnectionId(null) + } const savedId = localStorage.getItem('selectedConnectionId') const saved = savedId ? connections.find((c) => c.id === savedId) : null selectConnection((pinned || saved || connections[0]).id) - }, [connections, connectionId, selectConnection]) + }, [connections, connectionId, selectConnection, setConnectionId]) const changeConnection = useCallback( (connId) => { From 9261ddf88bd405fb6dc382f8cceead5f92b44d5e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 23 Sep 2026 18:49:44 +0000 Subject: [PATCH 2/2] test: add test for impersonation with inaccessible connection Verifies that when an admin uses 'View as' to impersonate a user who lacks access to a connection, the backend correctly denies access to that connection. This is the backend counterpart to the frontend fix - both layers must refuse access to ensure the security model holds. Co-authored-by: Venkat SF --- .../security/AccessControlServiceTest.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java b/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java index 5551446..afc149f 100644 --- a/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java +++ b/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java @@ -359,6 +359,44 @@ void impersonatingDbaAllowsMutateSql() { assertFalse(accessControlService.isCurrentUserAdmin()); } + /** + * An admin viewing as a user who lacks access to a connection sees access denied. + * + *

This is the backend counterpart to the frontend fix: when the admin selects a + * connection, then uses "View as" to switch to a user without access to that connection, + * the UI should not be able to make API calls against the connection. The UI fix clears + * the stale connectionId; this test ensures the backend also correctly denies access. + */ + @Test + void impersonatingUserWithoutAccessDeniesConnection() { + com.dbaagent.model.User impersonator = new com.dbaagent.model.User(); + impersonator.setId(1L); + impersonator.setUsername("admin"); + impersonator.setRole("ADMIN"); + com.dbaagent.model.User target = new com.dbaagent.model.User(); + target.setId(2L); + target.setUsername("mart-viewer"); + target.setRole("DEVELOPER"); + com.dbaagent.security.ImpersonationContext.enter( + new com.dbaagent.security.ImpersonationContext.State(impersonator, target) + ); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("mart-viewer", null, List.of()) + ); + + // The target user has no grant on conn-1 + when(connectionAccessService.resolveAccess("conn-1", "mart-viewer", false)) + .thenReturn(resolved("conn-1", EffectiveConnectionAccess.NONE, null)); + + // Verify: admin bypass is disabled during impersonation + assertFalse(accessControlService.isCurrentUserAdmin()); + + // Verify: attempting to access the connection should throw 403 + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> accessControlService.assertCanUseChatEditor("conn-1")); + assertEquals(403, ex.getStatusCode().value()); + } + private ConnectionAccessService.ResolvedConnectionAccess resolved( String connectionId, EffectiveConnectionAccess effectiveAccess,