Skip to content

Commit 27d07ea

Browse files
fix: clear stale connectionId after impersonation changes (#121)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Problem When an admin uses "View as" to impersonate a user who doesn't have access to the currently selected connection, the UI shows a 403 error ("chat and editor access denied for this connection"). ### Reproduction Steps 1. Admin selects `aws-rds-master` connection (only Arun/Gaurav have grants) 2. Admin uses "View as" to switch to a user without access to that connection 3. User sees 403 error in Agent/Editor/other connection-scoped sections ### User Experience The user is stuck on a connection they cannot access, with no clear way to recover. The connection appears selected in the sidebar, but all API calls fail with 403. ## Root Cause The frontend's `useConnectionManager` hook had an early return when `connectionId` was set, without validating that the connection was actually in the current user's `connections` array. When impersonation changes: 1. Backend correctly returns only connections visible to the target user via `ConnectionAccessService.getVisibleConnections()` 2. Frontend receives the new (filtered) connection list 3. **Bug**: The hook's `useEffect` returned early if `connectionId` was set, keeping the stale connection 4. Section components then made API calls with the stale `connectionId`, getting 403 ```javascript // Before (buggy): useEffect(() => { if (connections.length === 0) return if (connectionId) return // Early return without validation! // ...auto-select logic }, [connections, connectionId, ...]) ``` ## Fix ### 1. Core fix in `useConnectionManager.js` Added validation that `connectionId` exists in the `connections` array. If not, clear it and auto-select from valid options: ```javascript // After (fixed): if (connectionId) { const stillValid = connections.some((c) => c.id === connectionId) if (stillValid) return // Clear the stale connection localStorage.removeItem('selectedConnectionId') setConnectionId(null) } ``` ### 2. Defensive guards in section components Added guards in all connection-scoped sections to prevent rendering with invalid connections: - Check `isLoading` first - prevents rendering during connection list refresh - Check both `connectionId` AND `selectedConnection` - ensures the connection is valid This pattern was applied to all affected sections: - `AgentChatSection` - `EditorSection` - `DashboardsSection` - `SchemaSection` - `SchemaDocsSection` - `SlowQueriesSection` - `DigestSection` - `CompanyKnowledgeSection` - `MonitorSection` ### 3. Backend test added Added `impersonatingUserWithoutAccessDeniesConnection()` test to verify the backend correctly denies access when an admin views as a user who lacks access to a connection. ## Security Considerations **This fix does NOT weaken security:** - The backend grant model remains unchanged - Admin bypass for connection access is preserved - All API authorization checks (`assertCanReadConnectionContent`, `assertCanUseChatEditor`, etc.) remain in place - This fix only ensures the UI doesn't present inaccessible connections as usable ## Testing The fix ensures: 1. When impersonation changes, stale connections are cleared 2. Users see a proper "no connection selected" state instead of 403 errors 3. Auto-selection picks from the effective user's accessible connections ### Scenarios Covered - ✅ Admin can access any connection (admin bypass preserved) - ✅ Granted user can access assigned connections - ✅ User without grant sees empty/appropriate state - ✅ Impersonation where target user lacks access to admin's selected connection ### Test Results - `AccessControlServiceTest` passes (including new impersonation test) - Frontend lint passes (no new errors introduced) ## Note for Stayflexi Deployment On Stayflexi, only Arun and Gaurav have grants on the `aws-rds-master` connection. Other users viewing as them or accessing that connection will now see a proper "no connection selected" state rather than a confusing 403 error. If broader access is needed, connection grants should be added via **Manage Connections → Share**. ## Files Changed | File | Purpose | |------|---------| | `src/lib/hooks/useConnectionManager.js` | Core fix: validate connectionId against connections array | | `src/components/sections/*.jsx` | Add isLoading and selectedConnection guards | | `src/components/sections/*.module.css` | Add loading spinner animation | | `backend/.../AccessControlServiceTest.java` | Add test for impersonation with inaccessible connection | <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-33392497-bd30-5d7a-a359-0dba4888d535?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-33392497-bd30-5d7a-a359-0dba4888d535&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent b4a6165 commit 27d07ea

13 files changed

Lines changed: 218 additions & 31 deletions

‎backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,44 @@ void impersonatingDbaAllowsMutateSql() {
359359
assertFalse(accessControlService.isCurrentUserAdmin());
360360
}
361361

362+
/**
363+
* An admin viewing as a user who lacks access to a connection sees access denied.
364+
*
365+
* <p>This is the backend counterpart to the frontend fix: when the admin selects a
366+
* connection, then uses "View as" to switch to a user without access to that connection,
367+
* the UI should not be able to make API calls against the connection. The UI fix clears
368+
* the stale connectionId; this test ensures the backend also correctly denies access.
369+
*/
370+
@Test
371+
void impersonatingUserWithoutAccessDeniesConnection() {
372+
com.dbaagent.model.User impersonator = new com.dbaagent.model.User();
373+
impersonator.setId(1L);
374+
impersonator.setUsername("admin");
375+
impersonator.setRole("ADMIN");
376+
com.dbaagent.model.User target = new com.dbaagent.model.User();
377+
target.setId(2L);
378+
target.setUsername("mart-viewer");
379+
target.setRole("DEVELOPER");
380+
com.dbaagent.security.ImpersonationContext.enter(
381+
new com.dbaagent.security.ImpersonationContext.State(impersonator, target)
382+
);
383+
SecurityContextHolder.getContext().setAuthentication(
384+
new UsernamePasswordAuthenticationToken("mart-viewer", null, List.of())
385+
);
386+
387+
// The target user has no grant on conn-1
388+
when(connectionAccessService.resolveAccess("conn-1", "mart-viewer", false))
389+
.thenReturn(resolved("conn-1", EffectiveConnectionAccess.NONE, null));
390+
391+
// Verify: admin bypass is disabled during impersonation
392+
assertFalse(accessControlService.isCurrentUserAdmin());
393+
394+
// Verify: attempting to access the connection should throw 403
395+
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
396+
() -> accessControlService.assertCanUseChatEditor("conn-1"));
397+
assertEquals(403, ex.getStatusCode().value());
398+
}
399+
362400
private ConnectionAccessService.ResolvedConnectionAccess resolved(
363401
String connectionId,
364402
EffectiveConnectionAccess effectiveAccess,

‎src/components/sections/AgentChatSection.jsx‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,25 @@ import { useAuth } from '@/hooks/useAuth'
33
import AgentChatPanel from '@/components/AgentChat/AgentChatPanel'
44

55
export default function AgentChatSection() {
6-
const { connectionId, selectedConnection } = useConnectionManager()
6+
const { connectionId, selectedConnection, isLoading } = useConnectionManager()
77
const { username } = useAuth()
88

9-
if (!connectionId) {
9+
// Wait for the connection list to load before rendering anything. Without this,
10+
// we might render the agent panel with a stale connectionId from before an
11+
// impersonation change — the new user's connection list hasn't loaded yet, so
12+
// selectedConnection is undefined, but connectionId is still the old value.
13+
if (isLoading) {
14+
return (
15+
<div style={{ padding: 40, color: '#6b7280', fontSize: 14 }}>
16+
Loading connections…
17+
</div>
18+
)
19+
}
20+
21+
// Either no connection selected, or the selected connectionId is not in the
22+
// current user's connections (stale after impersonation). Both cases mean the
23+
// user needs to pick a valid connection before chatting.
24+
if (!connectionId || !selectedConnection) {
1025
return (
1126
<div style={{ padding: 40, color: '#6b7280', fontSize: 14 }}>
1227
Select a database connection to chat with the DeepSQL Agent.
@@ -20,8 +35,8 @@ export default function AgentChatSection() {
2035
<AgentChatPanel
2136
key={`${username || 'anon'}:${connectionId}`}
2237
connectionId={connectionId}
23-
connectionName={selectedConnection?.connectionName}
24-
canManageContent={Boolean(selectedConnection?.canManageContent)}
38+
connectionName={selectedConnection.connectionName}
39+
canManageContent={Boolean(selectedConnection.canManageContent)}
2540
/>
2641
)
2742
}

‎src/components/sections/CompanyKnowledgeSection.jsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1-
import { Building2 } from 'lucide-react'
1+
import { Building2, Loader2 } from 'lucide-react'
22
import { useConnectionManager } from '@/lib/hooks/useConnectionManager'
33
import CompanyKnowledgePanel from '@/components/company-knowledge/CompanyKnowledgePanel'
44
import styles from './SectionEmpty.module.css'
55

66
export default function CompanyKnowledgeSection() {
7-
const { connectionId } = useConnectionManager()
7+
const { connectionId, selectedConnection, isLoading } = useConnectionManager()
88

9-
if (!connectionId) {
9+
if (isLoading) {
10+
return (
11+
<div className={styles.root}>
12+
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
13+
<p className={styles.subtitle}>Loading connections…</p>
14+
</div>
15+
)
16+
}
17+
18+
// Either no connection or stale connectionId not in the effective user's list
19+
if (!connectionId || !selectedConnection) {
1020
return (
1121
<div className={styles.root}>
1222
<div className={styles.iconWrap}><Building2 size={26} color="#9ca3af" /></div>

‎src/components/sections/DashboardsSection.jsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useCallback, useEffect } from 'react'
2-
import { LayoutDashboard } from 'lucide-react'
2+
import { LayoutDashboard, Loader2 } from 'lucide-react'
33
import { useConnectionManager } from '@/lib/hooks/useConnectionManager'
44
import { useSetImmersive } from '@/lib/stores/useNavStore'
55
import DashboardsHome from './DashboardsHome'
@@ -10,7 +10,7 @@ import emptyStyles from './SectionEmpty.module.css'
1010
// dashboard, a focused full-bleed builder workspace (sidebar hidden via the
1111
// nav store's immersive flag).
1212
export default function DashboardsSection() {
13-
const { connectionId } = useConnectionManager()
13+
const { connectionId, selectedConnection, isLoading } = useConnectionManager()
1414
const setImmersive = useSetImmersive()
1515
const [open_, setOpen] = useState(null) // null = gallery; 'new' | dashboard object = workspace
1616

@@ -22,7 +22,17 @@ export default function DashboardsSection() {
2222
// Safety: never leave the app in immersive mode when this section unmounts.
2323
useEffect(() => () => setImmersive(false), [setImmersive])
2424

25-
if (!connectionId) {
25+
if (isLoading) {
26+
return (
27+
<div className={emptyStyles.root}>
28+
<Loader2 size={24} color="#9ca3af" className={emptyStyles.spin} />
29+
<p className={emptyStyles.subtitle}>Loading connections…</p>
30+
</div>
31+
)
32+
}
33+
34+
// Either no connection or stale connectionId not in the effective user's list
35+
if (!connectionId || !selectedConnection) {
2636
return (
2737
<div className={emptyStyles.root}>
2838
<div className={emptyStyles.iconWrap}><LayoutDashboard size={26} color="#9ca3af" /></div>

‎src/components/sections/DigestSection.jsx‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useCallback } from 'react'
2-
import { Newspaper, RefreshCw, Settings, Check, Clock, AlertCircle, Zap } from 'lucide-react'
2+
import { Loader2, Newspaper, RefreshCw, Settings, Check, Clock, AlertCircle, Zap } from 'lucide-react'
33
import { slackDigestAPI, digestPreferencesAPI } from '@/lib/api/client'
44
import { useConnectionManager } from '@/lib/hooks/useConnectionManager'
55
import DigestPreferencesPanel from './DigestPreferencesPanel'
@@ -165,7 +165,7 @@ function DigestSection({ section }) {
165165
const DIGEST_PREFS_AUTOPEN_KEY = 'deepsql.digestPrefs.autoOpened.v1'
166166

167167
export default function DigestFeedSection() {
168-
const { connectionId, selectedConnection } = useConnectionManager()
168+
const { connectionId, selectedConnection, isLoading: connectionsLoading } = useConnectionManager()
169169
const [digests, setDigests] = useState([])
170170
const [loading, setLoading] = useState(false)
171171
const [triggering, setTriggering] = useState(false)
@@ -257,6 +257,26 @@ export default function DigestFeedSection() {
257257
}
258258
}
259259

260+
// Wait for connection list to load first
261+
if (connectionsLoading) {
262+
return (
263+
<div className={styles.root}>
264+
<div className={styles.topBar}>
265+
<div className={styles.topBarLeft}>
266+
<Newspaper size={17} className={styles.topBarIcon} />
267+
<span className={styles.topBarTitle}>DB Digest</span>
268+
</div>
269+
</div>
270+
<div className={styles.feed}>
271+
<div className={styles.loadingState}>
272+
<Loader2 size={20} className={styles.spinning} color="#9ca3af" />
273+
<span>Loading connections…</span>
274+
</div>
275+
</div>
276+
</div>
277+
)
278+
}
279+
260280
return (
261281
<div className={styles.root}>
262282
{/* Top bar */}
@@ -309,15 +329,16 @@ export default function DigestFeedSection() {
309329
</div>
310330
)}
311331

312-
{!error && !loading && !connectionId && (
332+
{/* Either no connection or stale connectionId not in the effective user's list */}
333+
{!error && !loading && (!connectionId || !selectedConnection) && (
313334
<div className={styles.emptyState}>
314335
<Newspaper size={32} color="#d1d5db" />
315336
<h3>No connection selected</h3>
316337
<p>Select a connection to view its digest history.</p>
317338
</div>
318339
)}
319340

320-
{!error && !loading && !!connectionId && digests.length === 0 && (
341+
{!error && !loading && !!connectionId && !!selectedConnection && digests.length === 0 && (
321342
<div className={styles.emptyState}>
322343
<Newspaper size={32} color="#d1d5db" />
323344
<h3>No digests yet</h3>

‎src/components/sections/EditorSection.jsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import { useConnectionManager } from '@/lib/hooks/useConnectionManager'
22
import SqlRunnerTab from '@/components/tabs/Core/SqlRunnerTab'
3-
import { Code2 } from 'lucide-react'
3+
import { Code2, Loader2 } from 'lucide-react'
44
import styles from './SectionEmpty.module.css'
55

66
export default function EditorSection() {
7-
const { connectionId } = useConnectionManager()
7+
const { connectionId, selectedConnection, isLoading } = useConnectionManager()
88

9-
if (!connectionId) {
9+
if (isLoading) {
10+
return (
11+
<div className={styles.root}>
12+
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
13+
<p className={styles.subtitle}>Loading connections…</p>
14+
</div>
15+
)
16+
}
17+
18+
// Either no connection selected, or stale connectionId not in the current user's list
19+
if (!connectionId || !selectedConnection) {
1020
return (
1121
<div className={styles.root}>
1222
<div className={styles.iconWrap}>

‎src/components/sections/MonitorSection.jsx‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { useEffect, useState } from 'react'
2-
import { BarChart2 } from 'lucide-react'
2+
import { BarChart2, Loader2 } from 'lucide-react'
33
import { slowQueriesAPI } from '@/lib/api/client'
44
import { useConnectionManager } from '@/lib/hooks/useConnectionManager'
55
import AnalyticsTab from '@/components/tabs/Monitoring/AnalyticsTab'
66
import styles from './SectionEmpty.module.css'
77

88
export default function MonitorSection() {
9-
const { connectionId } = useConnectionManager()
9+
const { connectionId, selectedConnection, isLoading: connectionsLoading } = useConnectionManager()
1010
const [hasData, setHasData] = useState(null) // null = loading
1111
const [loading, setLoading] = useState(true)
1212

@@ -27,7 +27,18 @@ export default function MonitorSection() {
2727
.finally(() => setLoading(false))
2828
}, [connectionId])
2929

30-
if (!connectionId) {
30+
// Wait for connection list to load first
31+
if (connectionsLoading) {
32+
return (
33+
<div className={styles.root}>
34+
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
35+
<p className={styles.subtitle}>Loading connections…</p>
36+
</div>
37+
)
38+
}
39+
40+
// Either no connection or stale connectionId not in the effective user's list
41+
if (!connectionId || !selectedConnection) {
3142
return (
3243
<div className={styles.root}>
3344
<div className={styles.iconWrap}>

‎src/components/sections/SchemaDocsSection.jsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FileText } from 'lucide-react'
1+
import { FileText, Loader2 } from 'lucide-react'
22
import { useConnectionManager } from '@/lib/hooks/useConnectionManager'
33
import { useSetActiveSection } from '@/lib/stores/useNavStore'
44
import { useCompanyKnowledgeStore } from '@/lib/stores/useCompanyKnowledgeStore'
@@ -7,11 +7,21 @@ import styles from './SectionEmpty.module.css'
77
import workspaceStyles from './TopLevelSection.module.css'
88

99
export default function SchemaDocsSection() {
10-
const { connectionId } = useConnectionManager()
10+
const { connectionId, selectedConnection, isLoading } = useConnectionManager()
1111
const setActiveSection = useSetActiveSection()
1212
const setLinkedFilters = useCompanyKnowledgeStore((state) => state.setLinkedFilters)
1313

14-
if (!connectionId) {
14+
if (isLoading) {
15+
return (
16+
<div className={styles.root}>
17+
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
18+
<p className={styles.subtitle}>Loading connections…</p>
19+
</div>
20+
)
21+
}
22+
23+
// Either no connection or stale connectionId not in the effective user's list
24+
if (!connectionId || !selectedConnection) {
1525
return (
1626
<div className={styles.root}>
1727
<div className={styles.iconWrap}><FileText size={26} color="#9ca3af" /></div>

‎src/components/sections/SchemaSection.jsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
1-
import { Network } from 'lucide-react'
1+
import { Network, Loader2 } from 'lucide-react'
22
import { useConnectionManager } from '@/lib/hooks/useConnectionManager'
33
import BrainWorkspace from '@/components/tabs/Brain/BrainWorkspace'
44
import styles from './SectionEmpty.module.css'
55

66
export default function SchemaSection() {
7-
const { connectionId } = useConnectionManager()
7+
const { connectionId, selectedConnection, isLoading } = useConnectionManager()
88

9-
if (!connectionId) {
9+
if (isLoading) {
10+
return (
11+
<div className={styles.root}>
12+
<Loader2 size={24} color="#9ca3af" className={styles.spin} />
13+
<p className={styles.subtitle}>Loading connections…</p>
14+
</div>
15+
)
16+
}
17+
18+
// Either no connection or stale connectionId not in the effective user's list
19+
if (!connectionId || !selectedConnection) {
1020
return (
1121
<div className={styles.root}>
1222
<div className={styles.iconWrap}><Network size={26} color="#9ca3af" /></div>

‎src/components/sections/SectionEmpty.module.css‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@
7272
background: #f9fafb;
7373
}
7474

75+
.spin {
76+
animation: sectionSpin 1s linear infinite;
77+
}
78+
79+
@keyframes sectionSpin {
80+
to { transform: rotate(360deg); }
81+
}
82+
7583
.pills {
7684
display: flex;
7785
gap: 8px;

0 commit comments

Comments
 (0)