Skip to content

Commit 81f332c

Browse files
committed
fix(dashboards): close favorite-toggle IDOR and tighten workspace guards
Addresses PR #80 review. BLOCKER — POST /saved-dashboards/{id}/favorite had no authorization at all. A non-member could toggle the favorite flag on a workspace-restricted dashboard and, worse than reported, read the entire row back from the 200 response — dashboardConfig and chatMessages included — bypassing the very 404 that hides it. Reproduced live before the fix (200, is_favorite f->t, full config in the body); now 404 with the row untouched and nothing leaked. An audit of every handler in the controller found this was the only one missing its gate: the list endpoints use filterReadable, and the rest already assert both connection access and workspace membership. Also fixes the review's follow-ups: - assertWorkspaceAssignable called getWorkspace(), which asserts only visibility, so a VIEWER could create dashboards into a workspace while moveDashboard() required MANAGER for the same effect. Added assertCanAssignInto() so both paths agree, plus a connection-match check. - MANAGE_DASHBOARD_WORKSPACES was declared in the Permission enum and offered in the role editor but enforced nowhere, so uticking it had no effect — worse than not offering the toggle. createWorkspace now asserts it. - getFolders ran a DISTINCT over every dashboard on the connection, leaking the folder names of workspace-restricted ones. It now derives folders from the readable set (verified: non-member sees ['hi'], admin sees ['SecretFolder','hi']). Note on how this was missed: the earlier QA exercised the favorite toggle only as admin, which passes regardless of the guard. Testing a permission check requires the role that should fail.
1 parent dd092ad commit 81f332c

4 files changed

Lines changed: 112 additions & 4 deletions

File tree

backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.dbaagent.controller;
22

33
import com.dbaagent.model.DashboardVersion;
4+
import com.dbaagent.model.DashboardWorkspace;
45
import com.dbaagent.model.SavedDashboard;
56
import com.dbaagent.service.ConnectionChatAccessPolicyService;
67
import com.dbaagent.service.DashboardWorkspaceService;
@@ -12,6 +13,7 @@
1213
import org.springframework.http.HttpStatus;
1314
import org.springframework.http.ResponseEntity;
1415
import org.springframework.web.bind.annotation.*;
16+
import org.springframework.web.server.ResponseStatusException;
1517

1618
import java.util.HashMap;
1719
import java.util.List;
@@ -49,14 +51,23 @@ private static ResponseEntity<Map<String, Object>> conflict(OptimisticLockingFai
4951
}
5052

5153
/**
52-
* A dashboard may only be created into a workspace the caller can actually manage —
53-
* otherwise anyone could push a dashboard into someone else's workspace.
54+
* A dashboard may only be created into a workspace the caller can actually manage.
55+
*
56+
* <p>This previously called {@code getWorkspace}, which asserts only *visibility* —
57+
* so a VIEWER could create dashboards into a workspace they merely belonged to,
58+
* despite {@code moveDashboard} requiring MANAGER for the same effect. The two paths
59+
* now agree.
5460
*/
5561
private void assertWorkspaceAssignable(String connectionId, UUID workspaceId) {
5662
if (workspaceId == null) {
5763
return;
5864
}
59-
dashboardWorkspaceService.getWorkspace(workspaceId);
65+
DashboardWorkspace workspace = dashboardWorkspaceService.getWorkspace(workspaceId);
66+
if (!workspace.getConnectionId().equals(connectionId)) {
67+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
68+
"Workspace belongs to a different connection");
69+
}
70+
dashboardWorkspaceService.assertCanAssignInto(workspaceId);
6071
}
6172

6273
/** Publish this dashboard to the web (opt-in, revocable public link). */
@@ -384,6 +395,15 @@ public ResponseEntity<Map<String, Object>> deleteDashboard(@PathVariable UUID id
384395
public ResponseEntity<Map<String, Object>> toggleFavorite(@PathVariable UUID id) {
385396
try {
386397
log.info("Toggling favorite for dashboard: {}", id);
398+
// This handler had NO authorization at all, so a non-member could toggle the
399+
// favorite flag on a workspace-restricted dashboard and — worse — read the
400+
// whole row back from the 200 response (dashboardConfig and chatMessages
401+
// included), bypassing the very 404 that hides it. Load first, then apply the
402+
// same connection + workspace gate every other handler here uses.
403+
SavedDashboard existing = savedDashboardService.getDashboardById(id)
404+
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
405+
accessControlService.assertCanReadConnectionContent(existing.getConnectionId());
406+
dashboardWorkspaceService.assertCanReadDashboard(existing);
387407

388408
SavedDashboard updated = savedDashboardService.toggleFavorite(id);
389409

@@ -512,7 +532,18 @@ public ResponseEntity<Map<String, Object>> getFolders(@PathVariable String conne
512532
log.info("Fetching folders for connection: {}", connectionId);
513533
accessControlService.assertCanReadConnectionContent(connectionId);
514534

515-
List<String> folders = savedDashboardService.getFolders(connectionId);
535+
// Derive folders from the dashboards this caller can actually see. The
536+
// repository query is a DISTINCT over every dashboard on the connection, so a
537+
// non-member learned the folder names of workspace-restricted dashboards —
538+
// a small leak, but through the same list the 404s are meant to hide.
539+
List<String> folders = dashboardWorkspaceService
540+
.filterReadable(savedDashboardService.getDashboardsByConnection(connectionId))
541+
.stream()
542+
.map(SavedDashboard::getFolder)
543+
.filter(f -> f != null && !f.isBlank())
544+
.distinct()
545+
.sorted()
546+
.toList();
516547

517548
Map<String, Object> response = new HashMap<>();
518549
response.put("success", true);

backend/src/main/java/com/dbaagent/service/DashboardWorkspaceService.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.dbaagent.service;
22

33
import com.dbaagent.model.DashboardWorkspace;
4+
import com.dbaagent.model.Permission;
45
import com.dbaagent.model.DashboardWorkspaceMember;
56
import com.dbaagent.model.DashboardWorkspaceRole;
67
import com.dbaagent.model.SavedDashboard;
@@ -97,6 +98,11 @@ public List<SavedDashboard> listDashboards(UUID workspaceId) {
9798
@Transactional
9899
public DashboardWorkspace createWorkspace(String connectionId, String name, String description, String color) {
99100
accessControlService.assertCanReadConnectionContent(connectionId);
101+
// MANAGE_DASHBOARD_WORKSPACES was declared in the Permission enum and offered in
102+
// the role editor, but nothing enforced it — an admin who unticked it saw no
103+
// effect, which is worse than not offering the toggle at all.
104+
accessControlService.assertHasPermission(Permission.MANAGE_DASHBOARD_WORKSPACES,
105+
"You do not have permission to create dashboard workspaces");
100106
String cleanName = requireName(name);
101107

102108
workspaceRepository.findByConnectionIdAndNameIgnoreCase(connectionId, cleanName).ifPresent(existing -> {
@@ -315,6 +321,23 @@ private void assertCanView(DashboardWorkspace workspace) {
315321
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
316322
}
317323

324+
/**
325+
* Assert the caller may put a dashboard into this workspace.
326+
*
327+
* <p>Deliberately manage-level, not view-level: a VIEWER can open a workspace but must
328+
* not be able to push new dashboards into someone else's, which is what checking only
329+
* visibility allowed. Mirrors {@link #moveDashboard}, so creating into a workspace and
330+
* moving into one now require the same thing.
331+
*/
332+
public void assertCanAssignInto(UUID workspaceId) {
333+
if (workspaceId == null) {
334+
return;
335+
}
336+
DashboardWorkspace workspace = workspaceRepository.findById(workspaceId)
337+
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
338+
assertCanManage(workspace);
339+
}
340+
318341
private void assertCanManage(DashboardWorkspace workspace) {
319342
accessControlService.assertCanReadConnectionContent(workspace.getConnectionId());
320343
if (accessControlService.isCurrentUserAdmin()) {

backend/src/main/java/com/dbaagent/service/security/AccessControlService.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,16 @@ public void assertCanManageConnections() {
206206
}
207207
}
208208

209+
/** Assert the caller holds a permission, with a caller-supplied message. */
210+
public void assertHasPermission(Permission permission, String message) {
211+
if (!authEnabled) {
212+
return;
213+
}
214+
if (!hasPermission(permission)) {
215+
throw new ResponseStatusException(FORBIDDEN, message);
216+
}
217+
}
218+
209219
/**
210220
* Whether the current principal carries a permission authority.
211221
*

backend/src/test/java/com/dbaagent/service/DashboardWorkspaceAccessTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,50 @@ void cannotRemoveLastManager() {
193193
.hasMessageContaining("409");
194194
}
195195

196+
@Test
197+
@DisplayName("assertCanAssignInto requires MANAGER, not mere membership")
198+
void assignIntoRequiresManager() {
199+
// Creating a dashboard into a workspace used to call getWorkspace(), which asserts
200+
// only visibility — so a VIEWER could push dashboards into a workspace while
201+
// moveDashboard() required MANAGER for the same effect.
202+
DashboardWorkspace workspace = new DashboardWorkspace();
203+
workspace.setId(WORKSPACE);
204+
workspace.setConnectionId(CONNECTION);
205+
when(workspaceRepository.findById(WORKSPACE)).thenReturn(Optional.of(workspace));
206+
207+
DashboardWorkspaceMember viewer = new DashboardWorkspaceMember();
208+
viewer.setWorkspaceId(WORKSPACE);
209+
viewer.setUsername("analyst");
210+
viewer.setWorkspaceRole(DashboardWorkspaceRole.VIEWER);
211+
when(memberRepository.findByWorkspaceIdAndUsernameIgnoreCase(WORKSPACE, "analyst"))
212+
.thenReturn(Optional.of(viewer));
213+
214+
assertThatThrownBy(() -> service.assertCanAssignInto(WORKSPACE))
215+
.isInstanceOf(ResponseStatusException.class)
216+
.hasMessageContaining("403");
217+
218+
// A manager passes.
219+
viewer.setWorkspaceRole(DashboardWorkspaceRole.MANAGER);
220+
org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> service.assertCanAssignInto(WORKSPACE));
221+
}
222+
223+
@Test
224+
@DisplayName("assertCanReadDashboard is what stops the favorite-toggle IDOR")
225+
void favoriteTogglePathIsGated() {
226+
// POST /saved-dashboards/{id}/favorite had no authorization at all: a non-member
227+
// could flip the flag on a workspace-restricted dashboard AND read the whole row
228+
// back from the 200 response, bypassing the 404 that hides it. The controller now
229+
// runs this same gate before toggling.
230+
when(memberRepository.findByWorkspaceIdAndUsernameIgnoreCase(WORKSPACE, "analyst"))
231+
.thenReturn(Optional.empty());
232+
233+
SavedDashboard restricted = dashboard(WORKSPACE);
234+
235+
assertThatThrownBy(() -> service.assertCanReadDashboard(restricted))
236+
.isInstanceOf(ResponseStatusException.class)
237+
.hasMessageContaining("404");
238+
}
239+
196240
@Test
197241
@DisplayName("A dashboard cannot be moved into a workspace on a different connection")
198242
void cannotMoveAcrossConnections() {

0 commit comments

Comments
 (0)