Skip to content

Commit 136a8c1

Browse files
committed
fix(access): require MANAGE_CONNECTIONS to create a connection; hide admin surfaces
POST /connections had no authorization at all — it went straight to test-and-save, so any authenticated user could create, then edit and delete, their own connection. Verified live: as DATA_ENGINEER the request returned 200 and the row persisted with owner_username = analyst. An earlier check reported 403 only because the payload was malformed, so validation rejected it before authorization was ever reached; the endpoint was open. Creation is not scoped to an existing connection id, so none of the assertCanManage*Connection* helpers apply. assertCanManageConnections() is permission-based rather than admin-only, so DBA — which holds MANAGE_CONNECTIONS by design — keeps working, as does any custom role granted it. It honours security.auth.enabled like every other guard here, so the dev-mode bypass stays coherent. UI: Settings and Connections are administrative surfaces and are hidden from Developer and Data Engineer. Enforced inside SettingsModal and ManageConnectionsModal, not only at the call sites — both are opened from several places, and gating each entry point separately means the next new one silently reopens the hole. Hiding Settings also removes MCP tokens from those roles, which is the intended trade. Nav gating moves from a minimum-role table to per-section permissions, so custom roles and admin overrides take effect without a code change.
1 parent 808fdb5 commit 136a8c1

11 files changed

Lines changed: 550 additions & 74 deletions

File tree

CLAUDE.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,99 @@ returns a number).
210210
4. **Tooltips**: Always use `HelpTooltip` component, never plain `title` attributes.
211211
5. **Design**: Minimal black/white/grey palette, Inter font, subtle transitions. See UX guidelines in full CLAUDE.md.
212212

213+
### Roles, Permissions & Custom Roles
214+
215+
Roles are **not a hierarchy**. The old model ranked `DEVELOPER < ADMIN` and compared
216+
`ordinal()`; the shipped roles deliberately overlap without nesting, so an ordering
217+
comparison has no meaning and `Role.isAtLeast` is gone.
218+
219+
| Role | Sections | Notes |
220+
|---|---|---|
221+
| `ADMIN` | everything | Fixed point: holds **every** permission; overrides against it are refused, so the last admin cannot be locked out of user management. |
222+
| `DBA` | all menus + connection settings | **No** user creation / invite codes / role management. |
223+
| `DATA_ENGINEER` | Agent, Dashboards, Editor | No Digest, no Performance. |
224+
| `DEVELOPER` | Agent, Digest, Dashboards, Performance, Editor | No connection settings. |
225+
| custom | whatever an admin ticks | `custom_roles` rows; the `code` is written to `users.role`. |
226+
227+
- **Permissions are the unit of authorization.** `Permission` carries the built-in roles
228+
that hold it by default (`defaultRoles`); one `VIEW_*` permission per sidebar section
229+
(`VIEW_AGENT`, `VIEW_DASHBOARDS`, `VIEW_DIGEST`, `VIEW_BRAIN`, `VIEW_PERFORMANCE`,
230+
`VIEW_EDITOR`). The frontend gates nav on those codes (`SECTION_PERMISSION` in
231+
`src/lib/features.js`), not on a minimum role.
232+
- **A "role code" is either a built-in `Role` name or a `CustomRole.code`** — they share
233+
the `users.role` namespace, so `CustomRoleService` refuses a code colliding with a
234+
built-in one. `Role.fromString` returns **null** for anything unrecognised instead of
235+
collapsing to DEVELOPER: mapping a custom role onto a built-in one would hand its
236+
holders the wrong permissions. Use `PermissionService.getEffectivePermissions(roleCode)`
237+
`User.getRoleEnum()` is null for a custom role and `Role.getPermissions()` skips
238+
overrides.
239+
- **Every token-minting path must resolve by role code.** `AuthSessionService`,
240+
`PasswordlessAuthService`, `AuthInternalController`, `CustomUserDetailsService` and the
241+
`/auth/me` payload all use `user.getRoleCode()` + `PermissionService`; `JwtUtil` gained
242+
a `String roleCode` overload for exactly this. A `Role`-typed path cannot represent a
243+
custom role, so a custom-role user would silently get the wrong claim.
244+
- **An unknown role code grants nothing** rather than falling back — a deleted custom role
245+
must not become silent Developer access. Deleting a custom role is refused while any
246+
user still holds it.
247+
- `RolePermissionOverride.role` is now a role-code **string** (same column), so overrides
248+
work for custom roles too. Built-in role permission sets are code, not data: the API
249+
refuses to edit them directly and points at overrides instead, so an admin's change
250+
survives an upgrade.
251+
252+
### Connection access levels & the create-connection guard
253+
254+
- **There is one access level.** `ConnectionAccessLevel.CHAT_EDITOR` is `@Deprecated` and
255+
retained only so pre-existing rows parse; `fromString` folds it (and a blank value) into
256+
`FULL_CONTENT`, and `ConnectionAccessService.resolveAccess` returns `FULL_CONTENT` for
257+
**every** grant. Assigning a connection therefore implies content access — no migration
258+
was needed, legacy rows upgrade themselves on read. The "Full Access" / "Chat + Editor"
259+
badges are gone; only Owner/Admin are surfaced.
260+
- **`AccessControlServiceTest` cannot prove anything about this.** It stubs
261+
`resolveAccess` to return a fixed `EffectiveConnectionAccess`, so its CHAT_EDITOR case
262+
passes vacuously no matter what the resolver does. `ConnectionAccessLevelCollapseTest`
263+
exercises the real path — add coverage there, not to the stubbed test.
264+
- **`POST /connections` had no authorization at all.** It went straight to test-and-save,
265+
so any authenticated user could create — then edit and delete — their own connection
266+
(verified live: the row persisted with `owner_username = analyst` for a DATA_ENGINEER).
267+
Hiding the sidebar button is not a control. It now calls
268+
`accessControlService.assertCanManageConnections()`, which is **permission-based, not
269+
admin-only**, so DBA and any custom role holding `MANAGE_CONNECTIONS` still work.
270+
Creation is not scoped to a connection id, so none of the `assertCanManage*Connection*`
271+
helpers apply — a new unscoped endpoint needs this guard explicitly.
272+
- **Settings and Connections are admin surfaces in the UI.** `SettingsModal` and
273+
`ManageConnectionsModal` each refuse to render without the relevant permission, enforced
274+
*inside* the component rather than only at the call site: both are opened from several
275+
places, and gating each entry point separately means the next one silently reopens the
276+
hole. Hiding Settings also removes MCP tokens from those roles — that is intended.
277+
278+
### Dashboard workspaces
279+
280+
`DashboardWorkspace` groups dashboards within one connection and carries its own member
281+
list (`DashboardWorkspaceMember`, keyed by **username** to match `connection_access_grant`
282+
so "View as" resolves membership as the target user).
283+
284+
- **The rule is an AND, and it only ever narrows.** Connection access is checked first and
285+
unchanged (`assertCanReadConnectionContent`); workspace membership is an *additional*
286+
gate. Adding someone to a workspace can never grant them a connection they were not
287+
already given. `saved_dashboards.workspace_id` is nullable — NULL means "not grouped",
288+
governed purely by the connection ACL exactly as before.
289+
- Admins bypass the membership half, matching how they already bypass connection grants.
290+
- **Non-membership reports 404, not 403** — a user outside the workspace must not learn
291+
the dashboard exists.
292+
- **Deleting a workspace detaches its dashboards, never deletes them** (the FK is
293+
deliberately non-cascading). Removing the last MANAGER is refused, otherwise the
294+
workspace could never be changed again by anyone but an admin.
295+
- `DashboardWorkspaceService.filterReadable` resolves a whole list in one membership
296+
query; use it for any new dashboard-list endpoint rather than checking per row.
297+
- **`/saved-dashboards` had no connection authorization at all** before this change —
298+
create, list, get, update and delete took a caller-supplied `connectionId`/id and
299+
checked nothing, so any authenticated user could read every dashboard on every
300+
connection (verified live against a running install, not inferred). All of them now
301+
assert connection access *and* the workspace gate; `DashboardAlertController` does the
302+
same through its single `requireDashboard` choke point. This is the same
303+
"authentication is not authorization" trap `BrainController` documents — there is still
304+
no filter doing it for you.
305+
213306
### Admin profile switch
214307
Admins can **View as** a sub-user from the top-right of the home layout (`ProfileSwitch`) to verify connection ACLs, chat/editor policies, and role-gated nav.
215308

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@ private ConnectionRequest mergeTestRequest(ConnectionRequest saved, ConnectionRe
189189
public ResponseEntity<Map<String, Object>> saveConnection(@RequestBody ConnectionRequest request) {
190190
Map<String, Object> response = new HashMap<>();
191191
try {
192+
// Creating a connection is not scoped to an existing connection id, so none of
193+
// the assertCanManage*Connection* checks apply here — this endpoint had no
194+
// authorization at all, and any authenticated user could add (then edit and
195+
// delete) their own connection. Hiding the Connections button did not stop it.
196+
accessControlService.assertCanManageConnections();
197+
192198
// Test connection with privilege checks
193199
ConnectionTestResult result = connectionService.testConnectionWithPrivileges(request);
194200

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.dbaagent.model.Chat;
55
import com.dbaagent.model.ChatFeedback;
66
import com.dbaagent.model.EffectiveConnectionAccess;
7+
import com.dbaagent.model.Permission;
78
import com.dbaagent.repository.AnalysisHistoryRepository;
89
import com.dbaagent.repository.ChatFeedbackRepository;
910
import com.dbaagent.repository.ChatRepository;
@@ -181,6 +182,52 @@ public String requireCurrentUsername() {
181182
return username;
182183
}
183184

185+
/**
186+
* Assert the caller may create a database connection.
187+
*
188+
* <p>Creating a connection is not scoped to an existing connection, so none of the
189+
* {@code assertCanManage*Connection*} checks apply — there is no id to resolve
190+
* access against yet. Without this, {@code POST /connections} had no authorization
191+
* at all: a Developer or Data Engineer could create, then edit and delete, their own
192+
* connection (verified live against a running install — the row persisted with
193+
* {@code owner_username = analyst}). Hiding the Connections button only hid the
194+
* button.
195+
*
196+
* <p>Permission-based rather than {@code isCurrentUserAdmin()} so DBA — which holds
197+
* MANAGE_CONNECTIONS by design — keeps working, and so an admin-defined custom role
198+
* granting that permission behaves consistently.
199+
*/
200+
public void assertCanManageConnections() {
201+
if (!authEnabled) {
202+
return;
203+
}
204+
if (!hasPermission(Permission.MANAGE_CONNECTIONS)) {
205+
throw new ResponseStatusException(FORBIDDEN, "You do not have permission to manage connections");
206+
}
207+
}
208+
209+
/**
210+
* Whether the current principal carries a permission authority.
211+
*
212+
* <p>{@code CustomUserDetailsService} stamps every effective permission onto the
213+
* authentication as a plain authority alongside {@code ROLE_<code>}, so this reads
214+
* the already-resolved set (overrides and custom roles included) without a lookup.
215+
*/
216+
public boolean hasPermission(Permission permission) {
217+
if (permission == null) {
218+
return false;
219+
}
220+
if (isCurrentUserAdmin()) {
221+
return true;
222+
}
223+
Authentication authentication = currentAuthentication();
224+
if (authentication == null || !authentication.isAuthenticated()) {
225+
return false;
226+
}
227+
return authentication.getAuthorities().stream()
228+
.anyMatch(authority -> permission.name().equals(authority.getAuthority()));
229+
}
230+
184231
public boolean isCurrentUserAdmin() {
185232
if (ImpersonationContext.isActive()) {
186233
return ImpersonationContext.current()
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package com.dbaagent.service.security;
2+
3+
import com.dbaagent.model.Permission;
4+
import com.dbaagent.repository.AnalysisHistoryRepository;
5+
import com.dbaagent.repository.ChatFeedbackRepository;
6+
import com.dbaagent.repository.ChatRepository;
7+
import org.junit.jupiter.api.AfterEach;
8+
import org.junit.jupiter.api.BeforeEach;
9+
import org.junit.jupiter.api.DisplayName;
10+
import org.junit.jupiter.api.Test;
11+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
12+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
13+
import org.springframework.security.core.context.SecurityContextHolder;
14+
import org.springframework.test.util.ReflectionTestUtils;
15+
import org.springframework.web.server.ResponseStatusException;
16+
17+
import java.util.List;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
21+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
22+
import static org.mockito.Mockito.mock;
23+
24+
/**
25+
* Creating a connection requires MANAGE_CONNECTIONS.
26+
*
27+
* <p>{@code POST /connections} previously had no authorization whatsoever: it went
28+
* straight to test-and-save. A Data Engineer could create a connection and then edit and
29+
* delete it — verified against a running install, where the row persisted with
30+
* {@code owner_username = analyst}. Only the sidebar button was hidden, which is not a
31+
* control.
32+
*
33+
* <p>The permission is read from the principal's granted authorities, which
34+
* {@code CustomUserDetailsService} stamps from the fully-resolved effective permission
35+
* set — so overrides and custom roles are honoured here without a second lookup.
36+
*/
37+
class ConnectionCreateAuthorizationTest {
38+
39+
private AccessControlService accessControlService;
40+
41+
@BeforeEach
42+
void setUp() {
43+
accessControlService = new AccessControlService(
44+
mock(ConnectionAccessService.class),
45+
mock(ChatRepository.class),
46+
mock(ChatFeedbackRepository.class),
47+
mock(AnalysisHistoryRepository.class)
48+
);
49+
// Every real deployment runs with auth on; the dev-mode bypass is covered below.
50+
ReflectionTestUtils.setField(accessControlService, "authEnabled", true);
51+
}
52+
53+
@AfterEach
54+
void tearDown() {
55+
SecurityContextHolder.clearContext();
56+
}
57+
58+
private void authenticateAs(String username, String roleCode, String... permissions) {
59+
var authorities = new java.util.ArrayList<SimpleGrantedAuthority>();
60+
authorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
61+
for (String p : permissions) {
62+
authorities.add(new SimpleGrantedAuthority(p));
63+
}
64+
SecurityContextHolder.getContext().setAuthentication(
65+
new UsernamePasswordAuthenticationToken(username, null, authorities));
66+
}
67+
68+
@Test
69+
@DisplayName("A Developer cannot create a connection")
70+
void developerRefused() {
71+
// Developer's effective set has no MANAGE_CONNECTIONS (see PermissionResolutionTest).
72+
authenticateAs("analyst", "DEVELOPER",
73+
Permission.VIEW_AGENT.name(), Permission.VIEW_DASHBOARDS.name(),
74+
Permission.VIEW_EDITOR.name(), Permission.EXECUTE_QUERIES.name());
75+
76+
assertThatThrownBy(() -> accessControlService.assertCanManageConnections())
77+
.isInstanceOf(ResponseStatusException.class)
78+
.hasMessageContaining("403");
79+
assertThat(accessControlService.hasPermission(Permission.MANAGE_CONNECTIONS)).isFalse();
80+
}
81+
82+
@Test
83+
@DisplayName("A Data Engineer cannot create a connection")
84+
void dataEngineerRefused() {
85+
authenticateAs("analyst", "DATA_ENGINEER",
86+
Permission.VIEW_AGENT.name(), Permission.VIEW_DASHBOARDS.name(),
87+
Permission.VIEW_EDITOR.name());
88+
89+
assertThatThrownBy(() -> accessControlService.assertCanManageConnections())
90+
.isInstanceOf(ResponseStatusException.class)
91+
.hasMessageContaining("403");
92+
}
93+
94+
@Test
95+
@DisplayName("A DBA can create a connection — the check is permission-based, not admin-only")
96+
void dbaAllowed() {
97+
// DBA holds MANAGE_CONNECTIONS by design but is not an admin, so an
98+
// isCurrentUserAdmin() check here would have broken it.
99+
authenticateAs("dba-user", "DBA", Permission.MANAGE_CONNECTIONS.name());
100+
101+
assertDoesNotThrow(() -> accessControlService.assertCanManageConnections());
102+
}
103+
104+
@Test
105+
@DisplayName("An admin can create a connection")
106+
void adminAllowed() {
107+
authenticateAs("admin", "ADMIN");
108+
109+
assertDoesNotThrow(() -> accessControlService.assertCanManageConnections());
110+
assertThat(accessControlService.hasPermission(Permission.MANAGE_CONNECTIONS)).isTrue();
111+
}
112+
113+
@Test
114+
@DisplayName("A custom role granted MANAGE_CONNECTIONS can create a connection")
115+
void customRoleWithPermissionAllowed() {
116+
authenticateAs("analyst", "PLATFORM_ENG", Permission.MANAGE_CONNECTIONS.name());
117+
118+
assertDoesNotThrow(() -> accessControlService.assertCanManageConnections());
119+
}
120+
121+
@Test
122+
@DisplayName("An unauthenticated caller cannot create a connection")
123+
void anonymousRefused() {
124+
SecurityContextHolder.clearContext();
125+
126+
assertThatThrownBy(() -> accessControlService.assertCanManageConnections())
127+
.isInstanceOf(ResponseStatusException.class)
128+
.hasMessageContaining("403");
129+
}
130+
131+
@Test
132+
@DisplayName("With auth disabled the check is a no-op, matching every other guard here")
133+
void devModeBypass() {
134+
// Consistency matters: requireCurrentUsername/isCurrentUserAdmin both honour this
135+
// flag, and a guard that ignored it would switch connection creation off in the
136+
// documented dev-mode bypass instead of opening it.
137+
ReflectionTestUtils.setField(accessControlService, "authEnabled", false);
138+
SecurityContextHolder.clearContext();
139+
140+
assertDoesNotThrow(() -> accessControlService.assertCanManageConnections());
141+
}
142+
}

src/components/ManageConnectionsModal.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import ConnectionSlowQueryConfig from "./ConnectionSlowQueryConfig";
1717
import SlowQuerySourceModal from "./SlowQuerySourceModal";
1818
import { connectionAPI, brainAPI } from "@/lib/api/client";
1919
import { useAuth } from "@/hooks/useAuth";
20+
import { PERMISSIONS } from "@/lib/permissions";
2021
import { getConnectionAccessBadge, getConnectionAccessLabel } from "@/lib/features";
2122

2223
export default function ManageConnectionsModal({
@@ -25,7 +26,7 @@ export default function ManageConnectionsModal({
2526
onConnectionDeleted,
2627
onConnectionSaved,
2728
}) {
28-
const { isAdmin } = useAuth();
29+
const { isAdmin, hasPermission } = useAuth();
2930
const [connections, setConnections] = useState([]);
3031
const [loading, setLoading] = useState(false);
3132
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
@@ -118,6 +119,12 @@ export default function ManageConnectionsModal({
118119

119120
if (!isOpen) return null;
120121

122+
// Enforced here rather than only at the call sites: this modal adds, edits and deletes
123+
// database connections, and it is opened from the sidebar, the Agent view and the user
124+
// menu. Gating each entry point separately means the next new one silently reopens the
125+
// hole. The backend already 403s these writes; this keeps the UI honest about it.
126+
if (!hasPermission(PERMISSIONS.MANAGE_CONNECTIONS)) return null;
127+
121128
return (
122129
<div className={styles.overlay} onClick={onClose}>
123130
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>

0 commit comments

Comments
 (0)