diff --git a/CLAUDE.md b/CLAUDE.md
index cbe5242..f0704a6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -210,6 +210,99 @@ returns a number).
4. **Tooltips**: Always use `HelpTooltip` component, never plain `title` attributes.
5. **Design**: Minimal black/white/grey palette, Inter font, subtle transitions. See UX guidelines in full CLAUDE.md.
+### Roles, Permissions & Custom Roles
+
+Roles are **not a hierarchy**. The old model ranked `DEVELOPER < ADMIN` and compared
+`ordinal()`; the shipped roles deliberately overlap without nesting, so an ordering
+comparison has no meaning and `Role.isAtLeast` is gone.
+
+| Role | Sections | Notes |
+|---|---|---|
+| `ADMIN` | everything | Fixed point: holds **every** permission; overrides against it are refused, so the last admin cannot be locked out of user management. |
+| `DBA` | all menus + connection settings | **No** user creation / invite codes / role management. |
+| `DATA_ENGINEER` | Agent, Dashboards, Editor | No Digest, no Performance. |
+| `DEVELOPER` | Agent, Digest, Dashboards, Performance, Editor | No connection settings. |
+| custom | whatever an admin ticks | `custom_roles` rows; the `code` is written to `users.role`. |
+
+- **Permissions are the unit of authorization.** `Permission` carries the built-in roles
+ that hold it by default (`defaultRoles`); one `VIEW_*` permission per sidebar section
+ (`VIEW_AGENT`, `VIEW_DASHBOARDS`, `VIEW_DIGEST`, `VIEW_BRAIN`, `VIEW_PERFORMANCE`,
+ `VIEW_EDITOR`). The frontend gates nav on those codes (`SECTION_PERMISSION` in
+ `src/lib/features.js`), not on a minimum role.
+- **A "role code" is either a built-in `Role` name or a `CustomRole.code`** — they share
+ the `users.role` namespace, so `CustomRoleService` refuses a code colliding with a
+ built-in one. `Role.fromString` returns **null** for anything unrecognised instead of
+ collapsing to DEVELOPER: mapping a custom role onto a built-in one would hand its
+ holders the wrong permissions. Use `PermissionService.getEffectivePermissions(roleCode)`
+ — `User.getRoleEnum()` is null for a custom role and `Role.getPermissions()` skips
+ overrides.
+- **Every token-minting path must resolve by role code.** `AuthSessionService`,
+ `PasswordlessAuthService`, `AuthInternalController`, `CustomUserDetailsService` and the
+ `/auth/me` payload all use `user.getRoleCode()` + `PermissionService`; `JwtUtil` gained
+ a `String roleCode` overload for exactly this. A `Role`-typed path cannot represent a
+ custom role, so a custom-role user would silently get the wrong claim.
+- **An unknown role code grants nothing** rather than falling back — a deleted custom role
+ must not become silent Developer access. Deleting a custom role is refused while any
+ user still holds it.
+- `RolePermissionOverride.role` is now a role-code **string** (same column), so overrides
+ work for custom roles too. Built-in role permission sets are code, not data: the API
+ refuses to edit them directly and points at overrides instead, so an admin's change
+ survives an upgrade.
+
+### Connection access levels & the create-connection guard
+
+- **There is one access level.** `ConnectionAccessLevel.CHAT_EDITOR` is `@Deprecated` and
+ retained only so pre-existing rows parse; `fromString` folds it (and a blank value) into
+ `FULL_CONTENT`, and `ConnectionAccessService.resolveAccess` returns `FULL_CONTENT` for
+ **every** grant. Assigning a connection therefore implies content access — no migration
+ was needed, legacy rows upgrade themselves on read. The "Full Access" / "Chat + Editor"
+ badges are gone; only Owner/Admin are surfaced.
+- **`AccessControlServiceTest` cannot prove anything about this.** It stubs
+ `resolveAccess` to return a fixed `EffectiveConnectionAccess`, so its CHAT_EDITOR case
+ passes vacuously no matter what the resolver does. `ConnectionAccessLevelCollapseTest`
+ exercises the real path — add coverage there, not to the stubbed test.
+- **`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: the row persisted with `owner_username = analyst` for a DATA_ENGINEER).
+ Hiding the sidebar button is not a control. It now calls
+ `accessControlService.assertCanManageConnections()`, which is **permission-based, not
+ admin-only**, so DBA and any custom role holding `MANAGE_CONNECTIONS` still work.
+ Creation is not scoped to a connection id, so none of the `assertCanManage*Connection*`
+ helpers apply — a new unscoped endpoint needs this guard explicitly.
+- **Settings and Connections are admin surfaces in the UI.** `SettingsModal` and
+ `ManageConnectionsModal` each refuse to render without the relevant permission, enforced
+ *inside* the component rather than only at the call site: both are opened from several
+ places, and gating each entry point separately means the next one silently reopens the
+ hole. Hiding Settings also removes MCP tokens from those roles — that is intended.
+
+### Dashboard workspaces
+
+`DashboardWorkspace` groups dashboards within one connection and carries its own member
+list (`DashboardWorkspaceMember`, keyed by **username** to match `connection_access_grant`
+so "View as" resolves membership as the target user).
+
+- **The rule is an AND, and it only ever narrows.** Connection access is checked first and
+ unchanged (`assertCanReadConnectionContent`); workspace membership is an *additional*
+ gate. Adding someone to a workspace can never grant them a connection they were not
+ already given. `saved_dashboards.workspace_id` is nullable — NULL means "not grouped",
+ governed purely by the connection ACL exactly as before.
+- Admins bypass the membership half, matching how they already bypass connection grants.
+- **Non-membership reports 404, not 403** — a user outside the workspace must not learn
+ the dashboard exists.
+- **Deleting a workspace detaches its dashboards, never deletes them** (the FK is
+ deliberately non-cascading). Removing the last MANAGER is refused, otherwise the
+ workspace could never be changed again by anyone but an admin.
+- `DashboardWorkspaceService.filterReadable` resolves a whole list in one membership
+ query; use it for any new dashboard-list endpoint rather than checking per row.
+- **`/saved-dashboards` had no connection authorization at all** before this change —
+ create, list, get, update and delete took a caller-supplied `connectionId`/id and
+ checked nothing, so any authenticated user could read every dashboard on every
+ connection (verified live against a running install, not inferred). All of them now
+ assert connection access *and* the workspace gate; `DashboardAlertController` does the
+ same through its single `requireDashboard` choke point. This is the same
+ "authentication is not authorization" trap `BrainController` documents — there is still
+ no filter doing it for you.
+
### Admin profile switch
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.
diff --git a/backend/src/main/java/com/dbaagent/config/RolePermissionConstraintInitializer.java b/backend/src/main/java/com/dbaagent/config/RolePermissionConstraintInitializer.java
new file mode 100644
index 0000000..ce93c62
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/config/RolePermissionConstraintInitializer.java
@@ -0,0 +1,95 @@
+package com.dbaagent.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import javax.sql.DataSource;
+
+/**
+ * Drops the stale CHECK constraints Hibernate generated for
+ * {@code role_permission_overrides} under the original two-role, twenty-permission
+ * enums.
+ *
+ *
{@code ddl-auto=update} adds columns and tables but never drops a constraint it
+ * previously created , so on any database created before the role model changed both
+ * checks survive and reject every new value. Inserting an override for the DBA role, or
+ * for any of the new section permissions, fails with:
+ *
+ *
+ * ERROR: new row for relation "role_permission_overrides" violates check constraint
+ * "role_permission_overrides_permission_code_check"
+ *
+ *
+ * That was observed against a live install, not inferred — the tables looked correct
+ * and only an actual INSERT revealed it. There is no Flyway runtime in this repo
+ * (see CLAUDE.md), so this initializer is what actually applies the matching statements
+ * in {@code V117__create_dashboard_workspaces_and_custom_roles.sql}.
+ *
+ *
Dropping rather than rewriting the constraints is deliberate: the {@code Role} /
+ * {@code Permission} enums plus {@code PermissionService} are the authority for these
+ * values, and a database-level copy of an enum has to be re-migrated on every future
+ * addition — which is precisely how this broke.
+ */
+@Configuration
+@Slf4j
+public class RolePermissionConstraintInitializer {
+
+ private static final String TABLE = "role_permission_overrides";
+
+ @Bean("rolePermissionConstraintBootstrap")
+ @DependsOn("entityManagerFactory")
+ public Object rolePermissionConstraintBootstrap(DataSource dataSource) {
+ JdbcTemplate jdbc = new JdbcTemplate(dataSource);
+
+ if (!tableExists(jdbc, TABLE)) {
+ return new Object();
+ }
+
+ int dropped = 0;
+ dropped += dropCheckIfPresent(jdbc, TABLE + "_role_check");
+ dropped += dropCheckIfPresent(jdbc, TABLE + "_permission_code_check");
+
+ // Widen the role column so a custom role code fits; harmless if already wide.
+ try {
+ jdbc.execute("ALTER TABLE " + TABLE + " ALTER COLUMN role TYPE VARCHAR(64)");
+ } catch (RuntimeException e) {
+ log.warn("Could not widen {}.role: {}", TABLE, e.getMessage());
+ }
+
+ if (dropped > 0) {
+ log.info("Dropped {} stale CHECK constraint(s) on {} left over from the previous role model",
+ dropped, TABLE);
+ }
+ return new Object();
+ }
+
+ private int dropCheckIfPresent(JdbcTemplate jdbc, String constraintName) {
+ Integer count = jdbc.queryForObject("""
+ SELECT COUNT(*)
+ FROM information_schema.table_constraints
+ WHERE table_schema = 'public' AND table_name = ? AND constraint_name = ?
+ """, Integer.class, TABLE, constraintName);
+ if (count == null || count == 0) {
+ return 0;
+ }
+ try {
+ jdbc.execute("ALTER TABLE " + TABLE + " DROP CONSTRAINT IF EXISTS " + constraintName);
+ return 1;
+ } catch (RuntimeException e) {
+ log.warn("Could not drop stale constraint {}: {}", constraintName, e.getMessage());
+ return 0;
+ }
+ }
+
+ private boolean tableExists(JdbcTemplate jdbc, String tableName) {
+ Integer count = jdbc.queryForObject("""
+ SELECT COUNT(*)
+ FROM information_schema.tables
+ WHERE table_schema = 'public' AND table_name = ?
+ """, Integer.class, tableName);
+ return count != null && count > 0;
+ }
+}
diff --git a/backend/src/main/java/com/dbaagent/controller/AuthController.java b/backend/src/main/java/com/dbaagent/controller/AuthController.java
index 105b272..55104b1 100644
--- a/backend/src/main/java/com/dbaagent/controller/AuthController.java
+++ b/backend/src/main/java/com/dbaagent/controller/AuthController.java
@@ -167,7 +167,8 @@ public ResponseEntity> completeGoogleLogin(
return redirectToFrontend("/login?error=" + urlEncode(result.message()));
}
- if (result.sessionAuthentication() != null && result.user() != null && result.role() != null) {
+ // Gate on roleCode, not role: role is null for a custom-role user.
+ if (result.sessionAuthentication() != null && result.user() != null && result.roleCode() != null) {
authSessionService.writeSessionCookies(httpResponse, result.sessionAuthentication());
return redirectToFrontend("/dashboard");
}
@@ -224,8 +225,8 @@ public ResponseEntity> refreshSession(HttpServletRequest httpRequest, HttpServ
}
Map payload = toAuthPayload(
effectiveUser,
- effectiveUser.getRoleEnum(),
- permissionService.getEffectivePermissionCodes(effectiveUser.getRoleEnum())
+ effectiveUser.getRoleCode(),
+ permissionService.getEffectivePermissionCodes(effectiveUser.getRoleCode())
);
impersonationService.decorateAuthPayload(httpRequest, user, payload);
return ResponseEntity.ok(payload);
@@ -332,9 +333,9 @@ public ResponseEntity> getCurrentUser(HttpServletRequest httpRequest) {
return ResponseEntity.status(401).body(Map.of("message", "Not authenticated"));
}
User user = currentUserEntity();
- Role role = user.getRoleEnum();
- Set permissions = permissionService.getEffectivePermissionCodes(role);
- Map response = toAuthPayload(user, role, permissions);
+ String roleCode = user.getRoleCode();
+ Set permissions = permissionService.getEffectivePermissionCodes(roleCode);
+ Map response = toAuthPayload(user, roleCode, permissions);
impersonationService.decorateAuthPayload(httpRequest, user, response);
return ResponseEntity.ok(response);
}
@@ -378,13 +379,17 @@ private ResponseEntity> authResponse(PasswordlessAuthService.AuthFlowResult re
if (!result.success()) {
return ResponseEntity.status(400).body(Map.of("message", result.message()));
}
- if (result.sessionAuthentication() != null && result.user() != null && result.role() != null) {
+ // Gate on roleCode, not role: result.role() is null for a user holding a custom
+ // role, which sent an otherwise-successful login down the "challenge required"
+ // branch below and then NPE'd in Map.of on a null challengeId — a 500 on every
+ // custom-role login. Observed live, not inferred.
+ if (result.sessionAuthentication() != null && result.user() != null && result.roleCode() != null) {
authSessionService.writeSessionCookies(httpResponse, result.sessionAuthentication());
authSessionService.clearImpersonationCookie(httpResponse);
Set permissionNames = result.permissions() == null ? Set.of() : result.permissions().stream()
.map(Enum::name)
.collect(Collectors.toSet());
- return ResponseEntity.ok(toAuthPayload(result.user(), result.role(), permissionNames));
+ return ResponseEntity.ok(toAuthPayload(result.user(), result.roleCode(), permissionNames));
}
return ResponseEntity.ok(Map.of(
"challengeId", result.nextChallengeId(),
@@ -401,10 +406,19 @@ private ResponseEntity> authError(ResponseStatusException e) {
}
private Map toAuthPayload(User user, Role role, Set permissions) {
+ return toAuthPayload(user, role != null ? role.name() : user.getRoleCode(), permissions);
+ }
+
+ /**
+ * Auth payload keyed by role code , so a user holding a custom role reports
+ * that role rather than the nearest built-in one.
+ */
+ private Map toAuthPayload(User user, String roleCode, Set permissions) {
Map response = new LinkedHashMap<>();
response.put("username", user.getUsername());
response.put("email", user.getEmail());
- response.put("role", role.name());
+ response.put("role", roleCode);
+ response.put("roleName", permissionService.describeRole(roleCode));
response.put("permissions", permissions);
response.put("emailVerified", user.isEmailVerified());
response.put("accountStatus", user.getAccountStatus());
diff --git a/backend/src/main/java/com/dbaagent/controller/AuthInternalController.java b/backend/src/main/java/com/dbaagent/controller/AuthInternalController.java
index 3223957..5f26700 100644
--- a/backend/src/main/java/com/dbaagent/controller/AuthInternalController.java
+++ b/backend/src/main/java/com/dbaagent/controller/AuthInternalController.java
@@ -60,11 +60,11 @@ public ResponseEntity> issueToken(
.body(Map.of("message", "Admin user not found"));
}
- Role role = admin.getRoleEnum();
- Set permissions = permissionService.getEffectivePermissions(role);
+ String roleCode = admin.getRoleCode();
+ Set permissions = permissionService.getEffectivePermissions(roleCode);
AuthSessionService.SessionAuthentication session = authSessionService.createSession(
- admin, role, permissions,
+ admin, roleCode, permissions,
request.getRemoteAddr(),
"DeepSQL-TestSuite/1.0",
true
diff --git a/backend/src/main/java/com/dbaagent/controller/ConnectionController.java b/backend/src/main/java/com/dbaagent/controller/ConnectionController.java
index fd34425..1a88e1b 100644
--- a/backend/src/main/java/com/dbaagent/controller/ConnectionController.java
+++ b/backend/src/main/java/com/dbaagent/controller/ConnectionController.java
@@ -189,6 +189,12 @@ private ConnectionRequest mergeTestRequest(ConnectionRequest saved, ConnectionRe
public ResponseEntity> saveConnection(@RequestBody ConnectionRequest request) {
Map response = new HashMap<>();
try {
+ // Creating a connection is not scoped to an existing connection id, so none of
+ // the assertCanManage*Connection* checks apply here — this endpoint had no
+ // authorization at all, and any authenticated user could add (then edit and
+ // delete) their own connection. Hiding the Connections button did not stop it.
+ accessControlService.assertCanManageConnections();
+
// Test connection with privilege checks
ConnectionTestResult result = connectionService.testConnectionWithPrivileges(request);
diff --git a/backend/src/main/java/com/dbaagent/controller/DashboardAlertController.java b/backend/src/main/java/com/dbaagent/controller/DashboardAlertController.java
index abec91d..fb10ed7 100644
--- a/backend/src/main/java/com/dbaagent/controller/DashboardAlertController.java
+++ b/backend/src/main/java/com/dbaagent/controller/DashboardAlertController.java
@@ -4,6 +4,7 @@
import com.dbaagent.model.SavedDashboard;
import com.dbaagent.service.DashboardAlertService;
import com.dbaagent.service.SavedDashboardService;
+import com.dbaagent.service.DashboardWorkspaceService;
import com.dbaagent.service.security.AccessControlService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -25,6 +26,7 @@ public class DashboardAlertController {
private final DashboardAlertService alertService;
private final SavedDashboardService savedDashboardService;
private final AccessControlService accessControlService;
+ private final DashboardWorkspaceService dashboardWorkspaceService;
@PostMapping
public ResponseEntity> create(@PathVariable UUID dashboardId, @RequestBody DashboardAlert draft) {
@@ -93,8 +95,15 @@ public ResponseEntity> delete(@PathVariable UUID dashboardId
}
}
+ /**
+ * The single point every handler here resolves a dashboard through, so the workspace
+ * membership gate applies to all of them at once. The connection check stays with
+ * each caller because read and write paths need different assertions.
+ */
private SavedDashboard requireDashboard(UUID dashboardId) {
- return savedDashboardService.getDashboardById(dashboardId)
+ SavedDashboard dashboard = savedDashboardService.getDashboardById(dashboardId)
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
+ dashboardWorkspaceService.assertCanReadDashboard(dashboard);
+ return dashboard;
}
}
diff --git a/backend/src/main/java/com/dbaagent/controller/DashboardWorkspaceController.java b/backend/src/main/java/com/dbaagent/controller/DashboardWorkspaceController.java
new file mode 100644
index 0000000..161cff6
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/controller/DashboardWorkspaceController.java
@@ -0,0 +1,235 @@
+package com.dbaagent.controller;
+
+import com.dbaagent.model.DashboardWorkspace;
+import com.dbaagent.model.DashboardWorkspaceMember;
+import com.dbaagent.model.SavedDashboard;
+import com.dbaagent.repository.SavedDashboardRepository;
+import com.dbaagent.service.DashboardWorkspaceService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.*;
+
+/**
+ * Dashboard workspaces: grouping dashboards with their own member list.
+ *
+ *
+ * GET /dashboard-workspaces/connection/{connectionId} workspaces I can see
+ * POST /dashboard-workspaces create
+ * GET /dashboard-workspaces/{id} one workspace
+ * PUT /dashboard-workspaces/{id} rename / recolour
+ * DELETE /dashboard-workspaces/{id} delete (detaches dashboards)
+ * GET /dashboard-workspaces/{id}/dashboards dashboards inside it
+ * GET /dashboard-workspaces/{id}/members member list
+ * POST /dashboard-workspaces/{id}/members add or change a member
+ * DELETE /dashboard-workspaces/{id}/members/{username} remove a member
+ * PUT /dashboard-workspaces/dashboards/{dashboardId} move a dashboard in/out
+ *
+ *
+ * Every method delegates its access check to {@link DashboardWorkspaceService}, which
+ * asserts connection access first and workspace membership second. As elsewhere in this
+ * codebase there is no filter doing this for you — a new endpoint here must call the
+ * service, never the repositories directly.
+ */
+@RestController
+@RequestMapping("/dashboard-workspaces")
+@RequiredArgsConstructor
+@Slf4j
+public class DashboardWorkspaceController {
+
+ private final DashboardWorkspaceService workspaceService;
+ private final SavedDashboardRepository savedDashboardRepository;
+
+ @GetMapping("/connection/{connectionId}")
+ public ResponseEntity> listWorkspaces(@PathVariable String connectionId) {
+ try {
+ List workspaces = workspaceService.listVisibleWorkspaces(connectionId);
+ return ResponseEntity.ok(workspaces.stream().map(this::describe).toList());
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error listing dashboard workspaces", e);
+ return failure("Failed to load workspaces");
+ }
+ }
+
+ @PostMapping
+ public ResponseEntity> createWorkspace(@RequestBody Map body) {
+ try {
+ String connectionId = asString(body.get("connectionId"));
+ if (connectionId == null) {
+ return ResponseEntity.badRequest().body(Map.of("success", false, "message", "connectionId is required"));
+ }
+ DashboardWorkspace workspace = workspaceService.createWorkspace(
+ connectionId,
+ asString(body.get("name")),
+ asString(body.get("description")),
+ asString(body.get("color"))
+ );
+ return ResponseEntity.ok(describe(workspace));
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error creating dashboard workspace", e);
+ return failure("Failed to create workspace");
+ }
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity> getWorkspace(@PathVariable UUID id) {
+ try {
+ return ResponseEntity.ok(describe(workspaceService.getWorkspace(id)));
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error loading dashboard workspace", e);
+ return failure("Failed to load workspace");
+ }
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity> updateWorkspace(@PathVariable UUID id, @RequestBody Map body) {
+ try {
+ DashboardWorkspace workspace = workspaceService.updateWorkspace(
+ id,
+ asString(body.get("name")),
+ // Distinguish "omitted" from "cleared": a present-but-blank value clears
+ // the field, matching updateDashboard's convention.
+ body.containsKey("description") ? String.valueOf(Objects.toString(body.get("description"), "")) : null,
+ body.containsKey("color") ? String.valueOf(Objects.toString(body.get("color"), "")) : null
+ );
+ return ResponseEntity.ok(describe(workspace));
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error updating dashboard workspace", e);
+ return failure("Failed to update workspace");
+ }
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity> deleteWorkspace(@PathVariable UUID id) {
+ try {
+ workspaceService.deleteWorkspace(id);
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error deleting dashboard workspace", e);
+ return failure("Failed to delete workspace");
+ }
+ }
+
+ @GetMapping("/{id}/dashboards")
+ public ResponseEntity> listDashboards(@PathVariable UUID id) {
+ try {
+ return ResponseEntity.ok(workspaceService.listDashboards(id));
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error listing workspace dashboards", e);
+ return failure("Failed to load dashboards");
+ }
+ }
+
+ @GetMapping("/{id}/members")
+ public ResponseEntity> listMembers(@PathVariable UUID id) {
+ try {
+ List members = workspaceService.listMembers(id);
+ return ResponseEntity.ok(members.stream().map(this::describeMember).toList());
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error listing workspace members", e);
+ return failure("Failed to load members");
+ }
+ }
+
+ @PostMapping("/{id}/members")
+ public ResponseEntity> addMember(@PathVariable UUID id, @RequestBody Map body) {
+ try {
+ DashboardWorkspaceMember member = workspaceService.addMember(
+ id, asString(body.get("username")), asString(body.get("workspaceRole")));
+ return ResponseEntity.ok(describeMember(member));
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error adding workspace member", e);
+ return failure("Failed to add member");
+ }
+ }
+
+ @DeleteMapping("/{id}/members/{username}")
+ public ResponseEntity> removeMember(@PathVariable UUID id, @PathVariable String username) {
+ try {
+ workspaceService.removeMember(id, username);
+ return ResponseEntity.ok(Map.of("success", true));
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error removing workspace member", e);
+ return failure("Failed to remove member");
+ }
+ }
+
+ /** Move a dashboard into a workspace, or out of one with a null/blank workspaceId. */
+ @PutMapping("/dashboards/{dashboardId}")
+ public ResponseEntity> moveDashboard(@PathVariable UUID dashboardId, @RequestBody Map body) {
+ try {
+ String raw = asString(body.get("workspaceId"));
+ UUID target = raw == null ? null : UUID.fromString(raw);
+ SavedDashboard dashboard = workspaceService.moveDashboard(dashboardId, target);
+ return ResponseEntity.ok(Map.of(
+ "success", true,
+ "dashboardId", dashboard.getId().toString(),
+ "workspaceId", dashboard.getWorkspaceId() == null ? "" : dashboard.getWorkspaceId().toString()
+ ));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Invalid workspace id"));
+ } catch (ResponseStatusException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Error moving dashboard between workspaces", e);
+ return failure("Failed to move dashboard");
+ }
+ }
+
+ private Map describe(DashboardWorkspace workspace) {
+ Map body = new LinkedHashMap<>();
+ body.put("id", workspace.getId().toString());
+ body.put("connectionId", workspace.getConnectionId());
+ body.put("name", workspace.getName());
+ body.put("description", workspace.getDescription());
+ body.put("color", workspace.getColor());
+ body.put("createdBy", workspace.getCreatedBy());
+ body.put("createdAt", workspace.getCreatedAt());
+ body.put("updatedAt", workspace.getUpdatedAt());
+ body.put("dashboardCount", savedDashboardRepository.countByWorkspaceId(workspace.getId()));
+ return body;
+ }
+
+ private Map describeMember(DashboardWorkspaceMember member) {
+ Map body = new LinkedHashMap<>();
+ body.put("id", member.getId().toString());
+ body.put("username", member.getUsername());
+ body.put("workspaceRole", member.getWorkspaceRole().name());
+ body.put("addedBy", member.getAddedBy());
+ body.put("createdAt", member.getCreatedAt());
+ return body;
+ }
+
+ private static ResponseEntity> failure(String message) {
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(Map.of("success", false, "message", message));
+ }
+
+ private static String asString(Object value) {
+ if (value == null) return null;
+ String s = String.valueOf(value).trim();
+ return s.isEmpty() ? null : s;
+ }
+}
diff --git a/backend/src/main/java/com/dbaagent/controller/ImpersonationController.java b/backend/src/main/java/com/dbaagent/controller/ImpersonationController.java
index 8810972..aec2339 100644
--- a/backend/src/main/java/com/dbaagent/controller/ImpersonationController.java
+++ b/backend/src/main/java/com/dbaagent/controller/ImpersonationController.java
@@ -81,12 +81,13 @@ public ResponseEntity> stop(
}
private Map toAuthPayload(User user, User impersonator) {
- Role role = user.getRoleEnum();
- Set permissions = permissionService.getEffectivePermissionCodes(role);
+ String roleCode = user.getRoleCode();
+ Set permissions = permissionService.getEffectivePermissionCodes(roleCode);
Map payload = new LinkedHashMap<>();
payload.put("username", user.getUsername());
payload.put("email", user.getEmail());
- payload.put("role", role.name());
+ payload.put("role", roleCode);
+ payload.put("roleName", permissionService.describeRole(roleCode));
payload.put("permissions", permissions);
payload.put("emailVerified", user.isEmailVerified());
payload.put("accountStatus", user.getAccountStatus());
diff --git a/backend/src/main/java/com/dbaagent/controller/PermissionController.java b/backend/src/main/java/com/dbaagent/controller/PermissionController.java
index 89b3bcb..174fce6 100644
--- a/backend/src/main/java/com/dbaagent/controller/PermissionController.java
+++ b/backend/src/main/java/com/dbaagent/controller/PermissionController.java
@@ -1,8 +1,10 @@
package com.dbaagent.controller;
+import com.dbaagent.model.CustomRole;
import com.dbaagent.model.Permission;
import com.dbaagent.model.Role;
import com.dbaagent.model.RolePermissionOverride;
+import com.dbaagent.service.CustomRoleService;
import com.dbaagent.service.PermissionService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -13,31 +15,35 @@
import java.util.*;
/**
- * Controller for permission-related endpoints.
+ * Permission and role endpoints.
*
- * Public endpoints:
- * - GET /api/permissions/me - Get current user's effective permissions
- *
- * Admin endpoints:
- * - GET /api/permissions/registry - Get full permission registry
- * - GET /api/permissions/roles - Get all roles with permissions
- * - GET /api/permissions/overrides - Get all overrides
- * - POST /api/permissions/overrides - Create/update an override
- * - DELETE /api/permissions/overrides - Remove an override
+ *
+ * GET /permissions/me current user's effective permissions
+ * GET /permissions/registry every permission and who holds it (admin)
+ * GET /permissions/roles built-in + custom roles (admin)
+ * POST /permissions/roles create a custom role (admin)
+ * PUT /permissions/roles/{code} update a custom role (admin)
+ * DELETE /permissions/roles/{code} delete an unused custom role (admin)
+ * GET /permissions/overrides all role-permission overrides (admin)
+ * POST /permissions/overrides create or update an override (admin)
+ * DELETE /permissions/overrides remove an override (admin)
+ *
*/
@RestController
@RequestMapping("/permissions")
public class PermissionController {
private final PermissionService permissionService;
+ private final CustomRoleService customRoleService;
- public PermissionController(PermissionService permissionService) {
+ public PermissionController(PermissionService permissionService, CustomRoleService customRoleService) {
this.permissionService = permissionService;
+ this.customRoleService = customRoleService;
}
/**
- * Get the current user's effective permissions.
- * This is called by frontend after login to know what the user can do.
+ * The current user's effective permissions — what the frontend uses to decide which
+ * menus and actions to show.
*/
@GetMapping("/me")
public ResponseEntity> getMyPermissions() {
@@ -46,55 +52,113 @@ public ResponseEntity> getMyPermissions() {
return ResponseEntity.status(401).body(Map.of("error", "Not authenticated"));
}
- // Extract role from authorities
- Role role = extractRoleFromAuthentication(auth);
- Set permissions = permissionService.getEffectivePermissionCodes(role);
+ String roleCode = extractRoleCode(auth);
+ Set permissions = permissionService.getEffectivePermissionCodes(roleCode);
Map response = new HashMap<>();
- response.put("role", role.name());
+ response.put("role", roleCode);
+ response.put("roleName", permissionService.describeRole(roleCode));
response.put("permissions", permissions);
response.put("permissionCount", permissions.size());
-
return ResponseEntity.ok(response);
}
- /**
- * Get the full permission registry.
- * Shows all permissions with their default roles and any overrides.
- */
@GetMapping("/registry")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity> getPermissionRegistry() {
List registry = permissionService.getPermissionRegistry();
+ List> catalog = Arrays.stream(Permission.values())
+ .map(p -> Map.of("code", p.name(), "description", p.getDescription()))
+ .toList();
return ResponseEntity.ok(Map.of(
"permissions", registry,
+ "catalog", catalog,
"totalPermissions", Permission.values().length,
- "totalRoles", Role.values().length
+ "totalRoles", permissionService.getAllRoleCodes().size()
));
}
- /**
- * Get all roles with their effective permissions.
- */
@GetMapping("/roles")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity> getRoles() {
List roles = permissionService.getRoleRegistry();
- return ResponseEntity.ok(Map.of("roles", roles));
+ for (PermissionService.RoleInfo role : roles) {
+ role.userCount = customRoleService.countUsersWithRole(role.code);
+ }
+ return ResponseEntity.ok(Map.of(
+ "roles", roles,
+ "catalog", Arrays.stream(Permission.values())
+ .map(p -> Map.of("code", p.name(), "description", p.getDescription()))
+ .toList()
+ ));
+ }
+
+ /** Create a custom role from a name plus an explicit permission list. */
+ @PostMapping("/roles")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity> createCustomRole(@RequestBody Map request) {
+ try {
+ CustomRole role = customRoleService.createRole(
+ asString(request.get("name")),
+ asString(request.get("description")),
+ asStringList(request.get("permissions")),
+ currentActor()
+ );
+ return ResponseEntity.ok(Map.of("success", true, "role", describe(role)));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(Map.of("success", false, "message", e.getMessage()));
+ }
+ }
+
+ @PutMapping("/roles/{code}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity> updateCustomRole(@PathVariable String code, @RequestBody Map request) {
+ try {
+ if (Role.isBuiltIn(code)) {
+ // Built-in role permission sets are code, not data. Changing one is what
+ // overrides are for, so the admin's change survives an upgrade.
+ return ResponseEntity.badRequest().body(Map.of(
+ "success", false,
+ "message", "Built-in roles cannot be edited directly. Use a permission override, "
+ + "or create a custom role."
+ ));
+ }
+ CustomRole role = customRoleService.updateRole(
+ code,
+ asString(request.get("name")),
+ request.containsKey("description") ? asString(request.get("description")) : null,
+ request.containsKey("permissions") ? asStringList(request.get("permissions")) : null,
+ currentActor()
+ );
+ return ResponseEntity.ok(Map.of("success", true, "role", describe(role)));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(Map.of("success", false, "message", e.getMessage()));
+ }
+ }
+
+ @DeleteMapping("/roles/{code}")
+ @PreAuthorize("hasRole('ADMIN')")
+ public ResponseEntity> deleteCustomRole(@PathVariable String code) {
+ try {
+ if (Role.isBuiltIn(code)) {
+ return ResponseEntity.badRequest().body(Map.of(
+ "success", false, "message", "Built-in roles cannot be deleted"));
+ }
+ customRoleService.deleteRole(code, currentActor());
+ return ResponseEntity.ok(Map.of("success", true, "message", "Role deleted"));
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(Map.of("success", false, "message", e.getMessage()));
+ }
}
- /**
- * Get all permission overrides.
- */
@GetMapping("/overrides")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity> getOverrides() {
- List overrides = permissionService.getAllOverrides();
- List> overrideList = overrides.stream()
+ List> overrideList = permissionService.getAllOverrides().stream()
.map(o -> {
Map map = new HashMap<>();
map.put("id", o.getId());
- map.put("role", o.getRole().name());
+ map.put("role", o.getRole());
map.put("permission", o.getPermissionCode().name());
map.put("granted", o.isGranted());
map.put("reason", o.getReason());
@@ -103,137 +167,137 @@ public ResponseEntity> getOverrides() {
return map;
})
.toList();
-
- return ResponseEntity.ok(Map.of(
- "overrides", overrideList,
- "totalOverrides", overrideList.size()
- ));
+ return ResponseEntity.ok(Map.of("overrides", overrideList, "totalOverrides", overrideList.size()));
}
- /**
- * Create or update a permission override.
- */
@PostMapping("/overrides")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity> setOverride(@RequestBody Map request) {
try {
- String roleStr = (String) request.get("role");
- String permissionStr = (String) request.get("permission");
+ String roleCode = asString(request.get("role"));
+ String permissionStr = asString(request.get("permission"));
Boolean granted = (Boolean) request.get("granted");
- String reason = (String) request.get("reason");
+ String reason = asString(request.get("reason"));
- if (roleStr == null || permissionStr == null || granted == null) {
+ if (roleCode == null || permissionStr == null || granted == null) {
return ResponseEntity.badRequest().body(Map.of(
- "error", "Missing required fields: role, permission, granted"
- ));
+ "error", "Missing required fields: role, permission, granted"));
}
- Role role = Role.valueOf(roleStr.toUpperCase());
- Permission permission = Permission.valueOf(permissionStr.toUpperCase());
-
- // Get current user for audit
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
- String updatedBy = auth != null ? auth.getName() : "system";
+ Permission permission = Permission.fromCode(permissionStr);
+ if (permission == null) {
+ return ResponseEntity.badRequest().body(Map.of("error", "Unknown permission: " + permissionStr));
+ }
RolePermissionOverride override = permissionService.setOverride(
- role, permission, granted, reason, updatedBy
- );
+ roleCode, permission, granted, reason, currentActor());
+
+ Map overrideBody = new HashMap<>();
+ overrideBody.put("id", override.getId());
+ overrideBody.put("role", override.getRole());
+ overrideBody.put("permission", override.getPermissionCode().name());
+ overrideBody.put("granted", override.isGranted());
+ overrideBody.put("reason", override.getReason());
return ResponseEntity.ok(Map.of(
"success", true,
- "message", granted ?
- "Permission " + permission + " granted to " + role :
- "Permission " + permission + " revoked from " + role,
- "override", Map.of(
- "id", override.getId(),
- "role", override.getRole().name(),
- "permission", override.getPermissionCode().name(),
- "granted", override.isGranted(),
- "reason", override.getReason()
- )
+ "message", (granted ? "Permission granted to " : "Permission revoked from ")
+ + permissionService.describeRole(roleCode),
+ "override", overrideBody
));
} catch (IllegalArgumentException e) {
- return ResponseEntity.badRequest().body(Map.of(
- "error", "Invalid role or permission: " + e.getMessage()
- ));
+ return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
- /**
- * Remove a permission override (revert to default).
- */
@DeleteMapping("/overrides")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity> removeOverride(@RequestBody Map request) {
- try {
- String roleStr = request.get("role");
- String permissionStr = request.get("permission");
-
- if (roleStr == null || permissionStr == null) {
- return ResponseEntity.badRequest().body(Map.of(
- "error", "Missing required fields: role, permission"
- ));
- }
-
- Role role = Role.valueOf(roleStr.toUpperCase());
- Permission permission = Permission.valueOf(permissionStr.toUpperCase());
-
- permissionService.removeOverride(role, permission);
-
- return ResponseEntity.ok(Map.of(
- "success", true,
- "message", "Override removed. Permission " + permission +
- " for " + role + " reverted to default behavior."
- ));
- } catch (IllegalArgumentException e) {
+ String roleCode = request.get("role");
+ String permissionStr = request.get("permission");
+ if (roleCode == null || permissionStr == null) {
return ResponseEntity.badRequest().body(Map.of(
- "error", "Invalid role or permission: " + e.getMessage()
- ));
+ "error", "Missing required fields: role, permission"));
}
+ Permission permission = Permission.fromCode(permissionStr);
+ if (permission == null) {
+ return ResponseEntity.badRequest().body(Map.of("error", "Unknown permission: " + permissionStr));
+ }
+ permissionService.removeOverride(roleCode, permission);
+ return ResponseEntity.ok(Map.of(
+ "success", true,
+ "message", "Override removed; " + permission + " reverted to the role's default."
+ ));
}
- /**
- * Check if a specific role has a specific permission.
- */
+ /** Whether a role holds a permission, and whether that differs from its default. */
@GetMapping("/check")
- public ResponseEntity> checkPermission(
- @RequestParam String role,
- @RequestParam String permission) {
- try {
- Role r = Role.valueOf(role.toUpperCase());
- Permission p = Permission.valueOf(permission.toUpperCase());
+ public ResponseEntity> checkPermission(@RequestParam String role, @RequestParam String permission) {
+ Permission p = Permission.fromCode(permission);
+ if (p == null) {
+ return ResponseEntity.badRequest().body(Map.of("error", "Unknown permission: " + permission));
+ }
+ if (!permissionService.roleExists(role)) {
+ return ResponseEntity.badRequest().body(Map.of("error", "Unknown role: " + role));
+ }
+ boolean granted = permissionService.hasPermission(role, p);
+ Role builtIn = Role.fromString(role);
+ boolean isDefault = builtIn != null && p.isGrantedByDefaultTo(builtIn);
+ return ResponseEntity.ok(Map.of(
+ "role", role.toUpperCase(),
+ "permission", p.name(),
+ "granted", granted,
+ "isDefault", isDefault,
+ "hasOverride", granted != isDefault
+ ));
+ }
- boolean hasPermission = permissionService.hasPermission(r, p);
- boolean isDefault = p.isGrantedByDefaultTo(r);
+ private static Map describe(CustomRole role) {
+ Map body = new HashMap<>();
+ body.put("code", role.getCode());
+ body.put("name", role.getName());
+ body.put("description", role.getDescription());
+ body.put("permissions", role.getPermissionCodeSet());
+ body.put("builtIn", false);
+ return body;
+ }
- return ResponseEntity.ok(Map.of(
- "role", r.name(),
- "permission", p.name(),
- "granted", hasPermission,
- "isDefault", isDefault,
- "hasOverride", hasPermission != isDefault
- ));
- } catch (IllegalArgumentException e) {
- return ResponseEntity.badRequest().body(Map.of(
- "error", "Invalid role or permission: " + e.getMessage()
- ));
+ private static String currentActor() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ return auth != null ? auth.getName() : "system";
+ }
+
+ private static String asString(Object value) {
+ if (value == null) return null;
+ String s = String.valueOf(value).trim();
+ return s.isEmpty() ? null : s;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List asStringList(Object value) {
+ if (value == null) return List.of();
+ if (value instanceof Collection> collection) {
+ return collection.stream().filter(Objects::nonNull).map(String::valueOf).toList();
}
+ return List.of(String.valueOf(value));
}
- private Role extractRoleFromAuthentication(Authentication auth) {
+ /**
+ * The caller's role code from their granted authorities.
+ *
+ * Custom roles carry a {@code ROLE_} authority just like built-in ones (see
+ * {@code CustomUserDetailsService}), so this returns the code as stored without
+ * needing to know whether it is built-in.
+ */
+ private static String extractRoleCode(Authentication auth) {
if (auth.getAuthorities() != null) {
for (var authority : auth.getAuthorities()) {
- String authorityStr = authority.getAuthority();
- if (authorityStr.startsWith("ROLE_")) {
- String roleName = authorityStr.substring(5);
- try {
- return Role.valueOf(roleName.toUpperCase());
- } catch (IllegalArgumentException ignored) {
- // Continue to next authority
- }
+ String value = authority.getAuthority();
+ if (value != null && value.startsWith("ROLE_")) {
+ return value.substring(5);
}
}
}
- return Role.DEVELOPER; // Default fallback
+ return Role.DEVELOPER.name();
}
}
diff --git a/backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java b/backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java
index 7dc8ca5..34bc8ec 100644
--- a/backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java
+++ b/backend/src/main/java/com/dbaagent/controller/SavedDashboardController.java
@@ -3,6 +3,7 @@
import com.dbaagent.model.DashboardVersion;
import com.dbaagent.model.SavedDashboard;
import com.dbaagent.service.ConnectionChatAccessPolicyService;
+import com.dbaagent.service.DashboardWorkspaceService;
import com.dbaagent.service.SavedDashboardService;
import com.dbaagent.service.security.AccessControlService;
import lombok.extern.slf4j.Slf4j;
@@ -31,6 +32,9 @@ public class SavedDashboardController {
@Autowired
private ConnectionChatAccessPolicyService connectionChatAccessPolicyService;
+ @Autowired
+ private DashboardWorkspaceService dashboardWorkspaceService;
+
// Every write method below is load-then-save on a row a background generation
// turn (SavedDashboardService.beginGenerationTurn etc.) may be writing at the
// same time. Without this helper, the loser's raw Hibernate message
@@ -44,6 +48,17 @@ private static ResponseEntity> conflict(OptimisticLockingFai
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
+ /**
+ * A dashboard may only be created into a workspace the caller can actually manage —
+ * otherwise anyone could push a dashboard into someone else's workspace.
+ */
+ private void assertWorkspaceAssignable(String connectionId, UUID workspaceId) {
+ if (workspaceId == null) {
+ return;
+ }
+ dashboardWorkspaceService.getWorkspace(workspaceId);
+ }
+
/** Publish this dashboard to the web (opt-in, revocable public link). */
@PostMapping("/{id}/share")
public ResponseEntity> enableShare(@PathVariable UUID id) {
@@ -51,6 +66,7 @@ public ResponseEntity> enableShare(@PathVariable UUID id) {
SavedDashboard existing = savedDashboardService.getDashboardById(id)
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
accessControlService.assertCanReadConnectionContent(existing.getConnectionId());
+ dashboardWorkspaceService.assertCanReadDashboard(existing);
if (connectionChatAccessPolicyService.hasActivePolicy(existing.getConnectionId())) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of(
"success", false,
@@ -81,6 +97,7 @@ public ResponseEntity> setSharePassword(@PathVariable UUID i
SavedDashboard existing = savedDashboardService.getDashboardById(id)
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
accessControlService.assertCanReadConnectionContent(existing.getConnectionId());
+ dashboardWorkspaceService.assertCanReadDashboard(existing);
SavedDashboard d = savedDashboardService.setSharePassword(id, body == null ? null : body.get("password"));
return ResponseEntity.ok(Map.of("success", true, "sharePasswordSet", d.isSharePasswordSet()));
} catch (IllegalArgumentException e) {
@@ -103,6 +120,7 @@ public ResponseEntity> disableShare(@PathVariable UUID id) {
SavedDashboard existing = savedDashboardService.getDashboardById(id)
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
accessControlService.assertCanReadConnectionContent(existing.getConnectionId());
+ dashboardWorkspaceService.assertCanReadDashboard(existing);
savedDashboardService.disablePublicShare(id);
return ResponseEntity.ok(Map.of("success", true, "isPublic", false));
} catch (IllegalArgumentException e) {
@@ -125,6 +143,8 @@ public ResponseEntity> disableShare(@PathVariable UUID id) {
public ResponseEntity> createDashboard(@RequestBody SavedDashboard savedDashboard) {
try {
log.info("Creating saved dashboard: {} for connection: {}", savedDashboard.getName(), savedDashboard.getConnectionId());
+ accessControlService.assertCanManageConnectionContent(savedDashboard.getConnectionId());
+ assertWorkspaceAssignable(savedDashboard.getConnectionId(), savedDashboard.getWorkspaceId());
SavedDashboard created = savedDashboardService.saveDashboard(savedDashboard);
@@ -152,8 +172,10 @@ public ResponseEntity> createDashboard(@RequestBody SavedDas
public ResponseEntity> getDashboardsByConnection(@PathVariable String connectionId) {
try {
log.info("Fetching saved dashboards for connection: {}", connectionId);
+ accessControlService.assertCanReadConnectionContent(connectionId);
- List dashboards = savedDashboardService.getDashboardsByConnection(connectionId);
+ List dashboards = dashboardWorkspaceService.filterReadable(
+ savedDashboardService.getDashboardsByConnection(connectionId));
Map response = new HashMap<>();
response.put("success", true);
@@ -182,6 +204,8 @@ public ResponseEntity> getDashboardById(@PathVariable UUID i
return savedDashboardService.getDashboardById(id)
.map(dashboard -> {
+ accessControlService.assertCanReadConnectionContent(dashboard.getConnectionId());
+ dashboardWorkspaceService.assertCanReadDashboard(dashboard);
Map response = new HashMap<>();
response.put("success", true);
response.put("savedDashboard", dashboard);
@@ -211,6 +235,10 @@ public ResponseEntity> getDashboardById(@PathVariable UUID i
public ResponseEntity> updateDashboard(@PathVariable UUID id, @RequestBody SavedDashboard updates) {
try {
log.info("Updating saved dashboard: {}", id);
+ SavedDashboard existing = savedDashboardService.getDashboardById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
+ accessControlService.assertCanManageConnectionContent(existing.getConnectionId());
+ dashboardWorkspaceService.assertCanReadDashboard(existing);
SavedDashboard updated = savedDashboardService.updateDashboard(id, updates);
@@ -248,6 +276,7 @@ public ResponseEntity> cloneDashboard(@PathVariable UUID id)
SavedDashboard existing = savedDashboardService.getDashboardById(id)
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
accessControlService.assertCanManageConnectionContent(existing.getConnectionId());
+ dashboardWorkspaceService.assertCanReadDashboard(existing);
SavedDashboard clone = savedDashboardService.cloneDashboard(id);
Map response = new HashMap<>();
response.put("success", true);
@@ -273,6 +302,7 @@ public ResponseEntity> getVersionHistory(@PathVariable UUID
SavedDashboard existing = savedDashboardService.getDashboardById(id)
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
accessControlService.assertCanReadConnectionContent(existing.getConnectionId());
+ dashboardWorkspaceService.assertCanReadDashboard(existing);
List versions = savedDashboardService.getVersionHistory(id);
Map response = new HashMap<>();
response.put("success", true);
@@ -298,6 +328,7 @@ public ResponseEntity> restoreVersion(@PathVariable UUID id,
SavedDashboard existing = savedDashboardService.getDashboardById(id)
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
accessControlService.assertCanManageConnectionContent(existing.getConnectionId());
+ dashboardWorkspaceService.assertCanReadDashboard(existing);
SavedDashboard restored = savedDashboardService.restoreVersion(id, versionId);
Map response = new HashMap<>();
response.put("success", true);
@@ -323,6 +354,10 @@ public ResponseEntity> restoreVersion(@PathVariable UUID id,
public ResponseEntity> deleteDashboard(@PathVariable UUID id) {
try {
log.info("Deleting saved dashboard: {}", id);
+ SavedDashboard existing = savedDashboardService.getDashboardById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
+ accessControlService.assertCanManageConnectionContent(existing.getConnectionId());
+ dashboardWorkspaceService.assertCanReadDashboard(existing);
savedDashboardService.deleteDashboard(id);
@@ -384,8 +419,10 @@ public ResponseEntity> toggleFavorite(@PathVariable UUID id)
public ResponseEntity> getFavoriteDashboards(@PathVariable String connectionId) {
try {
log.info("Fetching favorite dashboards for connection: {}", connectionId);
+ accessControlService.assertCanReadConnectionContent(connectionId);
- List dashboards = savedDashboardService.getFavoriteDashboards(connectionId);
+ List dashboards = dashboardWorkspaceService.filterReadable(
+ savedDashboardService.getFavoriteDashboards(connectionId));
Map response = new HashMap<>();
response.put("success", true);
@@ -413,8 +450,10 @@ public ResponseEntity> getDashboardsByFolder(
@PathVariable String folder) {
try {
log.info("Fetching dashboards in folder: {} for connection: {}", folder, connectionId);
+ accessControlService.assertCanReadConnectionContent(connectionId);
- List dashboards = savedDashboardService.getDashboardsByFolder(connectionId, folder);
+ List dashboards = dashboardWorkspaceService.filterReadable(
+ savedDashboardService.getDashboardsByFolder(connectionId, folder));
Map response = new HashMap<>();
response.put("success", true);
@@ -442,8 +481,10 @@ public ResponseEntity> searchDashboards(
@RequestParam String q) {
try {
log.info("Searching dashboards for connection: {} with term: {}", connectionId, q);
+ accessControlService.assertCanReadConnectionContent(connectionId);
- List dashboards = savedDashboardService.searchDashboards(connectionId, q);
+ List dashboards = dashboardWorkspaceService.filterReadable(
+ savedDashboardService.searchDashboards(connectionId, q));
Map response = new HashMap<>();
response.put("success", true);
@@ -469,6 +510,7 @@ public ResponseEntity> searchDashboards(
public ResponseEntity> getFolders(@PathVariable String connectionId) {
try {
log.info("Fetching folders for connection: {}", connectionId);
+ accessControlService.assertCanReadConnectionContent(connectionId);
List folders = savedDashboardService.getFolders(connectionId);
diff --git a/backend/src/main/java/com/dbaagent/model/ConnectionAccessLevel.java b/backend/src/main/java/com/dbaagent/model/ConnectionAccessLevel.java
index 6f198db..9b8e9c3 100644
--- a/backend/src/main/java/com/dbaagent/model/ConnectionAccessLevel.java
+++ b/backend/src/main/java/com/dbaagent/model/ConnectionAccessLevel.java
@@ -1,16 +1,32 @@
package com.dbaagent.model;
+/**
+ * The access level stored on a {@link ConnectionAccessGrant}.
+ *
+ * There is now only one level: {@link #FULL_CONTENT}. Assigning a connection to a user
+ * grants full content access to it — the old two-tier split (chat/editor only vs. full)
+ * was a distinction users had to reason about for little benefit, and it silently hid the
+ * Dashboards section from anyone on the lower tier.
+ *
+ *
{@code CHAT_EDITOR} is retained purely so existing rows written before this change
+ * still parse; {@link #fromString} folds it into FULL_CONTENT rather than failing, and
+ * nothing writes it any more. Do not reintroduce it as a distinct level without also
+ * restoring the UI that explains it.
+ */
public enum ConnectionAccessLevel {
+ /** @deprecated legacy value; resolves to {@link #FULL_CONTENT}. Retained for old rows. */
+ @Deprecated
CHAT_EDITOR,
FULL_CONTENT;
public static ConnectionAccessLevel fromString(String value) {
if (value == null || value.isBlank()) {
- throw new IllegalArgumentException("Access level is required");
+ // Assignment now implies full access, so an omitted level is not an error.
+ return FULL_CONTENT;
}
return switch (value.trim().toUpperCase()) {
- case "CHAT_EDITOR" -> CHAT_EDITOR;
- case "FULL_CONTENT", "FULL_ACCESS" -> FULL_CONTENT;
+ // Legacy values all collapse to the single remaining level.
+ case "CHAT_EDITOR", "FULL_CONTENT", "FULL_ACCESS" -> FULL_CONTENT;
default -> throw new IllegalArgumentException("Unsupported access level: " + value);
};
}
diff --git a/backend/src/main/java/com/dbaagent/model/CustomRole.java b/backend/src/main/java/com/dbaagent/model/CustomRole.java
new file mode 100644
index 0000000..f624806
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/model/CustomRole.java
@@ -0,0 +1,126 @@
+package com.dbaagent.model;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * An admin-defined role: a name plus an explicit set of permissions.
+ *
+ *
Unlike the built-in {@link Role} values, a custom role has no defaults to inherit —
+ * its permission set is exactly what an admin ticked, stored as a comma-separated list of
+ * {@link Permission} codes. Unknown codes (a permission removed in a later release) are
+ * dropped on read rather than failing, so an old row cannot break login.
+ *
+ *
The {@code code} is the value written to {@code users.role}, so it shares a namespace
+ * with the built-in role names; {@code DashboardWorkspaceService} and the role admin API
+ * both refuse to create a custom role whose code collides with a built-in one.
+ */
+@Entity
+@Table(name = "custom_roles", uniqueConstraints = {
+ @UniqueConstraint(name = "ux_custom_roles_code", columnNames = {"code"})
+})
+public class CustomRole {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ /** Stable uppercase identifier written to {@code users.role}. */
+ @Column(nullable = false, length = 64)
+ private String code;
+
+ @Column(nullable = false, length = 128)
+ private String name;
+
+ @Column(length = 500)
+ private String description;
+
+ /** Comma-separated {@link Permission} codes. */
+ @Column(name = "permission_codes", columnDefinition = "TEXT")
+ private String permissionCodes;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt = LocalDateTime.now();
+
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt = LocalDateTime.now();
+
+ @Column(name = "created_by", length = 255)
+ private String createdBy;
+
+ @Column(name = "updated_by", length = 255)
+ private String updatedBy;
+
+ @PreUpdate
+ public void preUpdate() {
+ this.updatedAt = LocalDateTime.now();
+ }
+
+ /** The permissions this role grants, with unknown codes dropped. */
+ @Transient
+ public Set getPermissions() {
+ if (permissionCodes == null || permissionCodes.isBlank()) {
+ return EnumSet.noneOf(Permission.class);
+ }
+ Set resolved = EnumSet.noneOf(Permission.class);
+ for (String raw : permissionCodes.split(",")) {
+ Permission permission = Permission.fromCode(raw);
+ if (permission != null) {
+ resolved.add(permission);
+ }
+ }
+ return resolved;
+ }
+
+ public void setPermissions(Set permissions) {
+ if (permissions == null || permissions.isEmpty()) {
+ this.permissionCodes = "";
+ return;
+ }
+ // LinkedHashSet over the enum's natural order keeps the stored string stable, so
+ // an unchanged permission set does not produce a spurious row update.
+ this.permissionCodes = new LinkedHashSet<>(EnumSet.copyOf(permissions)).stream()
+ .map(Enum::name)
+ .collect(Collectors.joining(","));
+ }
+
+ @Transient
+ public Set getPermissionCodeSet() {
+ Set codes = getPermissions().stream()
+ .map(Enum::name)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ return Collections.unmodifiableSet(codes);
+ }
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public String getCode() { return code; }
+ public void setCode(String code) { this.code = code; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public String getPermissionCodes() { return permissionCodes; }
+ public void setPermissionCodes(String permissionCodes) { this.permissionCodes = permissionCodes; }
+
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+
+ public LocalDateTime getUpdatedAt() { return updatedAt; }
+ public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
+
+ public String getCreatedBy() { return createdBy; }
+ public void setCreatedBy(String createdBy) { this.createdBy = createdBy; }
+
+ public String getUpdatedBy() { return updatedBy; }
+ public void setUpdatedBy(String updatedBy) { this.updatedBy = updatedBy; }
+}
diff --git a/backend/src/main/java/com/dbaagent/model/DashboardWorkspace.java b/backend/src/main/java/com/dbaagent/model/DashboardWorkspace.java
new file mode 100644
index 0000000..785c53c
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/model/DashboardWorkspace.java
@@ -0,0 +1,83 @@
+package com.dbaagent.model;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * A named group of dashboards within one connection, with its own member list.
+ *
+ * Workspace access narrows , never widens: a dashboard in a workspace is
+ * visible only to someone who both has read access to the connection (the existing
+ * per-connection ACL, unchanged) and is a member of the workspace. Admins bypass the
+ * membership half, matching how they already bypass connection grants. A dashboard with
+ * no workspace behaves exactly as it does today.
+ *
+ *
Scoped to a connection because every dashboard already is — a workspace spanning
+ * connections would have to re-check the connection ACL per dashboard anyway, so it
+ * would group things the ACL then pulls back apart.
+ */
+@Entity
+@Table(name = "dashboard_workspaces", indexes = {
+ @Index(name = "idx_dashboard_workspaces_connection", columnList = "connection_id")
+}, uniqueConstraints = {
+ @UniqueConstraint(name = "ux_dashboard_workspaces_conn_name", columnNames = {"connection_id", "name"})
+})
+public class DashboardWorkspace {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @Column(name = "connection_id", nullable = false)
+ private String connectionId;
+
+ @Column(nullable = false, length = 128)
+ private String name;
+
+ @Column(length = 500)
+ private String description;
+
+ /** Short colour token the UI uses for the workspace chip. */
+ @Column(length = 32)
+ private String color;
+
+ /** Username of the creator; always an implicit MANAGER member. */
+ @Column(name = "created_by", nullable = false, length = 255)
+ private String createdBy;
+
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @UpdateTimestamp
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ public UUID getId() { return id; }
+ public void setId(UUID id) { this.id = id; }
+
+ public String getConnectionId() { return connectionId; }
+ public void setConnectionId(String connectionId) { this.connectionId = connectionId; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public String getColor() { return color; }
+ public void setColor(String color) { this.color = color; }
+
+ public String getCreatedBy() { return createdBy; }
+ public void setCreatedBy(String createdBy) { this.createdBy = createdBy; }
+
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+
+ public LocalDateTime getUpdatedAt() { return updatedAt; }
+ public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
+}
diff --git a/backend/src/main/java/com/dbaagent/model/DashboardWorkspaceMember.java b/backend/src/main/java/com/dbaagent/model/DashboardWorkspaceMember.java
new file mode 100644
index 0000000..f332a8c
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/model/DashboardWorkspaceMember.java
@@ -0,0 +1,64 @@
+package com.dbaagent.model;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.CreationTimestamp;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * One user's membership of one {@link DashboardWorkspace}.
+ *
+ *
Keyed by username rather than user id to match how the rest of the access layer
+ * identifies actors ({@code ConnectionAccessGrant}, {@code AccessControlService}), so an
+ * impersonated ("View as") session resolves membership as the target user without a
+ * second lookup.
+ */
+@Entity
+@Table(name = "dashboard_workspace_members", indexes = {
+ @Index(name = "idx_dashboard_ws_members_workspace", columnList = "workspace_id"),
+ @Index(name = "idx_dashboard_ws_members_username", columnList = "username")
+}, uniqueConstraints = {
+ @UniqueConstraint(name = "ux_dashboard_ws_member", columnNames = {"workspace_id", "username"})
+})
+public class DashboardWorkspaceMember {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private UUID id;
+
+ @Column(name = "workspace_id", nullable = false)
+ private UUID workspaceId;
+
+ @Column(nullable = false, length = 255)
+ private String username;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "workspace_role", nullable = false, length = 32)
+ private DashboardWorkspaceRole workspaceRole = DashboardWorkspaceRole.VIEWER;
+
+ @Column(name = "added_by", length = 255)
+ private String addedBy;
+
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ public UUID getId() { return id; }
+ public void setId(UUID id) { this.id = id; }
+
+ public UUID getWorkspaceId() { return workspaceId; }
+ public void setWorkspaceId(UUID workspaceId) { this.workspaceId = workspaceId; }
+
+ public String getUsername() { return username; }
+ public void setUsername(String username) { this.username = username; }
+
+ public DashboardWorkspaceRole getWorkspaceRole() { return workspaceRole; }
+ public void setWorkspaceRole(DashboardWorkspaceRole workspaceRole) { this.workspaceRole = workspaceRole; }
+
+ public String getAddedBy() { return addedBy; }
+ public void setAddedBy(String addedBy) { this.addedBy = addedBy; }
+
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+}
diff --git a/backend/src/main/java/com/dbaagent/model/DashboardWorkspaceRole.java b/backend/src/main/java/com/dbaagent/model/DashboardWorkspaceRole.java
new file mode 100644
index 0000000..973f018
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/model/DashboardWorkspaceRole.java
@@ -0,0 +1,30 @@
+package com.dbaagent.model;
+
+/**
+ * A member's role within one dashboard workspace.
+ *
+ *
Deliberately two values, not a copy of the product's role model: this answers only
+ * "can this member change the workspace itself", on top of whatever the member's product
+ * role and connection grant already allow.
+ */
+public enum DashboardWorkspaceRole {
+ /** Can open the workspace and the dashboards in it. */
+ VIEWER,
+
+ /** VIEWER, plus renaming the workspace, adding/removing members, and moving dashboards in or out. */
+ MANAGER;
+
+ public boolean canManage() {
+ return this == MANAGER;
+ }
+
+ public static DashboardWorkspaceRole fromString(String value) {
+ if (value == null || value.isBlank()) {
+ return VIEWER;
+ }
+ return switch (value.trim().toUpperCase()) {
+ case "MANAGER", "ADMIN", "OWNER", "EDITOR" -> MANAGER;
+ default -> VIEWER;
+ };
+ }
+}
diff --git a/backend/src/main/java/com/dbaagent/model/Permission.java b/backend/src/main/java/com/dbaagent/model/Permission.java
index ac7e814..dce662f 100644
--- a/backend/src/main/java/com/dbaagent/model/Permission.java
+++ b/backend/src/main/java/com/dbaagent/model/Permission.java
@@ -2,69 +2,103 @@
/**
* Enumeration of all permissions in the system.
- * Each permission has a description and a default minimum role.
*
- * The defaultMinRole determines which roles get this permission by default
- * through role hierarchy inheritance:
- * - DEVELOPER permissions: Available to DEVELOPER, ADMIN
- * - ADMIN permissions: Available to ADMIN only
+ *
Permissions are the unit of authorization. A {@link Role} is nothing more than a
+ * named bundle of these, and a {@link CustomRole} is a bundle an admin defined at
+ * runtime. There is no longer a role hierarchy: the built-in roles overlap without
+ * nesting (Data Engineer sees Dashboards but not Digest; Developer sees Digest but
+ * cannot touch connection settings), so "is role A at least role B" is not a question
+ * with an answer. Ask whether a role holds a permission instead.
*
- * This can be overridden via RolePermissionOverride for exceptions.
+ *
{@link #defaultRoles} lists which built-in roles hold the permission out of the box.
+ * {@link RolePermissionOverride} can still add or remove one per role.
*/
public enum Permission {
- // ==================== ADMIN PRODUCT PERMISSIONS ====================
- VIEW_DASHBOARD("View dashboards and overview information", Role.ADMIN),
- VIEW_SCHEMA("Browse database schema, tables, and columns", Role.ADMIN),
- VIEW_SLOW_QUERIES("View slow query analysis and history", Role.ADMIN),
- VIEW_BRAIN("View Brain overview and database insights", Role.ADMIN),
- VIEW_PERFORMANCE("View performance metrics and insights", Role.ADMIN),
- VIEW_GROWTH("View growth monitoring data", Role.ADMIN),
- VIEW_PLAYBOOKS("View playbook definitions and history", Role.ADMIN),
+ // ==================== SECTION / MENU PERMISSIONS ====================
+ // One per top-level sidebar destination. These drive both the nav in the UI and
+ // the server-side section checks, so a hidden menu is not merely cosmetic.
+ VIEW_AGENT("Open the Agent chat section", Role.ADMIN, Role.DBA, Role.DATA_ENGINEER, Role.DEVELOPER),
+ VIEW_DASHBOARDS("Open the Dashboards section", Role.ADMIN, Role.DBA, Role.DATA_ENGINEER, Role.DEVELOPER),
+ VIEW_DIGEST("Open the Digest section", Role.ADMIN, Role.DBA, Role.DEVELOPER),
+ VIEW_BRAIN("Open the Brain section and database insights", Role.ADMIN, Role.DBA),
+ VIEW_PERFORMANCE("Open the Performance section (slow queries and workload)", Role.ADMIN, Role.DBA, Role.DEVELOPER),
+ VIEW_EDITOR("Open the SQL Editor section", Role.ADMIN, Role.DBA, Role.DATA_ENGINEER, Role.DEVELOPER),
- // ==================== DEVELOPER PERMISSIONS ====================
- EXECUTE_QUERIES("Execute SQL queries in SQL Runner", Role.DEVELOPER),
- USE_CHAT("Use the AI chat assistant", Role.DEVELOPER),
- EXPORT_DATA("Export query results and reports", Role.DEVELOPER),
+ // ==================== READ PERMISSIONS ====================
+ VIEW_DASHBOARD("View dashboards and overview information", Role.ADMIN, Role.DBA, Role.DATA_ENGINEER, Role.DEVELOPER),
+ VIEW_SCHEMA("Browse database schema, tables, and columns", Role.ADMIN, Role.DBA),
+ VIEW_SLOW_QUERIES("View slow query analysis and history", Role.ADMIN, Role.DBA, Role.DEVELOPER),
+ VIEW_GROWTH("View growth monitoring data", Role.ADMIN, Role.DBA),
+ VIEW_PLAYBOOKS("View playbook definitions and history", Role.ADMIN, Role.DBA),
- // ==================== ADMIN ACTION PERMISSIONS ====================
- RUN_ANALYSIS("Run analysis tasks (Key Columns, Schema, Anti-patterns)", Role.ADMIN),
- RUN_INGESTION("Run slow query log ingestion", Role.ADMIN),
- EXECUTE_PLAYBOOKS("Execute playbooks and automation", Role.ADMIN),
- USE_INDEX_ADVISOR("Apply Index Advisor recommendations", Role.ADMIN),
- MANAGE_ALERTS("Acknowledge and manage alerts", Role.ADMIN),
+ // ==================== CORE PRODUCT PERMISSIONS ====================
+ EXECUTE_QUERIES("Execute SQL queries in SQL Runner", Role.ADMIN, Role.DBA, Role.DATA_ENGINEER, Role.DEVELOPER),
+ USE_CHAT("Use the AI chat assistant", Role.ADMIN, Role.DBA, Role.DATA_ENGINEER, Role.DEVELOPER),
+ EXPORT_DATA("Export query results and reports", Role.ADMIN, Role.DBA, Role.DATA_ENGINEER, Role.DEVELOPER),
- // ==================== ADMIN PERMISSIONS (ADMIN ONLY) ====================
- MANAGE_CONNECTIONS("Create, edit, and delete database connections", Role.ADMIN),
+ // ==================== ACTION PERMISSIONS ====================
+ RUN_ANALYSIS("Run analysis tasks (Key Columns, Schema, Anti-patterns)", Role.ADMIN, Role.DBA),
+ RUN_INGESTION("Run slow query log ingestion", Role.ADMIN, Role.DBA),
+ EXECUTE_PLAYBOOKS("Execute playbooks and automation", Role.ADMIN, Role.DBA),
+ USE_INDEX_ADVISOR("Apply Index Advisor recommendations", Role.ADMIN, Role.DBA),
+ MANAGE_ALERTS("Acknowledge and manage alerts", Role.ADMIN, Role.DBA),
+
+ // ==================== WORKSPACE PERMISSIONS ====================
+ // Creating a workspace is a normal authoring action for anyone who can build
+ // dashboards; granting other people access to one is an administrative act and
+ // is checked per-workspace on top of this (see DashboardWorkspaceService).
+ MANAGE_DASHBOARD_WORKSPACES("Create dashboard workspaces and manage their members",
+ Role.ADMIN, Role.DBA, Role.DATA_ENGINEER, Role.DEVELOPER),
+
+ // ==================== ADMINISTRATIVE PERMISSIONS ====================
+ // DBA gets connection settings but explicitly NOT user creation.
+ MANAGE_CONNECTIONS("Create, edit, and delete database connections", Role.ADMIN, Role.DBA),
+ MANAGE_SETTINGS("Modify system settings and configurations", Role.ADMIN, Role.DBA),
MANAGE_USERS("View, edit roles, and delete users", Role.ADMIN),
MANAGE_INVITE_CODES("Generate and manage invite codes", Role.ADMIN),
- MANAGE_SETTINGS("Modify system settings and configurations", Role.ADMIN),
- MANAGE_PERMISSIONS("Manage role-permission overrides", Role.ADMIN);
+ MANAGE_PERMISSIONS("Manage roles and role-permission overrides", Role.ADMIN);
private final String description;
- private final Role defaultMinRole;
+ private final java.util.Set defaultRoles;
- Permission(String description, Role defaultMinRole) {
+ Permission(String description, Role... defaultRoles) {
this.description = description;
- this.defaultMinRole = defaultMinRole;
+ this.defaultRoles = defaultRoles.length == 0
+ ? java.util.EnumSet.noneOf(Role.class)
+ : java.util.EnumSet.copyOf(java.util.Arrays.asList(defaultRoles));
}
public String getDescription() {
return description;
}
- /**
- * The minimum role that gets this permission by default.
- * Higher roles in the hierarchy automatically inherit this permission.
- */
- public Role getDefaultMinRole() {
- return defaultMinRole;
+ /** The built-in roles that hold this permission by default. */
+ public java.util.Set getDefaultRoles() {
+ return java.util.Collections.unmodifiableSet(defaultRoles);
}
/**
- * Check if a role has this permission by default (via hierarchy).
- * A role has the permission if it's at or above the defaultMinRole.
+ * Check if a role has this permission by default, before overrides.
+ *
+ * ADMIN always holds every permission — an admin who could be locked out of
+ * user management by an override would be an unrecoverable install.
*/
public boolean isGrantedByDefaultTo(Role role) {
- return role.isAtLeast(defaultMinRole);
+ if (role == null) {
+ return false;
+ }
+ return role == Role.ADMIN || defaultRoles.contains(role);
+ }
+
+ /** Parse a permission code leniently; returns null when unknown. */
+ public static Permission fromCode(String code) {
+ if (code == null || code.isBlank()) {
+ return null;
+ }
+ try {
+ return Permission.valueOf(code.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
}
}
diff --git a/backend/src/main/java/com/dbaagent/model/Role.java b/backend/src/main/java/com/dbaagent/model/Role.java
index ce3c452..4f4d9c0 100644
--- a/backend/src/main/java/com/dbaagent/model/Role.java
+++ b/backend/src/main/java/com/dbaagent/model/Role.java
@@ -6,110 +6,104 @@
import java.util.stream.Collectors;
/**
- * Enumeration of user roles in the system.
- * Roles are hierarchical: each role inherits permissions from lower roles.
+ * Built-in user roles.
*
- * Hierarchy (lowest to highest):
- * DEVELOPER (0) → ADMIN (1)
+ *
These are not a hierarchy. The old model ranked DEVELOPER below ADMIN and
+ * compared roles with {@code ordinal()}; the current roles deliberately overlap without
+ * nesting — DATA_ENGINEER can open Dashboards but not Digest, DEVELOPER can open Digest
+ * but cannot edit connection settings — so there is no ordering to compare. Every
+ * authorization question is "does this role hold permission X", answered by
+ * {@link Permission#isGrantedByDefaultTo} and, with overrides applied, by
+ * {@code PermissionService}.
*
- * Permission assignment works via Permission.defaultMinRole:
- * - Permission with defaultMinRole=DEVELOPER → granted to DEVELOPER, ADMIN
- * - Permission with defaultMinRole=ADMIN → granted to ADMIN only
- *
- * This can be overridden via RolePermissionOverride for exceptions.
+ *
Roles beyond these are defined at runtime as {@link CustomRole} rows. A user's
+ * {@code role} column holds either one of these names or a custom role's code, which is
+ * why {@link #fromString} returns null for anything unrecognised rather than silently
+ * downgrading to DEVELOPER — a custom role name must not be mistaken for a built-in one.
*/
public enum Role {
- /**
- * DEVELOPER: Default product user.
- * Can use Chat and the SQL Editor.
- */
- DEVELOPER("Access to Chat and the SQL Editor"),
+ /** Full access to every product area and all administrative controls. */
+ ADMIN("Admin", "Full access to all product areas and administrative controls"),
- /**
- * ADMIN: Full access.
- * Inherits all DEVELOPER permissions plus access to all product areas and admin controls.
- */
- ADMIN("Access to all product areas and administrative controls");
+ /** All menu items and connection settings, but not user creation. */
+ DBA("DBA", "All product areas and connection settings, except user management"),
+
+ /** Agent, Dashboards, and the SQL Editor. */
+ DATA_ENGINEER("Data Engineer", "Agent, Dashboards, and the SQL Editor"),
+
+ /** Agent, Digest, Dashboards, Performance, and the SQL Editor. */
+ DEVELOPER("Developer", "Agent, Digest, Dashboards, Performance, and the SQL Editor");
+ private final String displayName;
private final String description;
- Role(String description) {
+ Role(String displayName, String description) {
+ this.displayName = displayName;
this.description = description;
}
+ public String getDisplayName() {
+ return displayName;
+ }
+
public String getDescription() {
return description;
}
- /**
- * Get the default permissions for this role based on hierarchy.
- * A role gets all permissions where permission.defaultMinRole <= this role.
- */
+ /** The permissions this role holds by default, before any override is applied. */
public Set getDefaultPermissions() {
return Arrays.stream(Permission.values())
.filter(p -> p.isGrantedByDefaultTo(this))
.collect(Collectors.toCollection(() -> EnumSet.noneOf(Permission.class)));
}
- /**
- * Get all permissions for this role.
- * Alias for getDefaultPermissions() for simpler API usage.
- */
+ /** Alias for {@link #getDefaultPermissions()}. */
public Set getPermissions() {
return getDefaultPermissions();
}
- /**
- * Check if this role has a permission by default (without considering overrides).
- */
+ /** Whether this role holds the permission by default (ignores overrides). */
public boolean hasPermissionByDefault(Permission permission) {
- return permission.isGrantedByDefaultTo(this);
+ return permission != null && permission.isGrantedByDefaultTo(this);
}
- /**
- * Check if this role has a specific permission.
- * Alias for hasPermissionByDefault() for simpler API usage.
- */
+ /** Alias for {@link #hasPermissionByDefault}. */
public boolean hasPermission(Permission permission) {
return hasPermissionByDefault(permission);
}
- /**
- * Check if this role is at or above another role in the hierarchy.
- * Used for permission inheritance.
- */
- public boolean isAtLeast(Role other) {
- return this.ordinal() >= other.ordinal();
+ public boolean isAdmin() {
+ return this == ADMIN;
}
/**
- * Check if this role is strictly above another role in the hierarchy.
- */
- public boolean isAbove(Role other) {
- return this.ordinal() > other.ordinal();
- }
-
- /**
- * Get a role by name, case-insensitive.
- * Legacy roles collapse into DEVELOPER for backward compatibility.
+ * Resolve a built-in role by name, case-insensitive.
+ *
+ * Returns {@code null} when the name is not a built-in role — the caller is then
+ * expected to look for a {@link CustomRole} with that code. Legacy role names that
+ * predate this enum collapse into DEVELOPER, which is what installs upgrading from
+ * the two-role model carry in their {@code users.role} column.
*/
public static Role fromString(String roleName) {
if (roleName == null || roleName.isBlank()) {
- return DEVELOPER;
+ return null;
}
return switch (roleName.trim().toUpperCase()) {
case "ADMIN" -> ADMIN;
+ case "DBA" -> DBA;
+ case "DATA_ENGINEER", "DATA-ENGINEER", "DATAENGINEER" -> DATA_ENGINEER;
case "DEVELOPER", "EDITOR", "VIEWER", "USER" -> DEVELOPER;
- default -> DEVELOPER;
+ default -> null;
};
}
- /**
- * Get all roles at or above the given minimum role.
- */
- public static Set getRolesAtOrAbove(Role minRole) {
- return Arrays.stream(values())
- .filter(r -> r.isAtLeast(minRole))
- .collect(Collectors.toSet());
+ /** As {@link #fromString}, but falls back to DEVELOPER instead of returning null. */
+ public static Role fromStringOrDefault(String roleName) {
+ Role role = fromString(roleName);
+ return role != null ? role : DEVELOPER;
+ }
+
+ public static boolean isBuiltIn(String roleName) {
+ return fromString(roleName) != null;
}
}
diff --git a/backend/src/main/java/com/dbaagent/model/RolePermissionOverride.java b/backend/src/main/java/com/dbaagent/model/RolePermissionOverride.java
index d2f1427..35fc826 100644
--- a/backend/src/main/java/com/dbaagent/model/RolePermissionOverride.java
+++ b/backend/src/main/java/com/dbaagent/model/RolePermissionOverride.java
@@ -23,9 +23,14 @@ public class RolePermissionOverride {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- @Column(nullable = false)
- @Enumerated(EnumType.STRING)
- private Role role;
+ /**
+ * The role this override applies to, as a role code: either a built-in {@link Role}
+ * name or a {@link CustomRole#getCode()}. It was an {@code @Enumerated} {@link Role}
+ * before custom roles existed; the column is unchanged (both forms are the same
+ * uppercase string), so existing rows keep working.
+ */
+ @Column(name = "role", nullable = false, length = 64)
+ private String role;
@Column(name = "permission_code", nullable = false)
@Enumerated(EnumType.STRING)
@@ -58,7 +63,7 @@ public RolePermissionOverride() {
this.updatedAt = LocalDateTime.now();
}
- public RolePermissionOverride(Role role, Permission permissionCode, boolean granted, String updatedBy) {
+ public RolePermissionOverride(String role, Permission permissionCode, boolean granted, String updatedBy) {
this();
this.role = role;
this.permissionCode = permissionCode;
@@ -66,7 +71,7 @@ public RolePermissionOverride(Role role, Permission permissionCode, boolean gran
this.updatedBy = updatedBy;
}
- public RolePermissionOverride(Role role, Permission permissionCode, boolean granted, String reason, String updatedBy) {
+ public RolePermissionOverride(String role, Permission permissionCode, boolean granted, String reason, String updatedBy) {
this(role, permissionCode, granted, updatedBy);
this.reason = reason;
}
@@ -85,11 +90,11 @@ public void setId(Long id) {
this.id = id;
}
- public Role getRole() {
+ public String getRole() {
return role;
}
- public void setRole(Role role) {
+ public void setRole(String role) {
this.role = role;
}
diff --git a/backend/src/main/java/com/dbaagent/model/SavedDashboard.java b/backend/src/main/java/com/dbaagent/model/SavedDashboard.java
index c1f7201..60c5fc4 100644
--- a/backend/src/main/java/com/dbaagent/model/SavedDashboard.java
+++ b/backend/src/main/java/com/dbaagent/model/SavedDashboard.java
@@ -15,6 +15,7 @@
@Index(name = "idx_saved_dashboards_connection_id", columnList = "connectionId"),
@Index(name = "idx_saved_dashboards_user_id", columnList = "userId"),
@Index(name = "idx_saved_dashboards_is_favorite", columnList = "isFavorite"),
+ @Index(name = "idx_saved_dashboards_workspace_id", columnList = "workspace_id"),
@Index(name = "idx_saved_dashboards_created_at", columnList = "createdAt")
})
@Data
@@ -77,6 +78,13 @@ public boolean isSharePasswordSet() {
@Column(length = 255)
private String folder;
+ // Optional grouping with its own member list (DashboardWorkspace). Null means the
+ // dashboard is not in a workspace and is governed purely by the connection ACL, as
+ // every dashboard was before workspaces existed. When set, a viewer needs BOTH
+ // connection read access AND workspace membership (admins bypass the latter).
+ @Column(name = "workspace_id")
+ private UUID workspaceId;
+
// Server-owned "is a generation turn in flight for this dashboard" marker.
// Set to RUNNING the instant a chat submit is accepted (before the slow
// agent work starts) and back to IDLE when it finishes — from the backend
diff --git a/backend/src/main/java/com/dbaagent/model/User.java b/backend/src/main/java/com/dbaagent/model/User.java
index a05f7b9..28262ef 100644
--- a/backend/src/main/java/com/dbaagent/model/User.java
+++ b/backend/src/main/java/com/dbaagent/model/User.java
@@ -60,14 +60,26 @@ public class User {
private LocalDateTime invitedAt;
/**
- * Get the Role enum for this user.
- * Returns DEVELOPER as default if role is null or invalid.
+ * The built-in {@link Role} for this user, or null when {@code role} names a
+ * custom role.
+ *
+ * It used to fall back to DEVELOPER for anything unrecognised. That is wrong now
+ * that custom roles exist: a user holding the custom role "ANALYST" would report
+ * itself as a DEVELOPER and inherit Developer's permissions instead of the ones the
+ * admin ticked. Callers that need a permission answer must go through
+ * {@code PermissionService}, which resolves both kinds by role code.
*/
@Transient
public Role getRoleEnum() {
return Role.fromString(this.role);
}
+ /** The user's role code, built-in or custom, as stored. */
+ @Transient
+ public String getRoleCode() {
+ return role == null || role.isBlank() ? Role.DEVELOPER.name() : role.trim().toUpperCase();
+ }
+
/**
* Set the role from a Role enum.
*/
@@ -76,27 +88,38 @@ public void setRoleEnum(Role role) {
}
/**
- * Get all permissions for this user based on their role.
+ * Default permissions for this user's built-in role.
+ *
+ *
Empty for a custom role — the authoritative answer for any role lives in
+ * {@code PermissionService.getEffectivePermissions(roleCode)}, which also applies
+ * overrides. This remains only for callers that already had a built-in role in hand.
*/
@Transient
public Set getPermissions() {
- return getRoleEnum().getPermissions();
+ Role builtIn = getRoleEnum();
+ return builtIn == null ? java.util.EnumSet.noneOf(Permission.class) : builtIn.getPermissions();
}
/**
- * Check if this user has a specific permission.
+ * Whether this user's built-in role holds a permission by default.
+ * Custom roles and overrides are not consulted here; use {@code PermissionService}.
*/
@Transient
public boolean hasPermission(Permission permission) {
- return getRoleEnum().hasPermission(permission);
+ Role builtIn = getRoleEnum();
+ return builtIn != null && builtIn.hasPermission(permission);
}
/**
- * Check if this user has at least the specified role level.
+ * Whether this user holds exactly the given built-in role.
+ *
+ * Formerly {@code isAtLeast}, a rank comparison. The roles no longer form a
+ * hierarchy — DATA_ENGINEER and DEVELOPER each have menus the other lacks — so an
+ * ordering comparison has no meaning and would silently answer nonsense.
*/
@Transient
public boolean hasRole(Role requiredRole) {
- return getRoleEnum().isAtLeast(requiredRole);
+ return requiredRole != null && getRoleEnum() == requiredRole;
}
/**
diff --git a/backend/src/main/java/com/dbaagent/repository/CustomRoleRepository.java b/backend/src/main/java/com/dbaagent/repository/CustomRoleRepository.java
new file mode 100644
index 0000000..06c3ecb
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/repository/CustomRoleRepository.java
@@ -0,0 +1,18 @@
+package com.dbaagent.repository;
+
+import com.dbaagent.model.CustomRole;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface CustomRoleRepository extends JpaRepository {
+
+ Optional findByCodeIgnoreCase(String code);
+
+ boolean existsByCodeIgnoreCase(String code);
+
+ List findAllByOrderByNameAsc();
+}
diff --git a/backend/src/main/java/com/dbaagent/repository/DashboardWorkspaceMemberRepository.java b/backend/src/main/java/com/dbaagent/repository/DashboardWorkspaceMemberRepository.java
new file mode 100644
index 0000000..2196203
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/repository/DashboardWorkspaceMemberRepository.java
@@ -0,0 +1,36 @@
+package com.dbaagent.repository;
+
+import com.dbaagent.model.DashboardWorkspaceMember;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+@Repository
+public interface DashboardWorkspaceMemberRepository extends JpaRepository {
+
+ List findByWorkspaceIdOrderByUsernameAsc(UUID workspaceId);
+
+ Optional findByWorkspaceIdAndUsernameIgnoreCase(UUID workspaceId, String username);
+
+ List findByUsernameIgnoreCase(String username);
+
+ /**
+ * Memberships this user holds among the given workspaces. Used to resolve visibility
+ * for a whole dashboard list in one query rather than one per dashboard.
+ */
+ List findByUsernameIgnoreCaseAndWorkspaceIdIn(String username, Collection workspaceIds);
+
+ // Derived deletes need their own transaction; a self-invoked @Transactional caller
+ // does not supply one (Spring proxies are bypassed by this::), which is the same
+ // trap McpTokenRepository.deleteByUserId documents.
+ @Transactional
+ void deleteByWorkspaceId(UUID workspaceId);
+
+ @Transactional
+ void deleteByWorkspaceIdAndUsernameIgnoreCase(UUID workspaceId, String username);
+}
diff --git a/backend/src/main/java/com/dbaagent/repository/DashboardWorkspaceRepository.java b/backend/src/main/java/com/dbaagent/repository/DashboardWorkspaceRepository.java
new file mode 100644
index 0000000..0169978
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/repository/DashboardWorkspaceRepository.java
@@ -0,0 +1,19 @@
+package com.dbaagent.repository;
+
+import com.dbaagent.model.DashboardWorkspace;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+@Repository
+public interface DashboardWorkspaceRepository extends JpaRepository {
+
+ List findByConnectionIdOrderByNameAsc(String connectionId);
+
+ Optional findByConnectionIdAndNameIgnoreCase(String connectionId, String name);
+
+ void deleteByConnectionId(String connectionId);
+}
diff --git a/backend/src/main/java/com/dbaagent/repository/RolePermissionOverrideRepository.java b/backend/src/main/java/com/dbaagent/repository/RolePermissionOverrideRepository.java
index 896e21c..68aece7 100644
--- a/backend/src/main/java/com/dbaagent/repository/RolePermissionOverrideRepository.java
+++ b/backend/src/main/java/com/dbaagent/repository/RolePermissionOverrideRepository.java
@@ -1,7 +1,6 @@
package com.dbaagent.repository;
import com.dbaagent.model.Permission;
-import com.dbaagent.model.Role;
import com.dbaagent.model.RolePermissionOverride;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
@@ -16,32 +15,32 @@ public interface RolePermissionOverrideRepository extends JpaRepository findByRole(Role role);
+ List findByRoleIgnoreCase(String role);
/**
* Find a specific override by role and permission.
*/
- Optional findByRoleAndPermissionCode(Role role, Permission permissionCode);
+ Optional findByRoleIgnoreCaseAndPermissionCode(String role, Permission permissionCode);
/**
* Find all overrides that grant permissions (used for adding permissions to roles).
*/
- List findByRoleAndGrantedTrue(Role role);
+ List findByRoleIgnoreCaseAndGrantedTrue(String role);
/**
* Find all overrides that revoke permissions (used for removing permissions from roles).
*/
- List findByRoleAndGrantedFalse(Role role);
+ List findByRoleIgnoreCaseAndGrantedFalse(String role);
/**
* Delete an override by role and permission.
*/
- void deleteByRoleAndPermissionCode(Role role, Permission permissionCode);
+ void deleteByRoleIgnoreCaseAndPermissionCode(String role, Permission permissionCode);
/**
* Check if an override exists for a role-permission pair.
*/
- boolean existsByRoleAndPermissionCode(Role role, Permission permissionCode);
+ boolean existsByRoleIgnoreCaseAndPermissionCode(String role, Permission permissionCode);
/**
* Count overrides (for admin dashboard).
diff --git a/backend/src/main/java/com/dbaagent/repository/SavedDashboardRepository.java b/backend/src/main/java/com/dbaagent/repository/SavedDashboardRepository.java
index 2fb0f58..653932f 100644
--- a/backend/src/main/java/com/dbaagent/repository/SavedDashboardRepository.java
+++ b/backend/src/main/java/com/dbaagent/repository/SavedDashboardRepository.java
@@ -55,6 +55,16 @@ List findByTag(@Param("connectionId") String connectionId,
*/
long countByConnectionIdAndFolder(String connectionId, String folder);
+ /**
+ * Dashboards inside one workspace, newest activity first.
+ */
+ List findByWorkspaceIdOrderByUpdatedAtDesc(UUID workspaceId);
+
+ /**
+ * Count dashboards grouped into a workspace (shown on the workspace chip).
+ */
+ long countByWorkspaceId(UUID workspaceId);
+
/**
* Get distinct folders for a connection
*/
diff --git a/backend/src/main/java/com/dbaagent/repository/UserRepository.java b/backend/src/main/java/com/dbaagent/repository/UserRepository.java
index b58cd6e..3636745 100644
--- a/backend/src/main/java/com/dbaagent/repository/UserRepository.java
+++ b/backend/src/main/java/com/dbaagent/repository/UserRepository.java
@@ -12,4 +12,7 @@ public interface UserRepository extends JpaRepository {
Optional findByEmailIgnoreCase(String email);
boolean existsByEmailIgnoreCase(String email);
List findAllByAccountStatus(String accountStatus);
+
+ /** How many users hold this role code. Guards deletion of a custom role in use. */
+ long countByRoleIgnoreCase(String role);
}
diff --git a/backend/src/main/java/com/dbaagent/security/CustomUserDetailsService.java b/backend/src/main/java/com/dbaagent/security/CustomUserDetailsService.java
index 9381c51..93cbea7 100644
--- a/backend/src/main/java/com/dbaagent/security/CustomUserDetailsService.java
+++ b/backend/src/main/java/com/dbaagent/security/CustomUserDetailsService.java
@@ -1,7 +1,6 @@
package com.dbaagent.security;
import com.dbaagent.model.Permission;
-import com.dbaagent.model.Role;
import com.dbaagent.model.User;
import com.dbaagent.repository.UserRepository;
import com.dbaagent.service.PermissionService;
@@ -50,19 +49,22 @@ public Optional loadUserEntityByUsername(String username) {
}
/**
- * Build Spring Security authorities from user's role and permissions.
- * Includes both ROLE_xxx authority and individual permission authorities.
+ * Build Spring Security authorities from the user's role code and its effective
+ * permissions.
+ *
+ * The role code is used verbatim, so a custom role yields {@code ROLE_ANALYST}
+ * exactly as a built-in one yields {@code ROLE_DBA}. Resolving through
+ * {@link PermissionService} rather than {@code Role.getPermissions()} is what makes
+ * custom roles and permission overrides take effect at login — reading the enum
+ * directly would give a custom-role user nothing at all.
*/
private Collection buildAuthorities(User user) {
List authorities = new ArrayList<>();
- Role role = user.getRoleEnum();
+ String roleCode = user.getRoleCode();
+ authorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
- // Add role authority (e.g., ROLE_ADMIN, ROLE_DEVELOPER)
- authorities.add(new SimpleGrantedAuthority("ROLE_" + role.name()));
-
- // Add individual permission authorities
- for (Permission permission : permissionService.getEffectivePermissions(role)) {
+ for (Permission permission : permissionService.getEffectivePermissions(roleCode)) {
authorities.add(new SimpleGrantedAuthority(permission.name()));
}
diff --git a/backend/src/main/java/com/dbaagent/security/JwtUtil.java b/backend/src/main/java/com/dbaagent/security/JwtUtil.java
index 1cdfc99..d21f698 100644
--- a/backend/src/main/java/com/dbaagent/security/JwtUtil.java
+++ b/backend/src/main/java/com/dbaagent/security/JwtUtil.java
@@ -145,9 +145,31 @@ public String generateAccessToken(
Set permissions,
Duration ttl,
Long impersonateUserId
+ ) {
+ return generateAccessToken(username, sessionId, role == null ? null : role.name(),
+ permissions, ttl, impersonateUserId);
+ }
+
+ /**
+ * Mint an access token for a role code , which may name a built-in
+ * {@link Role} or a custom one.
+ *
+ * The Role-typed overloads delegate here. They cannot represent a custom role, so
+ * every caller that resolves a user's role from the database must use this variant —
+ * otherwise a custom-role user's token would carry the wrong role claim.
+ */
+ public String generateAccessToken(
+ String username,
+ String sessionId,
+ String roleCode,
+ Set permissions,
+ Duration ttl,
+ Long impersonateUserId
) {
Map claims = new HashMap<>();
- claims.put("role", role.name());
+ claims.put("role", roleCode == null || roleCode.isBlank()
+ ? Role.DEVELOPER.name()
+ : roleCode.trim().toUpperCase());
List permissionNames = permissions.stream()
.map(Permission::name)
diff --git a/backend/src/main/java/com/dbaagent/service/AuthSessionService.java b/backend/src/main/java/com/dbaagent/service/AuthSessionService.java
index 3effbf0..acc4b7b 100644
--- a/backend/src/main/java/com/dbaagent/service/AuthSessionService.java
+++ b/backend/src/main/java/com/dbaagent/service/AuthSessionService.java
@@ -31,6 +31,7 @@ public class AuthSessionService {
private final UserSessionRepository userSessionRepository;
private final JwtUtil jwtUtil;
private final SecurityEventService securityEventService;
+ private final PermissionService permissionService;
@Value("${security.cookie.name:auth_token}")
private String accessCookieName;
@@ -56,7 +57,7 @@ public class AuthSessionService {
@Transactional
public SessionAuthentication createSession(
User user,
- Role role,
+ String roleCode,
Set permissions,
String clientIp,
String userAgent,
@@ -79,9 +80,10 @@ public SessionAuthentication createSession(
String accessToken = jwtUtil.generateAccessToken(
user.getUsername(),
session.getId(),
- role,
+ roleCode,
permissions,
- Duration.ofMinutes(accessMinutes)
+ Duration.ofMinutes(accessMinutes),
+ null
);
securityEventService.log(SecurityEventService.EventRequest.builder()
@@ -119,7 +121,7 @@ public Optional findValidSessionByRefreshToken(String refreshTokenH
public Optional refreshSession(
String rawRefreshToken,
User user,
- Role role,
+ String roleCode,
Set permissions,
String clientIp,
String userAgent
@@ -129,7 +131,7 @@ public Optional refreshSession(
.filter(session -> !session.isRevoked())
.filter(session -> !session.isRefreshExpired())
.filter(session -> session.getUserId().equals(user.getId()))
- .map(session -> rotateSession(session, user, role, permissions, clientIp, userAgent));
+ .map(session -> rotateSession(session, user, roleCode, permissions, clientIp, userAgent));
}
@Transactional
@@ -169,12 +171,14 @@ public void reissueAccessToken(
if (response == null || sessionId == null || sessionId.isBlank() || sessionOwner == null) {
return;
}
- Role role = sessionOwner.getRoleEnum();
+ // Resolve by role code, not Role enum: a custom-role user has no Role value, and
+ // Role.getPermissions() would also skip any admin-configured override.
+ String roleCode = sessionOwner.getRoleCode();
String accessToken = jwtUtil.generateAccessToken(
sessionOwner.getUsername(),
sessionId,
- role,
- role.getPermissions(),
+ roleCode,
+ permissionService.getEffectivePermissions(roleCode),
Duration.ofMinutes(accessMinutes),
impersonateUserId
);
@@ -209,7 +213,7 @@ public void clearSessionCookies(HttpServletResponse response) {
private SessionAuthentication rotateSession(
UserSession session,
User user,
- Role role,
+ String roleCode,
Set permissions,
String clientIp,
String userAgent
@@ -226,9 +230,10 @@ private SessionAuthentication rotateSession(
String accessToken = jwtUtil.generateAccessToken(
user.getUsername(),
session.getId(),
- role,
+ roleCode,
permissions,
- Duration.ofMinutes(accessMinutes)
+ Duration.ofMinutes(accessMinutes),
+ null
);
securityEventService.log(SecurityEventService.EventRequest.builder()
diff --git a/backend/src/main/java/com/dbaagent/service/CustomRoleService.java b/backend/src/main/java/com/dbaagent/service/CustomRoleService.java
new file mode 100644
index 0000000..49955bb
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/service/CustomRoleService.java
@@ -0,0 +1,162 @@
+package com.dbaagent.service;
+
+import com.dbaagent.model.CustomRole;
+import com.dbaagent.model.Permission;
+import com.dbaagent.model.Role;
+import com.dbaagent.repository.CustomRoleRepository;
+import com.dbaagent.repository.RolePermissionOverrideRepository;
+import com.dbaagent.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * CRUD for admin-defined roles.
+ *
+ * A custom role's {@code code} shares a namespace with the built-in {@link Role} names
+ * because both are written to {@code users.role}, so creation refuses a code that
+ * collides with a built-in one. Deletion refuses while any user still holds the role —
+ * silently reassigning people is not a decision this service should make on its own, and
+ * an orphaned code would resolve to no permissions at their next login.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class CustomRoleService {
+
+ private final CustomRoleRepository customRoleRepository;
+ private final RolePermissionOverrideRepository overrideRepository;
+ private final UserRepository userRepository;
+
+ public List listRoles() {
+ return customRoleRepository.findAllByOrderByNameAsc();
+ }
+
+ public CustomRole getByCode(String code) {
+ return customRoleRepository.findByCodeIgnoreCase(normalizeCode(code))
+ .orElseThrow(() -> new IllegalArgumentException("Custom role not found: " + code));
+ }
+
+ @Transactional
+ public CustomRole createRole(String name, String description, Collection permissionCodes, String actor) {
+ String cleanName = requireName(name);
+ String code = deriveCode(cleanName);
+
+ if (Role.isBuiltIn(code)) {
+ throw new IllegalArgumentException(
+ "\"" + cleanName + "\" collides with the built-in " + code + " role. Choose a different name.");
+ }
+ if (customRoleRepository.existsByCodeIgnoreCase(code)) {
+ throw new IllegalArgumentException("A role named \"" + cleanName + "\" already exists");
+ }
+
+ CustomRole role = new CustomRole();
+ role.setCode(code);
+ role.setName(cleanName);
+ role.setDescription(trimToNull(description));
+ role.setPermissions(resolvePermissions(permissionCodes));
+ role.setCreatedBy(actor);
+ role.setUpdatedBy(actor);
+
+ CustomRole saved = customRoleRepository.save(role);
+ log.info("Custom role created: code={}, permissions={}, by={}",
+ saved.getCode(), saved.getPermissions().size(), actor);
+ return saved;
+ }
+
+ @Transactional
+ public CustomRole updateRole(String code, String name, String description,
+ Collection permissionCodes, String actor) {
+ CustomRole role = getByCode(code);
+
+ if (name != null && !name.isBlank()) {
+ // The code is the identity written to users.role, so renaming changes only the
+ // label. Re-deriving the code would orphan every user holding the old one.
+ role.setName(requireName(name));
+ }
+ if (description != null) {
+ role.setDescription(trimToNull(description));
+ }
+ if (permissionCodes != null) {
+ role.setPermissions(resolvePermissions(permissionCodes));
+ }
+ role.setUpdatedBy(actor);
+
+ CustomRole saved = customRoleRepository.save(role);
+ log.info("Custom role updated: code={}, by={}", saved.getCode(), actor);
+ return saved;
+ }
+
+ @Transactional
+ public void deleteRole(String code, String actor) {
+ CustomRole role = getByCode(code);
+ long holders = userRepository.countByRoleIgnoreCase(role.getCode());
+ if (holders > 0) {
+ throw new IllegalArgumentException(
+ "Cannot delete \"" + role.getName() + "\": " + holders + " user(s) still have this role. "
+ + "Reassign them first.");
+ }
+ // Overrides are keyed by role code with no FK, so they would dangle silently.
+ overrideRepository.deleteAll(overrideRepository.findByRoleIgnoreCase(role.getCode()));
+ customRoleRepository.delete(role);
+ log.info("Custom role deleted: code={}, by={}", role.getCode(), actor);
+ }
+
+ /** How many users hold this role code (built-in or custom). */
+ public long countUsersWithRole(String roleCode) {
+ return userRepository.countByRoleIgnoreCase(normalizeCode(roleCode));
+ }
+
+ private static Set resolvePermissions(Collection codes) {
+ Set resolved = EnumSet.noneOf(Permission.class);
+ if (codes == null) {
+ return resolved;
+ }
+ for (String raw : codes) {
+ Permission permission = Permission.fromCode(raw);
+ if (permission == null) {
+ throw new IllegalArgumentException("Unknown permission: " + raw);
+ }
+ resolved.add(permission);
+ }
+ return resolved;
+ }
+
+ private static String requireName(String name) {
+ String clean = name == null ? "" : name.trim();
+ if (clean.isEmpty()) {
+ throw new IllegalArgumentException("Role name is required");
+ }
+ if (clean.length() > 128) {
+ throw new IllegalArgumentException("Role name must be 128 characters or fewer");
+ }
+ return clean;
+ }
+
+ /** "Data Analyst" -> "DATA_ANALYST". */
+ private static String deriveCode(String name) {
+ String code = name.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]+", "_")
+ .replaceAll("^_+|_+$", "");
+ if (code.isEmpty()) {
+ throw new IllegalArgumentException("Role name must contain at least one letter or number");
+ }
+ return code.length() > 64 ? code.substring(0, 64) : code;
+ }
+
+ private static String normalizeCode(String code) {
+ return code == null ? "" : code.trim().toUpperCase(Locale.ROOT);
+ }
+
+ private static String trimToNull(String value) {
+ if (value == null) return null;
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+}
diff --git a/backend/src/main/java/com/dbaagent/service/DashboardWorkspaceService.java b/backend/src/main/java/com/dbaagent/service/DashboardWorkspaceService.java
new file mode 100644
index 0000000..7c1260e
--- /dev/null
+++ b/backend/src/main/java/com/dbaagent/service/DashboardWorkspaceService.java
@@ -0,0 +1,370 @@
+package com.dbaagent.service;
+
+import com.dbaagent.model.DashboardWorkspace;
+import com.dbaagent.model.DashboardWorkspaceMember;
+import com.dbaagent.model.DashboardWorkspaceRole;
+import com.dbaagent.model.SavedDashboard;
+import com.dbaagent.repository.DashboardWorkspaceMemberRepository;
+import com.dbaagent.repository.DashboardWorkspaceRepository;
+import com.dbaagent.repository.SavedDashboardRepository;
+import com.dbaagent.service.security.AccessControlService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+import static org.springframework.http.HttpStatus.FORBIDDEN;
+import static org.springframework.http.HttpStatus.NOT_FOUND;
+
+/**
+ * Dashboard workspaces: named groups of dashboards with their own member lists.
+ *
+ * The access rule is an AND, deliberately. Connection access is checked first and
+ * unchanged — {@code AccessControlService.assertCanReadConnectionContent} — and workspace
+ * membership is an additional gate on top. Membership therefore can only ever
+ * narrow what a user sees, never widen it, so introducing a workspace cannot hand anyone
+ * access to a connection they were not already granted.
+ *
+ *
Admins bypass the membership half, matching how they already bypass connection
+ * grants ({@code isCurrentUserAdmin}). Under "View as", {@code ImpersonationContext} has
+ * already replaced the principal, so membership resolves as the target user and an admin
+ * viewing as someone else correctly sees only that user's workspaces.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class DashboardWorkspaceService {
+
+ private final DashboardWorkspaceRepository workspaceRepository;
+ private final DashboardWorkspaceMemberRepository memberRepository;
+ private final SavedDashboardRepository savedDashboardRepository;
+ private final AccessControlService accessControlService;
+
+ // ==================== Queries ====================
+
+ /** Workspaces on this connection that the caller can see. */
+ public List listVisibleWorkspaces(String connectionId) {
+ accessControlService.assertCanReadConnectionContent(connectionId);
+ List all = workspaceRepository.findByConnectionIdOrderByNameAsc(connectionId);
+ if (accessControlService.isCurrentUserAdmin()) {
+ return all;
+ }
+ Set memberOf = memberWorkspaceIds(currentUsername(), all);
+ return all.stream()
+ .filter(ws -> memberOf.contains(ws.getId()))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * The workspace ids the caller may read on this connection, plus whether the caller
+ * sees everything. Callers filtering a dashboard list use this to avoid a membership
+ * query per dashboard.
+ */
+ public WorkspaceVisibility resolveVisibility(String connectionId) {
+ if (accessControlService.isCurrentUserAdmin()) {
+ return new WorkspaceVisibility(true, Set.of());
+ }
+ List all = workspaceRepository.findByConnectionIdOrderByNameAsc(connectionId);
+ return new WorkspaceVisibility(false, memberWorkspaceIds(currentUsername(), all));
+ }
+
+ public DashboardWorkspace getWorkspace(UUID workspaceId) {
+ DashboardWorkspace workspace = workspaceRepository.findById(workspaceId)
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
+ assertCanView(workspace);
+ return workspace;
+ }
+
+ public List listMembers(UUID workspaceId) {
+ DashboardWorkspace workspace = workspaceRepository.findById(workspaceId)
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
+ assertCanView(workspace);
+ return memberRepository.findByWorkspaceIdOrderByUsernameAsc(workspaceId);
+ }
+
+ /** Dashboards in a workspace the caller can see. */
+ public List listDashboards(UUID workspaceId) {
+ DashboardWorkspace workspace = getWorkspace(workspaceId);
+ return savedDashboardRepository.findByWorkspaceIdOrderByUpdatedAtDesc(workspace.getId());
+ }
+
+ // ==================== Mutations ====================
+
+ @Transactional
+ public DashboardWorkspace createWorkspace(String connectionId, String name, String description, String color) {
+ accessControlService.assertCanReadConnectionContent(connectionId);
+ String cleanName = requireName(name);
+
+ workspaceRepository.findByConnectionIdAndNameIgnoreCase(connectionId, cleanName).ifPresent(existing -> {
+ throw new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,
+ "A workspace named \"" + cleanName + "\" already exists on this connection");
+ });
+
+ String creator = accessControlService.requireCurrentUsername();
+
+ DashboardWorkspace workspace = new DashboardWorkspace();
+ workspace.setConnectionId(connectionId);
+ workspace.setName(cleanName);
+ workspace.setDescription(trimToNull(description));
+ workspace.setColor(trimToNull(color));
+ workspace.setCreatedBy(creator);
+ DashboardWorkspace saved = workspaceRepository.save(workspace);
+
+ // The creator is a MANAGER member outright rather than relying on a
+ // createdBy check at read time, so ownership survives if the workspace is
+ // later handed to someone else.
+ DashboardWorkspaceMember owner = new DashboardWorkspaceMember();
+ owner.setWorkspaceId(saved.getId());
+ owner.setUsername(creator);
+ owner.setWorkspaceRole(DashboardWorkspaceRole.MANAGER);
+ owner.setAddedBy(creator);
+ memberRepository.save(owner);
+
+ log.info("Dashboard workspace created: id={}, connection={}, by={}", saved.getId(), connectionId, creator);
+ return saved;
+ }
+
+ @Transactional
+ public DashboardWorkspace updateWorkspace(UUID workspaceId, String name, String description, String color) {
+ DashboardWorkspace workspace = workspaceRepository.findById(workspaceId)
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
+ assertCanManage(workspace);
+
+ if (name != null && !name.isBlank()) {
+ String cleanName = requireName(name);
+ workspaceRepository.findByConnectionIdAndNameIgnoreCase(workspace.getConnectionId(), cleanName)
+ .filter(other -> !other.getId().equals(workspaceId))
+ .ifPresent(other -> {
+ throw new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,
+ "A workspace named \"" + cleanName + "\" already exists on this connection");
+ });
+ workspace.setName(cleanName);
+ }
+ // Null means "field omitted"; blank is the explicit clear signal, matching the
+ // convention updateDashboard/setSharePassword already use.
+ if (description != null) {
+ workspace.setDescription(trimToNull(description));
+ }
+ if (color != null) {
+ workspace.setColor(trimToNull(color));
+ }
+ return workspaceRepository.save(workspace);
+ }
+
+ @Transactional
+ public void deleteWorkspace(UUID workspaceId) {
+ DashboardWorkspace workspace = workspaceRepository.findById(workspaceId)
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
+ assertCanManage(workspace);
+
+ // Dashboards outlive their workspace: detach them rather than cascading a delete.
+ // Deleting a grouping must never destroy the things grouped.
+ List dashboards = savedDashboardRepository.findByWorkspaceIdOrderByUpdatedAtDesc(workspaceId);
+ for (SavedDashboard dashboard : dashboards) {
+ dashboard.setWorkspaceId(null);
+ }
+ if (!dashboards.isEmpty()) {
+ savedDashboardRepository.saveAll(dashboards);
+ }
+
+ memberRepository.deleteByWorkspaceId(workspaceId);
+ workspaceRepository.delete(workspace);
+ log.info("Dashboard workspace deleted: id={}, detached {} dashboard(s)", workspaceId, dashboards.size());
+ }
+
+ @Transactional
+ public DashboardWorkspaceMember addMember(UUID workspaceId, String username, String workspaceRole) {
+ DashboardWorkspace workspace = workspaceRepository.findById(workspaceId)
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
+ assertCanManage(workspace);
+
+ String cleanUsername = username == null ? "" : username.trim();
+ if (cleanUsername.isEmpty()) {
+ throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "Username is required");
+ }
+
+ DashboardWorkspaceMember member = memberRepository
+ .findByWorkspaceIdAndUsernameIgnoreCase(workspaceId, cleanUsername)
+ .orElseGet(() -> {
+ DashboardWorkspaceMember fresh = new DashboardWorkspaceMember();
+ fresh.setWorkspaceId(workspaceId);
+ fresh.setUsername(cleanUsername);
+ return fresh;
+ });
+ member.setWorkspaceRole(DashboardWorkspaceRole.fromString(workspaceRole));
+ member.setAddedBy(accessControlService.requireCurrentUsername());
+ return memberRepository.save(member);
+ }
+
+ @Transactional
+ public void removeMember(UUID workspaceId, String username) {
+ DashboardWorkspace workspace = workspaceRepository.findById(workspaceId)
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
+ assertCanManage(workspace);
+
+ List members = memberRepository.findByWorkspaceIdOrderByUsernameAsc(workspaceId);
+ boolean removingLastManager = members.stream()
+ .filter(m -> m.getWorkspaceRole() == DashboardWorkspaceRole.MANAGER)
+ .allMatch(m -> m.getUsername().equalsIgnoreCase(username));
+ boolean anyManager = members.stream().anyMatch(m -> m.getWorkspaceRole() == DashboardWorkspaceRole.MANAGER);
+ if (anyManager && removingLastManager) {
+ // A workspace with no manager can never be changed again by anyone but an
+ // admin — refuse rather than create that dead end.
+ throw new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,
+ "Cannot remove the last manager. Promote another member first.");
+ }
+ memberRepository.deleteByWorkspaceIdAndUsernameIgnoreCase(workspaceId, username);
+ }
+
+ /**
+ * Move a dashboard into a workspace, or out of one when {@code workspaceId} is null.
+ *
+ * Both ends are checked: the caller must be able to manage the dashboard's
+ * connection content, and must be able to manage the destination workspace. Without
+ * the second check, anyone could push a dashboard into a workspace they cannot see.
+ */
+ @Transactional
+ public SavedDashboard moveDashboard(UUID dashboardId, UUID workspaceId) {
+ SavedDashboard dashboard = savedDashboardRepository.findById(dashboardId)
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Dashboard not found"));
+ accessControlService.assertCanManageConnectionContent(dashboard.getConnectionId());
+ assertCanReadDashboard(dashboard);
+
+ if (workspaceId == null) {
+ dashboard.setWorkspaceId(null);
+ return savedDashboardRepository.save(dashboard);
+ }
+
+ DashboardWorkspace target = workspaceRepository.findById(workspaceId)
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
+ if (!target.getConnectionId().equals(dashboard.getConnectionId())) {
+ throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,
+ "Workspace belongs to a different connection");
+ }
+ assertCanManage(target);
+
+ dashboard.setWorkspaceId(workspaceId);
+ return savedDashboardRepository.save(dashboard);
+ }
+
+ // ==================== Access checks ====================
+
+ /** True when the caller may read a dashboard given its workspace, if any. */
+ public boolean canReadDashboard(SavedDashboard dashboard) {
+ if (dashboard == null) {
+ return false;
+ }
+ UUID workspaceId = dashboard.getWorkspaceId();
+ if (workspaceId == null) {
+ return true;
+ }
+ if (accessControlService.isCurrentUserAdmin()) {
+ return true;
+ }
+ return memberRepository
+ .findByWorkspaceIdAndUsernameIgnoreCase(workspaceId, currentUsername())
+ .isPresent();
+ }
+
+ /**
+ * Assert workspace membership for a dashboard the caller already passed the
+ * connection check on. Reports 404, not 403: a user outside the workspace should not
+ * learn that a dashboard with that id exists.
+ */
+ public void assertCanReadDashboard(SavedDashboard dashboard) {
+ if (!canReadDashboard(dashboard)) {
+ throw new ResponseStatusException(NOT_FOUND, "Dashboard not found");
+ }
+ }
+
+ /** Filter a dashboard list to what the caller may see, in one membership query. */
+ public List filterReadable(List dashboards) {
+ if (dashboards == null || dashboards.isEmpty()) {
+ return List.of();
+ }
+ if (accessControlService.isCurrentUserAdmin()) {
+ return dashboards;
+ }
+ Set workspaceIds = dashboards.stream()
+ .map(SavedDashboard::getWorkspaceId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ if (workspaceIds.isEmpty()) {
+ return dashboards;
+ }
+ Set memberOf = memberRepository
+ .findByUsernameIgnoreCaseAndWorkspaceIdIn(currentUsername(), workspaceIds)
+ .stream()
+ .map(DashboardWorkspaceMember::getWorkspaceId)
+ .collect(Collectors.toSet());
+ return dashboards.stream()
+ .filter(d -> d.getWorkspaceId() == null || memberOf.contains(d.getWorkspaceId()))
+ .collect(Collectors.toList());
+ }
+
+ private void assertCanView(DashboardWorkspace workspace) {
+ accessControlService.assertCanReadConnectionContent(workspace.getConnectionId());
+ if (accessControlService.isCurrentUserAdmin()) {
+ return;
+ }
+ memberRepository.findByWorkspaceIdAndUsernameIgnoreCase(workspace.getId(), currentUsername())
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
+ }
+
+ private void assertCanManage(DashboardWorkspace workspace) {
+ accessControlService.assertCanReadConnectionContent(workspace.getConnectionId());
+ if (accessControlService.isCurrentUserAdmin()) {
+ return;
+ }
+ DashboardWorkspaceMember member = memberRepository
+ .findByWorkspaceIdAndUsernameIgnoreCase(workspace.getId(), currentUsername())
+ .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Workspace not found"));
+ if (!member.getWorkspaceRole().canManage()) {
+ throw new ResponseStatusException(FORBIDDEN, "Only a workspace manager can change this workspace");
+ }
+ }
+
+ private Set memberWorkspaceIds(String username, List candidates) {
+ if (candidates.isEmpty()) {
+ return Set.of();
+ }
+ Set ids = candidates.stream().map(DashboardWorkspace::getId).collect(Collectors.toSet());
+ return memberRepository.findByUsernameIgnoreCaseAndWorkspaceIdIn(username, ids).stream()
+ .map(DashboardWorkspaceMember::getWorkspaceId)
+ .collect(Collectors.toSet());
+ }
+
+ private String currentUsername() {
+ return accessControlService.requireCurrentUsername();
+ }
+
+ private static String requireName(String name) {
+ String clean = name == null ? "" : name.trim();
+ if (clean.isEmpty()) {
+ throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,
+ "Workspace name is required");
+ }
+ if (clean.length() > 128) {
+ throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,
+ "Workspace name must be 128 characters or fewer");
+ }
+ return clean;
+ }
+
+ private static String trimToNull(String value) {
+ if (value == null) return null;
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
+ /** Whether the caller sees every workspace, and if not, which ones they belong to. */
+ public record WorkspaceVisibility(boolean seesAll, Set memberWorkspaceIds) {
+ public boolean canSee(UUID workspaceId) {
+ return workspaceId == null || seesAll || memberWorkspaceIds.contains(workspaceId);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/dbaagent/service/PasswordlessAuthService.java b/backend/src/main/java/com/dbaagent/service/PasswordlessAuthService.java
index 9a836b3..c3c1317 100644
--- a/backend/src/main/java/com/dbaagent/service/PasswordlessAuthService.java
+++ b/backend/src/main/java/com/dbaagent/service/PasswordlessAuthService.java
@@ -292,12 +292,12 @@ public Optional refresh(String rawRefr
return authSessionService.findValidSessionByRefreshToken(tokenHash)
.flatMap(session -> userRepository.findById(session.getUserId())
.map(user -> {
- Role role = user.getRoleEnum();
+ String roleCode = user.getRoleCode();
return authSessionService.refreshSession(
rawRefreshToken,
user,
- role,
- permissionService.getEffectivePermissions(role),
+ roleCode,
+ permissionService.getEffectivePermissions(roleCode),
clientIp,
userAgent
);
@@ -531,11 +531,12 @@ private AuthFlowResult issueSession(
String requestId,
boolean mfaVerified
) {
+ String roleCode = user.getRoleCode();
Role role = user.getRoleEnum();
- Set permissions = permissionService.getEffectivePermissions(role);
+ Set permissions = permissionService.getEffectivePermissions(roleCode);
AuthSessionService.SessionAuthentication session = authSessionService.createSession(
user,
- role,
+ roleCode,
permissions,
clientIp,
userAgent,
@@ -547,7 +548,7 @@ private AuthFlowResult issueSession(
user.setLastLoginAt(LocalDateTime.now());
user.setLastLoginIp(clientIp);
userRepository.save(user);
- return AuthFlowResult.authenticated(user, role, permissions, session);
+ return AuthFlowResult.authenticated(user, role, roleCode, permissions, session);
}
private void markFailedOtp(AuthLoginChallenge challenge, User user, String clientIp, String userAgent, String requestId) {
@@ -746,7 +747,13 @@ public record AuthFlowResult(
boolean mfaRequired,
boolean mfaSetupRequired,
User user,
+ /**
+ * The built-in role, or null when the user holds a custom role. Prefer
+ * {@link #roleCode()} — a null here does NOT mean "not authenticated".
+ */
Role role,
+ /** The user's role code, built-in or custom. Non-null on a successful auth. */
+ String roleCode,
Set permissions,
AuthSessionService.SessionAuthentication sessionAuthentication
) {
@@ -777,11 +784,23 @@ static AuthFlowResult authenticated(
Role role,
Set permissions,
AuthSessionService.SessionAuthentication sessionAuthentication
+ ) {
+ return authenticated(user, role, user != null ? user.getRoleCode() : null,
+ permissions, sessionAuthentication);
+ }
+
+ static AuthFlowResult authenticated(
+ User user,
+ Role role,
+ String roleCode,
+ Set permissions,
+ AuthSessionService.SessionAuthentication sessionAuthentication
) {
return AuthFlowResult.builder()
.success(true)
.user(user)
.role(role)
+ .roleCode(roleCode)
.permissions(permissions)
.sessionAuthentication(sessionAuthentication)
.build();
diff --git a/backend/src/main/java/com/dbaagent/service/PermissionService.java b/backend/src/main/java/com/dbaagent/service/PermissionService.java
index e8b9019..38bcd19 100644
--- a/backend/src/main/java/com/dbaagent/service/PermissionService.java
+++ b/backend/src/main/java/com/dbaagent/service/PermissionService.java
@@ -1,8 +1,10 @@
package com.dbaagent.service;
+import com.dbaagent.model.CustomRole;
import com.dbaagent.model.Permission;
import com.dbaagent.model.Role;
import com.dbaagent.model.RolePermissionOverride;
+import com.dbaagent.repository.CustomRoleRepository;
import com.dbaagent.repository.RolePermissionOverrideRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -13,11 +15,18 @@
import java.util.stream.Collectors;
/**
- * Service for computing effective permissions based on:
- * 1. Default permissions from Permission.defaultMinRole (hierarchy-based)
- * 2. Custom overrides from RolePermissionOverride table
+ * Resolves the effective permissions of a role code.
*
- * Effective permission = (default permissions) + (granted overrides) - (revoked overrides)
+ * A role code is either a built-in {@link Role} name or a {@link CustomRole} code.
+ * The effective set is:
+ *
+ *
+ * built-in : Role default permissions + granted overrides - revoked overrides
+ * custom : the role's own explicit permission set + granted - revoked
+ *
+ *
+ * ADMIN is a fixed point: it holds every permission and overrides are ignored for it,
+ * so no configuration change can lock the last administrator out of user management.
*/
@Service
public class PermissionService {
@@ -25,167 +34,233 @@ public class PermissionService {
private static final Logger log = LoggerFactory.getLogger(PermissionService.class);
private final RolePermissionOverrideRepository overrideRepository;
+ private final CustomRoleRepository customRoleRepository;
- public PermissionService(RolePermissionOverrideRepository overrideRepository) {
+ public PermissionService(RolePermissionOverrideRepository overrideRepository,
+ CustomRoleRepository customRoleRepository) {
this.overrideRepository = overrideRepository;
+ this.customRoleRepository = customRoleRepository;
}
- /**
- * Get the effective permissions for a role.
- * Combines default hierarchy-based permissions with any overrides.
- */
- public Set getEffectivePermissions(Role role) {
- // Start with default permissions based on hierarchy
- Set effective = new HashSet<>(role.getDefaultPermissions());
+ /** Effective permissions for a role code (built-in name or custom role code). */
+ public Set getEffectivePermissions(String roleCode) {
+ String normalized = normalize(roleCode);
+ if (normalized == null) {
+ return EnumSet.noneOf(Permission.class);
+ }
+
+ Role builtIn = Role.fromString(normalized);
+ if (builtIn == Role.ADMIN) {
+ return EnumSet.allOf(Permission.class);
+ }
- // Apply overrides
- List overrides = overrideRepository.findByRole(role);
- for (RolePermissionOverride override : overrides) {
+ Set effective = EnumSet.noneOf(Permission.class);
+ if (builtIn != null) {
+ effective.addAll(builtIn.getDefaultPermissions());
+ } else {
+ Optional custom = customRoleRepository.findByCodeIgnoreCase(normalized);
+ if (custom.isEmpty()) {
+ // An unknown role code grants nothing. Falling back to a real role here
+ // would turn a typo, or a custom role someone deleted, into silent access.
+ log.warn("Unknown role code '{}' resolved to no permissions", roleCode);
+ return effective;
+ }
+ effective.addAll(custom.get().getPermissions());
+ }
+
+ for (RolePermissionOverride override : overrideRepository.findByRoleIgnoreCase(normalized)) {
if (override.isGranted()) {
- // Add permission (even if not in defaults)
effective.add(override.getPermissionCode());
} else {
- // Remove permission (even if in defaults)
effective.remove(override.getPermissionCode());
}
}
-
return effective;
}
- /**
- * Get effective permissions as a Set of permission code strings.
- * This is what gets returned to the frontend.
- */
- public Set getEffectivePermissionCodes(Role role) {
- return getEffectivePermissions(role).stream()
+ /** Convenience overload for a built-in role. */
+ public Set getEffectivePermissions(Role role) {
+ return role == null ? EnumSet.noneOf(Permission.class) : getEffectivePermissions(role.name());
+ }
+
+ public Set getEffectivePermissionCodes(String roleCode) {
+ return getEffectivePermissions(roleCode).stream()
.map(Enum::name)
- .collect(Collectors.toSet());
+ .collect(Collectors.toCollection(TreeSet::new));
+ }
+
+ public Set getEffectivePermissionCodes(Role role) {
+ return getEffectivePermissionCodes(role == null ? null : role.name());
+ }
+
+ public boolean hasPermission(String roleCode, Permission permission) {
+ return permission != null && getEffectivePermissions(roleCode).contains(permission);
}
- /**
- * Check if a role has a specific permission (considering overrides).
- */
public boolean hasPermission(Role role, Permission permission) {
- // Check for override first
- Optional override =
- overrideRepository.findByRoleAndPermissionCode(role, permission);
+ return hasPermission(role == null ? null : role.name(), permission);
+ }
- if (override.isPresent()) {
- return override.get().isGranted();
+ /** True when the role code names a built-in role or an existing custom role. */
+ public boolean roleExists(String roleCode) {
+ String normalized = normalize(roleCode);
+ if (normalized == null) {
+ return false;
}
+ return Role.isBuiltIn(normalized) || customRoleRepository.existsByCodeIgnoreCase(normalized);
+ }
- // Fall back to default
- return permission.isGrantedByDefaultTo(role);
+ /** Display name for a role code, for UI and audit messages. */
+ public String describeRole(String roleCode) {
+ String normalized = normalize(roleCode);
+ if (normalized == null) {
+ return "Unknown";
+ }
+ Role builtIn = Role.fromString(normalized);
+ if (builtIn != null) {
+ return builtIn.getDisplayName();
+ }
+ return customRoleRepository.findByCodeIgnoreCase(normalized)
+ .map(CustomRole::getName)
+ .orElse(normalized);
}
/**
- * Get the full permission registry with role assignments.
- * Returns all permissions with their default min role and any overrides.
+ * The full permission registry: every permission, which roles hold it, and any
+ * overrides recorded against it.
*/
public List getPermissionRegistry() {
- List allOverrides = overrideRepository.findAll();
- Map> overridesByPermission = allOverrides.stream()
- .collect(Collectors.groupingBy(RolePermissionOverride::getPermissionCode));
+ List allRoleCodes = getAllRoleCodes();
+ Map> overridesByPermission =
+ overrideRepository.findAll().stream()
+ .collect(Collectors.groupingBy(RolePermissionOverride::getPermissionCode));
+
+ Map> effectiveByRole = allRoleCodes.stream()
+ .collect(Collectors.toMap(code -> code, this::getEffectivePermissions));
return Arrays.stream(Permission.values())
.map(p -> {
PermissionInfo info = new PermissionInfo();
info.code = p.name();
info.description = p.getDescription();
- info.defaultMinRole = p.getDefaultMinRole().name();
-
- // Compute effective roles for this permission
- Set effectiveRoles = new HashSet<>();
- for (Role role : Role.values()) {
- if (hasPermission(role, p)) {
- effectiveRoles.add(role.name());
- }
- }
- info.effectiveRoles = effectiveRoles;
-
- // Include overrides for this permission
- info.overrides = overridesByPermission.getOrDefault(p, Collections.emptyList())
- .stream()
+ info.defaultRoles = p.getDefaultRoles().stream().map(Enum::name)
+ .collect(Collectors.toCollection(TreeSet::new));
+ info.effectiveRoles = effectiveByRole.entrySet().stream()
+ .filter(entry -> entry.getValue().contains(p))
+ .map(Map.Entry::getKey)
+ .collect(Collectors.toCollection(TreeSet::new));
+ info.overrides = overridesByPermission.getOrDefault(p, List.of()).stream()
.map(o -> {
OverrideInfo oi = new OverrideInfo();
- oi.role = o.getRole().name();
+ oi.role = o.getRole();
oi.granted = o.isGranted();
oi.reason = o.getReason();
return oi;
})
.collect(Collectors.toList());
-
return info;
})
.collect(Collectors.toList());
}
- /**
- * Get all overrides (for admin view).
- */
public List getAllOverrides() {
return overrideRepository.findAll();
}
- /**
- * Create or update an override.
- */
@Transactional
- public RolePermissionOverride setOverride(Role role, Permission permission, boolean granted, String reason, String updatedBy) {
- Optional existing =
- overrideRepository.findByRoleAndPermissionCode(role, permission);
-
- RolePermissionOverride override;
- if (existing.isPresent()) {
- override = existing.get();
- override.setGranted(granted);
- override.setReason(reason);
- override.setUpdatedBy(updatedBy);
- } else {
- override = new RolePermissionOverride(role, permission, granted, reason, updatedBy);
+ public RolePermissionOverride setOverride(String roleCode, Permission permission, boolean granted,
+ String reason, String updatedBy) {
+ String normalized = normalize(roleCode);
+ if (normalized == null || !roleExists(normalized)) {
+ throw new IllegalArgumentException("Unknown role: " + roleCode);
+ }
+ if (Role.ADMIN.name().equals(normalized)) {
+ // Admin holds every permission unconditionally; storing an override here
+ // would record a rule getEffectivePermissions deliberately ignores.
+ throw new IllegalArgumentException("The Admin role always holds every permission and cannot be overridden");
}
+ RolePermissionOverride override = overrideRepository
+ .findByRoleIgnoreCaseAndPermissionCode(normalized, permission)
+ .orElseGet(() -> new RolePermissionOverride(normalized, permission, granted, reason, updatedBy));
+ override.setGranted(granted);
+ override.setReason(reason);
+ override.setUpdatedBy(updatedBy);
+
RolePermissionOverride saved = overrideRepository.save(override);
log.info("Permission override set: role={}, permission={}, granted={}, by={}",
- role, permission, granted, updatedBy);
+ normalized, permission, granted, updatedBy);
return saved;
}
- /**
- * Remove an override (revert to default behavior).
- */
@Transactional
- public void removeOverride(Role role, Permission permission) {
- overrideRepository.deleteByRoleAndPermissionCode(role, permission);
- log.info("Permission override removed: role={}, permission={}", role, permission);
+ public void removeOverride(String roleCode, Permission permission) {
+ String normalized = normalize(roleCode);
+ if (normalized == null) {
+ return;
+ }
+ overrideRepository.deleteByRoleIgnoreCaseAndPermissionCode(normalized, permission);
+ log.info("Permission override removed: role={}, permission={}", normalized, permission);
}
- /**
- * Get role info with effective permissions.
- */
+ /** Every role code in the system: built-ins first, then custom roles by name. */
+ public List getAllRoleCodes() {
+ List codes = Arrays.stream(Role.values()).map(Enum::name)
+ .collect(Collectors.toCollection(ArrayList::new));
+ customRoleRepository.findAllByOrderByNameAsc().stream()
+ .map(CustomRole::getCode)
+ .filter(code -> !Role.isBuiltIn(code))
+ .forEach(codes::add);
+ return codes;
+ }
+
+ /** Role registry for the admin UI: built-in and custom roles with their permissions. */
public List getRoleRegistry() {
- return Arrays.stream(Role.values())
- .map(role -> {
- RoleInfo info = new RoleInfo();
- info.code = role.name();
- info.description = role.getDescription();
- info.level = role.ordinal();
- info.defaultPermissions = role.getDefaultPermissions().stream()
- .map(Enum::name)
- .collect(Collectors.toSet());
- info.effectivePermissions = getEffectivePermissionCodes(role);
- info.overrideCount = overrideRepository.findByRole(role).size();
- return info;
- })
- .collect(Collectors.toList());
+ List roles = new ArrayList<>();
+
+ for (Role role : Role.values()) {
+ RoleInfo info = new RoleInfo();
+ info.code = role.name();
+ info.name = role.getDisplayName();
+ info.description = role.getDescription();
+ info.builtIn = true;
+ info.editable = role != Role.ADMIN;
+ info.defaultPermissions = role.getDefaultPermissions().stream().map(Enum::name)
+ .collect(Collectors.toCollection(TreeSet::new));
+ info.effectivePermissions = getEffectivePermissionCodes(role.name());
+ info.overrideCount = role == Role.ADMIN ? 0 : overrideRepository.findByRoleIgnoreCase(role.name()).size();
+ roles.add(info);
+ }
+
+ for (CustomRole custom : customRoleRepository.findAllByOrderByNameAsc()) {
+ RoleInfo info = new RoleInfo();
+ info.code = custom.getCode();
+ info.name = custom.getName();
+ info.description = custom.getDescription();
+ info.builtIn = false;
+ info.editable = true;
+ info.defaultPermissions = custom.getPermissionCodeSet().stream()
+ .collect(Collectors.toCollection(TreeSet::new));
+ info.effectivePermissions = getEffectivePermissionCodes(custom.getCode());
+ info.overrideCount = overrideRepository.findByRoleIgnoreCase(custom.getCode()).size();
+ roles.add(info);
+ }
+
+ return roles;
+ }
+
+ private static String normalize(String roleCode) {
+ if (roleCode == null || roleCode.isBlank()) {
+ return null;
+ }
+ return roleCode.trim().toUpperCase();
}
// DTO classes for API responses
public static class PermissionInfo {
public String code;
public String description;
- public String defaultMinRole;
+ public Set defaultRoles;
public Set effectiveRoles;
public List overrides;
}
@@ -198,10 +273,14 @@ public static class OverrideInfo {
public static class RoleInfo {
public String code;
+ public String name;
public String description;
- public int level;
+ public boolean builtIn;
+ public boolean editable;
public Set defaultPermissions;
public Set effectivePermissions;
public int overrideCount;
+ /** Filled in by the controller; how many users currently hold this role. */
+ public long userCount;
}
}
diff --git a/backend/src/main/java/com/dbaagent/service/SlackConversationStateService.java b/backend/src/main/java/com/dbaagent/service/SlackConversationStateService.java
index a2152cf..9b92726 100644
--- a/backend/src/main/java/com/dbaagent/service/SlackConversationStateService.java
+++ b/backend/src/main/java/com/dbaagent/service/SlackConversationStateService.java
@@ -261,7 +261,9 @@ public StatusSnapshot buildStatusSnapshot() {
serviceUsername,
serviceUser.isPresent(),
serviceUser.map(User::getUsername).orElse(null),
- serviceUser.map(user -> user.getRoleEnum().name()).orElse(null),
+ // getRoleCode, not getRoleEnum().name(): the enum is null for a custom role
+ // and this status snapshot would NPE on it.
+ serviceUser.map(User::getRoleCode).orElse(null),
serviceUser.map(User::isAdmin).orElse(false),
allowedConnections.size(),
Math.toIntExact(channelBindingRepository.count()),
diff --git a/backend/src/main/java/com/dbaagent/service/UserManagementService.java b/backend/src/main/java/com/dbaagent/service/UserManagementService.java
index a6394ca..5613486 100644
--- a/backend/src/main/java/com/dbaagent/service/UserManagementService.java
+++ b/backend/src/main/java/com/dbaagent/service/UserManagementService.java
@@ -47,6 +47,9 @@ public class UserManagementService {
@Autowired
private UserSessionRepository userSessionRepository;
+ @Autowired
+ private PermissionService permissionService;
+
/**
* Create a new user (admin only).
*/
@@ -61,10 +64,7 @@ public Map createUser(String username, String password, String e
if (password.length() < 8) {
throw new IllegalArgumentException("Password must be at least 8 characters");
}
- Role role = Role.DEVELOPER;
- if (roleName != null && !roleName.isBlank()) {
- role = Role.fromString(roleName);
- }
+ String roleCode = resolveRoleCode(roleName);
String normalizedEmail = email.trim().toLowerCase();
if (userRepository.existsByEmailIgnoreCase(normalizedEmail)) {
throw new IllegalArgumentException("A user with this email already exists");
@@ -79,7 +79,7 @@ public Map createUser(String username, String password, String e
user.setUsername(resolvedUsername);
user.setEmail(normalizedEmail);
user.setPassword(passwordEncoder.encode(password));
- user.setRoleEnum(role);
+ user.setRole(roleCode);
user.setEmailVerifiedAt(LocalDateTime.now());
user.setAccountStatusEnum(UserAccountStatus.ACTIVE);
user.setInvitedAt(LocalDateTime.now());
@@ -91,10 +91,10 @@ public Map createUser(String username, String password, String e
.userId(user.getId())
.email(user.getEmail())
.targetResource("user:" + user.getId())
- .metadata(Map.of("role", role.name(), "action", "user_created"))
+ .metadata(Map.of("role", roleCode, "action", "user_created"))
.build());
- log.info("Admin created user: {} with role {}", normalizedEmail, role.name());
+ log.info("Admin created user: {} with role {}", normalizedEmail, roleCode);
return toUserDTO(user);
}
@@ -136,8 +136,8 @@ public Map updateUserRole(Long userId, String roleName) {
throw new IllegalArgumentException("Cannot change the admin user's role");
}
- Role role = Role.fromString(roleName);
- user.setRole(role.name());
+ String roleCode = resolveRoleCode(roleName);
+ user.setRole(roleCode);
userRepository.save(user);
securityEventService.log(SecurityEventService.EventRequest.builder()
.eventType(SecurityEventType.ROLE_CHANGED)
@@ -145,10 +145,10 @@ public Map updateUserRole(Long userId, String roleName) {
.userId(user.getId())
.email(user.getEmail())
.targetResource("user:" + user.getId())
- .metadata(Map.of("role", role.name()))
+ .metadata(Map.of("role", roleCode))
.build());
- log.info("Updated user {} role to {}", user.getUsername(), role.name());
+ log.info("Updated user {} role to {}", user.getUsername(), roleCode);
return toUserDTO(user);
}
@@ -207,7 +207,9 @@ public Map resendInvite(Long userId) {
if (user.getAccountStatusEnum() == UserAccountStatus.ACTIVE) {
throw new IllegalArgumentException("Direct account creation is enabled. Active users do not need invite emails.");
}
- Role role = user.getRoleEnum();
+ // UserInviteService is typed to the built-in enum; a custom-role invite carries
+ // the closest built-in and the real role is already stored on the user row.
+ Role role = Role.fromStringOrDefault(user.getRole());
var invite = userInviteService.createInvite(user.getEmail(), user.getUsername(), role, "admin", InviteType.STANDARD, null, null);
return toUserDTO(invite.user());
}
@@ -231,17 +233,24 @@ public Map disableUser(Long userId) {
* Get all roles with their permissions.
*/
public List> getRolesWithPermissions() {
- return Arrays.stream(Role.values())
- .map(role -> {
+ // Every assignable role, built-in and custom, with the permissions actually in
+ // effect (overrides applied) rather than the enum's raw defaults — this list is
+ // what the admin UI offers when changing someone's role.
+ return permissionService.getRoleRegistry().stream()
+ .map(info -> {
Map roleDTO = new HashMap<>();
- roleDTO.put("name", role.name());
- roleDTO.put("description", role.getDescription());
-
- List> permissions = role.getPermissions().stream()
- .map(p -> {
+ roleDTO.put("name", info.code);
+ roleDTO.put("code", info.code);
+ roleDTO.put("displayName", info.name);
+ roleDTO.put("description", info.description);
+ roleDTO.put("builtIn", info.builtIn);
+
+ List> permissions = info.effectivePermissions.stream()
+ .map(code -> {
+ Permission p = Permission.fromCode(code);
Map permDTO = new HashMap<>();
- permDTO.put("name", p.name());
- permDTO.put("description", p.getDescription());
+ permDTO.put("name", code);
+ permDTO.put("description", p != null ? p.getDescription() : code);
return permDTO;
})
.collect(Collectors.toList());
@@ -252,6 +261,28 @@ public List> getRolesWithPermissions() {
.collect(Collectors.toList());
}
+ /**
+ * Normalise a requested role name to a role code that actually exists.
+ *
+ * Blank means DEVELOPER. Anything else must name a built-in role or an existing
+ * custom role: an unknown code is rejected rather than silently downgraded, because a
+ * user whose role does not resolve gets no permissions at all at their next login.
+ */
+ private String resolveRoleCode(String roleName) {
+ if (roleName == null || roleName.isBlank()) {
+ return Role.DEVELOPER.name();
+ }
+ String code = roleName.trim().toUpperCase();
+ Role builtIn = Role.fromString(code);
+ if (builtIn != null) {
+ return builtIn.name();
+ }
+ if (!permissionService.roleExists(code)) {
+ throw new IllegalArgumentException("Unknown role: " + roleName);
+ }
+ return code;
+ }
+
/**
* Get all available permissions.
*/
@@ -283,11 +314,11 @@ private Map toUserDTO(User user) {
dto.put("mfaEnrolledAt", user.getMfaEnrolledAt());
dto.put("mfaEnrolled", user.getMfaEnrolledAt() != null);
- // Include permissions for convenience
- Set permissions = user.getPermissions();
- dto.put("permissions", permissions.stream()
- .map(Permission::name)
- .collect(Collectors.toList()));
+ // Effective permissions, resolved by role code so a custom role reports what it
+ // actually grants; user.getPermissions() only knows the built-in enum.
+ dto.put("roleName", permissionService.describeRole(user.getRoleCode()));
+ dto.put("permissions", new java.util.ArrayList<>(
+ permissionService.getEffectivePermissionCodes(user.getRoleCode())));
return dto;
}
diff --git a/backend/src/main/java/com/dbaagent/service/security/AccessControlService.java b/backend/src/main/java/com/dbaagent/service/security/AccessControlService.java
index 4a32265..c23a8d8 100644
--- a/backend/src/main/java/com/dbaagent/service/security/AccessControlService.java
+++ b/backend/src/main/java/com/dbaagent/service/security/AccessControlService.java
@@ -4,6 +4,7 @@
import com.dbaagent.model.Chat;
import com.dbaagent.model.ChatFeedback;
import com.dbaagent.model.EffectiveConnectionAccess;
+import com.dbaagent.model.Permission;
import com.dbaagent.repository.AnalysisHistoryRepository;
import com.dbaagent.repository.ChatFeedbackRepository;
import com.dbaagent.repository.ChatRepository;
@@ -181,6 +182,52 @@ public String requireCurrentUsername() {
return username;
}
+ /**
+ * Assert the caller may create a database connection.
+ *
+ * Creating a connection is not scoped to an existing connection, so none of the
+ * {@code assertCanManage*Connection*} checks apply — there is no id to resolve
+ * access against yet. Without this, {@code POST /connections} had no authorization
+ * at all: a Developer or Data Engineer could create, then edit and delete, their own
+ * connection (verified live against a running install — the row persisted with
+ * {@code owner_username = analyst}). Hiding the Connections button only hid the
+ * button.
+ *
+ *
Permission-based rather than {@code isCurrentUserAdmin()} so DBA — which holds
+ * MANAGE_CONNECTIONS by design — keeps working, and so an admin-defined custom role
+ * granting that permission behaves consistently.
+ */
+ public void assertCanManageConnections() {
+ if (!authEnabled) {
+ return;
+ }
+ if (!hasPermission(Permission.MANAGE_CONNECTIONS)) {
+ throw new ResponseStatusException(FORBIDDEN, "You do not have permission to manage connections");
+ }
+ }
+
+ /**
+ * Whether the current principal carries a permission authority.
+ *
+ *
{@code CustomUserDetailsService} stamps every effective permission onto the
+ * authentication as a plain authority alongside {@code ROLE_}, so this reads
+ * the already-resolved set (overrides and custom roles included) without a lookup.
+ */
+ public boolean hasPermission(Permission permission) {
+ if (permission == null) {
+ return false;
+ }
+ if (isCurrentUserAdmin()) {
+ return true;
+ }
+ Authentication authentication = currentAuthentication();
+ if (authentication == null || !authentication.isAuthenticated()) {
+ return false;
+ }
+ return authentication.getAuthorities().stream()
+ .anyMatch(authority -> permission.name().equals(authority.getAuthority()));
+ }
+
public boolean isCurrentUserAdmin() {
if (ImpersonationContext.isActive()) {
return ImpersonationContext.current()
diff --git a/backend/src/main/java/com/dbaagent/service/security/ConnectionAccessService.java b/backend/src/main/java/com/dbaagent/service/security/ConnectionAccessService.java
index c352804..adb4a3a 100644
--- a/backend/src/main/java/com/dbaagent/service/security/ConnectionAccessService.java
+++ b/backend/src/main/java/com/dbaagent/service/security/ConnectionAccessService.java
@@ -59,12 +59,14 @@ public ResolvedConnectionAccess resolveAccess(DatabaseConnection connection, Str
}
return grantRepository.findByConnectionIdAndUsernameIgnoreCase(connection.getId(), username)
+ // A grant is a grant: assignment implies full content access. Legacy
+ // CHAT_EDITOR rows resolve here too rather than to a lesser tier, so an
+ // existing user gains Dashboards/Brain write access instead of silently
+ // keeping a level the UI no longer explains.
.map(grant -> buildResolved(
connection,
ConnectionOwnershipType.ASSIGNED,
- grant.getAccessLevel() == ConnectionAccessLevel.FULL_CONTENT
- ? EffectiveConnectionAccess.FULL_CONTENT
- : EffectiveConnectionAccess.CHAT_EDITOR
+ EffectiveConnectionAccess.FULL_CONTENT
))
.orElseGet(() -> buildResolved(connection, null, EffectiveConnectionAccess.NONE));
}
diff --git a/backend/src/main/resources/db/migration/V117__create_dashboard_workspaces_and_custom_roles.sql b/backend/src/main/resources/db/migration/V117__create_dashboard_workspaces_and_custom_roles.sql
new file mode 100644
index 0000000..dc67cab
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V117__create_dashboard_workspaces_and_custom_roles.sql
@@ -0,0 +1,91 @@
+-- Dashboard workspaces + custom roles.
+--
+-- NOTE: this repository has no Flyway runtime (see CLAUDE.md). Schema is managed by
+-- spring.jpa.hibernate.ddl-auto=update, which creates these tables and the new
+-- saved_dashboards.workspace_id column from the JPA entities on startup. This file is
+-- the hand-maintained changelog: apply it with psql only if you need the schema without
+-- letting Hibernate touch the database.
+
+-- ── Dashboard workspaces ─────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS dashboard_workspaces (
+ id UUID PRIMARY KEY,
+ connection_id VARCHAR(255) NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ description VARCHAR(500),
+ color VARCHAR(32),
+ created_by VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_dashboard_workspaces_connection
+ ON dashboard_workspaces (connection_id);
+
+-- One workspace name per connection, so the folder chips stay unambiguous.
+CREATE UNIQUE INDEX IF NOT EXISTS ux_dashboard_workspaces_conn_name
+ ON dashboard_workspaces (connection_id, LOWER(name));
+
+-- ── Membership ───────────────────────────────────────────────────────────────
+-- Keyed by username to match connection_access_grant, so an impersonated ("View as")
+-- session resolves membership as the target user with no extra lookup.
+CREATE TABLE IF NOT EXISTS dashboard_workspace_members (
+ id UUID PRIMARY KEY,
+ workspace_id UUID NOT NULL REFERENCES dashboard_workspaces (id) ON DELETE CASCADE,
+ username VARCHAR(255) NOT NULL,
+ workspace_role VARCHAR(32) NOT NULL DEFAULT 'VIEWER',
+ added_by VARCHAR(255),
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_dashboard_ws_members_workspace
+ ON dashboard_workspace_members (workspace_id);
+CREATE INDEX IF NOT EXISTS idx_dashboard_ws_members_username
+ ON dashboard_workspace_members (username);
+CREATE UNIQUE INDEX IF NOT EXISTS ux_dashboard_ws_member
+ ON dashboard_workspace_members (workspace_id, LOWER(username));
+
+-- ── Dashboards join a workspace ──────────────────────────────────────────────
+-- Nullable on purpose: NULL means "not grouped", governed purely by the connection ACL
+-- exactly as every dashboard was before workspaces existed. Deliberately NOT a cascading
+-- FK — deleting a workspace detaches its dashboards rather than destroying them
+-- (DashboardWorkspaceService.deleteWorkspace).
+ALTER TABLE saved_dashboards
+ ADD COLUMN IF NOT EXISTS workspace_id UUID;
+
+CREATE INDEX IF NOT EXISTS idx_saved_dashboards_workspace_id
+ ON saved_dashboards (workspace_id);
+
+-- ── Custom roles ─────────────────────────────────────────────────────────────
+-- code shares a namespace with the built-in Role names because both are written to
+-- users.role; CustomRoleService refuses a code that collides with a built-in one.
+CREATE TABLE IF NOT EXISTS custom_roles (
+ id BIGSERIAL PRIMARY KEY,
+ code VARCHAR(64) NOT NULL,
+ name VARCHAR(128) NOT NULL,
+ description VARCHAR(500),
+ permission_codes TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ created_by VARCHAR(255),
+ updated_by VARCHAR(255)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS ux_custom_roles_code ON custom_roles (UPPER(code));
+
+-- ── role_permission_overrides widens from the old Role/Permission enums ──────
+-- The column is already a string; this only relaxes any length limit so a custom role
+-- code fits. Existing rows ('ADMIN', 'DEVELOPER') keep working untouched.
+ALTER TABLE role_permission_overrides
+ ALTER COLUMN role TYPE VARCHAR(64);
+
+-- Hibernate generated CHECK constraints from the ORIGINAL two-role / 20-permission
+-- enums and ddl-auto=update never drops a stale constraint. Left in place they reject
+-- every new value: inserting ('DBA','VIEW_AGENT') fails with
+-- violates check constraint "role_permission_overrides_permission_code_check"
+-- (observed on a live install, not inferred). Dropping them is what lets an override be
+-- recorded for DBA/DATA_ENGINEER or a custom role. The application enum is the
+-- authority for these values; SchemaConstraintRefreshInitializer applies this at boot.
+ALTER TABLE role_permission_overrides
+ DROP CONSTRAINT IF EXISTS role_permission_overrides_role_check;
+ALTER TABLE role_permission_overrides
+ DROP CONSTRAINT IF EXISTS role_permission_overrides_permission_code_check;
diff --git a/backend/src/test/java/com/dbaagent/service/AuthFlowResultCustomRoleTest.java b/backend/src/test/java/com/dbaagent/service/AuthFlowResultCustomRoleTest.java
new file mode 100644
index 0000000..480887e
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/service/AuthFlowResultCustomRoleTest.java
@@ -0,0 +1,69 @@
+package com.dbaagent.service;
+
+import com.dbaagent.model.Permission;
+import com.dbaagent.model.Role;
+import com.dbaagent.model.User;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Regression: a successful login by a user holding a custom role must be
+ * recognisable as successful.
+ *
+ * {@code AuthFlowResult.role()} is null for a custom role, because {@link Role} can
+ * only represent built-ins. {@code AuthController.authResponse} gated its success branch
+ * on that field, so a custom-role login fell through to the "challenge required" branch
+ * and threw NullPointerException inside {@code Map.of} on a null challengeId — HTTP 500
+ * on every custom-role sign-in. Verified against a running install before the fix.
+ *
+ *
The controller now gates on {@code roleCode()}, which this pins.
+ */
+class AuthFlowResultCustomRoleTest {
+
+ private static User userWithRole(String role) {
+ User user = new User();
+ user.setUsername("analyst");
+ user.setEmail("analyst@localhost");
+ user.setRole(role);
+ return user;
+ }
+
+ @Test
+ @DisplayName("A custom-role login carries a roleCode even though role is null")
+ void customRoleStillYieldsARoleCode() {
+ User user = userWithRole("ANALYST");
+
+ PasswordlessAuthService.AuthFlowResult result = PasswordlessAuthService.AuthFlowResult
+ .authenticated(user, Role.fromString("ANALYST"), Set.of(Permission.VIEW_DASHBOARDS), null);
+
+ assertThat(result.success()).isTrue();
+ assertThat(result.role()).isNull(); // the trap
+ assertThat(result.roleCode()).isEqualTo("ANALYST");
+ // The controller's success branch keys on this; null here was the 500.
+ assertThat(result.roleCode()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("A built-in role login still carries both role and roleCode")
+ void builtInRoleUnchanged() {
+ User user = userWithRole("DBA");
+
+ PasswordlessAuthService.AuthFlowResult result = PasswordlessAuthService.AuthFlowResult
+ .authenticated(user, Role.fromString("DBA"), Set.of(Permission.VIEW_BRAIN), null);
+
+ assertThat(result.role()).isEqualTo(Role.DBA);
+ assertThat(result.roleCode()).isEqualTo("DBA");
+ }
+
+ @Test
+ @DisplayName("User.getRoleCode never returns null, so the success gate cannot be tripped by a blank role")
+ void roleCodeNeverNull() {
+ assertThat(userWithRole(null).getRoleCode()).isEqualTo("DEVELOPER");
+ assertThat(userWithRole("").getRoleCode()).isEqualTo("DEVELOPER");
+ assertThat(userWithRole(" analyst ").getRoleCode()).isEqualTo("ANALYST");
+ }
+}
diff --git a/backend/src/test/java/com/dbaagent/service/DashboardWorkspaceAccessTest.java b/backend/src/test/java/com/dbaagent/service/DashboardWorkspaceAccessTest.java
new file mode 100644
index 0000000..f453e7b
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/service/DashboardWorkspaceAccessTest.java
@@ -0,0 +1,212 @@
+package com.dbaagent.service;
+
+import com.dbaagent.model.DashboardWorkspace;
+import com.dbaagent.model.DashboardWorkspaceMember;
+import com.dbaagent.model.DashboardWorkspaceRole;
+import com.dbaagent.model.SavedDashboard;
+import com.dbaagent.repository.DashboardWorkspaceMemberRepository;
+import com.dbaagent.repository.DashboardWorkspaceRepository;
+import com.dbaagent.repository.SavedDashboardRepository;
+import com.dbaagent.service.security.AccessControlService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+/**
+ * The workspace access rule: connection access AND workspace membership.
+ *
+ *
The load-bearing property is that membership can only ever narrow access.
+ * A dashboard with no workspace must stay visible to everyone who could already see it,
+ * and a dashboard in a workspace must be invisible to a non-member even though that user
+ * still has full read access to the connection.
+ */
+class DashboardWorkspaceAccessTest {
+
+ private static final String CONNECTION = "conn-1";
+ private static final UUID WORKSPACE = UUID.randomUUID();
+
+ private DashboardWorkspaceRepository workspaceRepository;
+ private DashboardWorkspaceMemberRepository memberRepository;
+ private SavedDashboardRepository savedDashboardRepository;
+ private AccessControlService accessControlService;
+ private DashboardWorkspaceService service;
+
+ @BeforeEach
+ void setUp() {
+ workspaceRepository = mock(DashboardWorkspaceRepository.class);
+ memberRepository = mock(DashboardWorkspaceMemberRepository.class);
+ savedDashboardRepository = mock(SavedDashboardRepository.class);
+ accessControlService = mock(AccessControlService.class);
+
+ when(accessControlService.requireCurrentUsername()).thenReturn("analyst");
+ when(accessControlService.isCurrentUserAdmin()).thenReturn(false);
+
+ service = new DashboardWorkspaceService(
+ workspaceRepository, memberRepository, savedDashboardRepository, accessControlService);
+ }
+
+ private static SavedDashboard dashboard(UUID workspaceId) {
+ SavedDashboard d = new SavedDashboard();
+ d.setId(UUID.randomUUID());
+ d.setConnectionId(CONNECTION);
+ d.setWorkspaceId(workspaceId);
+ return d;
+ }
+
+ @Test
+ @DisplayName("A dashboard with no workspace stays readable — workspaces never widen or narrow the ungrouped case")
+ void ungroupedDashboardIsReadable() {
+ assertThat(service.canReadDashboard(dashboard(null))).isTrue();
+ }
+
+ @Test
+ @DisplayName("A non-member cannot read a dashboard inside a workspace, despite connection access")
+ void nonMemberCannotReadGroupedDashboard() {
+ when(memberRepository.findByWorkspaceIdAndUsernameIgnoreCase(WORKSPACE, "analyst"))
+ .thenReturn(Optional.empty());
+
+ SavedDashboard grouped = dashboard(WORKSPACE);
+
+ assertThat(service.canReadDashboard(grouped)).isFalse();
+ // 404, not 403: a non-member must not learn the dashboard exists.
+ assertThatThrownBy(() -> service.assertCanReadDashboard(grouped))
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("404");
+ }
+
+ @Test
+ @DisplayName("A member can read a dashboard inside their workspace")
+ void memberCanReadGroupedDashboard() {
+ DashboardWorkspaceMember member = new DashboardWorkspaceMember();
+ member.setWorkspaceId(WORKSPACE);
+ member.setUsername("analyst");
+ member.setWorkspaceRole(DashboardWorkspaceRole.VIEWER);
+ when(memberRepository.findByWorkspaceIdAndUsernameIgnoreCase(WORKSPACE, "analyst"))
+ .thenReturn(Optional.of(member));
+
+ assertThat(service.canReadDashboard(dashboard(WORKSPACE))).isTrue();
+ }
+
+ @Test
+ @DisplayName("An admin reads every workspace without a membership row")
+ void adminBypassesMembership() {
+ when(accessControlService.isCurrentUserAdmin()).thenReturn(true);
+
+ assertThat(service.canReadDashboard(dashboard(WORKSPACE))).isTrue();
+ verify(memberRepository, never()).findByWorkspaceIdAndUsernameIgnoreCase(any(), anyString());
+ }
+
+ @Test
+ @DisplayName("filterReadable keeps ungrouped dashboards and drops non-member workspaces")
+ void filterReadableSplitsCorrectly() {
+ UUID otherWorkspace = UUID.randomUUID();
+ SavedDashboard ungrouped = dashboard(null);
+ SavedDashboard mine = dashboard(WORKSPACE);
+ SavedDashboard theirs = dashboard(otherWorkspace);
+
+ DashboardWorkspaceMember member = new DashboardWorkspaceMember();
+ member.setWorkspaceId(WORKSPACE);
+ member.setUsername("analyst");
+ when(memberRepository.findByUsernameIgnoreCaseAndWorkspaceIdIn(eq("analyst"), any()))
+ .thenReturn(List.of(member));
+
+ List visible = service.filterReadable(List.of(ungrouped, mine, theirs));
+
+ assertThat(visible).containsExactly(ungrouped, mine);
+ assertThat(visible).doesNotContain(theirs);
+ }
+
+ @Test
+ @DisplayName("A viewer cannot rename the workspace; only a manager can")
+ void viewerCannotManageWorkspace() {
+ DashboardWorkspace workspace = new DashboardWorkspace();
+ workspace.setId(WORKSPACE);
+ workspace.setConnectionId(CONNECTION);
+ workspace.setName("Finance");
+ when(workspaceRepository.findById(WORKSPACE)).thenReturn(Optional.of(workspace));
+
+ DashboardWorkspaceMember viewer = new DashboardWorkspaceMember();
+ viewer.setWorkspaceId(WORKSPACE);
+ viewer.setUsername("analyst");
+ viewer.setWorkspaceRole(DashboardWorkspaceRole.VIEWER);
+ when(memberRepository.findByWorkspaceIdAndUsernameIgnoreCase(WORKSPACE, "analyst"))
+ .thenReturn(Optional.of(viewer));
+
+ assertThatThrownBy(() -> service.updateWorkspace(WORKSPACE, "Renamed", null, null))
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("403");
+ }
+
+ @Test
+ @DisplayName("Deleting a workspace detaches its dashboards instead of deleting them")
+ void deleteDetachesDashboards() {
+ when(accessControlService.isCurrentUserAdmin()).thenReturn(true);
+ DashboardWorkspace workspace = new DashboardWorkspace();
+ workspace.setId(WORKSPACE);
+ workspace.setConnectionId(CONNECTION);
+ when(workspaceRepository.findById(WORKSPACE)).thenReturn(Optional.of(workspace));
+
+ SavedDashboard grouped = dashboard(WORKSPACE);
+ when(savedDashboardRepository.findByWorkspaceIdOrderByUpdatedAtDesc(WORKSPACE))
+ .thenReturn(List.of(grouped));
+
+ service.deleteWorkspace(WORKSPACE);
+
+ // The dashboard survives, merely ungrouped. Deleting a grouping must never
+ // destroy the things grouped.
+ assertThat(grouped.getWorkspaceId()).isNull();
+ verify(savedDashboardRepository).saveAll(any());
+ verify(savedDashboardRepository, never()).delete(any());
+ verify(workspaceRepository).delete(workspace);
+ }
+
+ @Test
+ @DisplayName("Removing the last manager is refused so the workspace cannot be orphaned")
+ void cannotRemoveLastManager() {
+ when(accessControlService.isCurrentUserAdmin()).thenReturn(true);
+ DashboardWorkspace workspace = new DashboardWorkspace();
+ workspace.setId(WORKSPACE);
+ workspace.setConnectionId(CONNECTION);
+ when(workspaceRepository.findById(WORKSPACE)).thenReturn(Optional.of(workspace));
+
+ DashboardWorkspaceMember onlyManager = new DashboardWorkspaceMember();
+ onlyManager.setWorkspaceId(WORKSPACE);
+ onlyManager.setUsername("owner");
+ onlyManager.setWorkspaceRole(DashboardWorkspaceRole.MANAGER);
+ when(memberRepository.findByWorkspaceIdOrderByUsernameAsc(WORKSPACE))
+ .thenReturn(List.of(onlyManager));
+
+ assertThatThrownBy(() -> service.removeMember(WORKSPACE, "owner"))
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("409");
+ }
+
+ @Test
+ @DisplayName("A dashboard cannot be moved into a workspace on a different connection")
+ void cannotMoveAcrossConnections() {
+ when(accessControlService.isCurrentUserAdmin()).thenReturn(true);
+ SavedDashboard d = dashboard(null);
+ when(savedDashboardRepository.findById(d.getId())).thenReturn(Optional.of(d));
+
+ DashboardWorkspace foreign = new DashboardWorkspace();
+ foreign.setId(WORKSPACE);
+ foreign.setConnectionId("some-other-connection");
+ when(workspaceRepository.findById(WORKSPACE)).thenReturn(Optional.of(foreign));
+
+ assertThatThrownBy(() -> service.moveDashboard(d.getId(), WORKSPACE))
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("400");
+ }
+}
diff --git a/backend/src/test/java/com/dbaagent/service/PermissionResolutionTest.java b/backend/src/test/java/com/dbaagent/service/PermissionResolutionTest.java
new file mode 100644
index 0000000..a87f280
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/service/PermissionResolutionTest.java
@@ -0,0 +1,165 @@
+package com.dbaagent.service;
+
+import com.dbaagent.model.CustomRole;
+import com.dbaagent.model.Permission;
+import com.dbaagent.model.Role;
+import com.dbaagent.model.RolePermissionOverride;
+import com.dbaagent.repository.CustomRoleRepository;
+import com.dbaagent.repository.RolePermissionOverrideRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Permission resolution across built-in and custom roles.
+ *
+ * These assert the *product* rules the roles were specified with — which menus each
+ * role can open — rather than restating the enum. A regression here is a user seeing a
+ * section they should not, so each role's section set is pinned explicitly.
+ */
+class PermissionResolutionTest {
+
+ private RolePermissionOverrideRepository overrideRepository;
+ private CustomRoleRepository customRoleRepository;
+ private PermissionService permissionService;
+
+ @BeforeEach
+ void setUp() {
+ overrideRepository = mock(RolePermissionOverrideRepository.class);
+ customRoleRepository = mock(CustomRoleRepository.class);
+ when(overrideRepository.findByRoleIgnoreCase(anyString())).thenReturn(List.of());
+ when(customRoleRepository.findByCodeIgnoreCase(anyString())).thenReturn(Optional.empty());
+ when(customRoleRepository.findAllByOrderByNameAsc()).thenReturn(List.of());
+ permissionService = new PermissionService(overrideRepository, customRoleRepository);
+ }
+
+ @Test
+ @DisplayName("DBA sees every section and can manage connections, but not users")
+ void dbaHasEverythingExceptUserManagement() {
+ Set permissions = permissionService.getEffectivePermissions(Role.DBA.name());
+
+ assertThat(permissions).contains(
+ Permission.VIEW_AGENT, Permission.VIEW_DASHBOARDS, Permission.VIEW_DIGEST,
+ Permission.VIEW_BRAIN, Permission.VIEW_PERFORMANCE, Permission.VIEW_EDITOR,
+ Permission.MANAGE_CONNECTIONS, Permission.MANAGE_SETTINGS);
+
+ assertThat(permissions).doesNotContain(
+ Permission.MANAGE_USERS, Permission.MANAGE_INVITE_CODES, Permission.MANAGE_PERMISSIONS);
+ }
+
+ @Test
+ @DisplayName("Data Engineer gets Agent, Dashboards and Editor only")
+ void dataEngineerSections() {
+ Set permissions = permissionService.getEffectivePermissions(Role.DATA_ENGINEER.name());
+
+ assertThat(permissions).contains(
+ Permission.VIEW_AGENT, Permission.VIEW_DASHBOARDS, Permission.VIEW_EDITOR);
+ assertThat(permissions).doesNotContain(
+ Permission.VIEW_DIGEST, Permission.VIEW_PERFORMANCE, Permission.VIEW_BRAIN,
+ Permission.MANAGE_CONNECTIONS, Permission.MANAGE_USERS);
+ }
+
+ @Test
+ @DisplayName("Developer gets Agent, Digest, Dashboards, Performance and Editor, but no connection settings")
+ void developerSections() {
+ Set permissions = permissionService.getEffectivePermissions(Role.DEVELOPER.name());
+
+ assertThat(permissions).contains(
+ Permission.VIEW_AGENT, Permission.VIEW_DIGEST, Permission.VIEW_DASHBOARDS,
+ Permission.VIEW_PERFORMANCE, Permission.VIEW_EDITOR);
+ assertThat(permissions).doesNotContain(
+ Permission.MANAGE_CONNECTIONS, Permission.MANAGE_SETTINGS, Permission.MANAGE_USERS);
+ }
+
+ @Test
+ @DisplayName("Roles do not nest: each of Data Engineer and Developer has something the other lacks")
+ void rolesAreNotAHierarchy() {
+ Set dataEngineer = permissionService.getEffectivePermissions(Role.DATA_ENGINEER.name());
+ Set developer = permissionService.getEffectivePermissions(Role.DEVELOPER.name());
+
+ // Developer has Digest and Performance that Data Engineer lacks...
+ assertThat(developer).contains(Permission.VIEW_DIGEST, Permission.VIEW_PERFORMANCE);
+ assertThat(dataEngineer).doesNotContain(Permission.VIEW_DIGEST, Permission.VIEW_PERFORMANCE);
+ // ...so neither is a superset of the other, which is why ordinal comparison had
+ // to go. If this ever passes trivially, the role definitions drifted.
+ assertThat(developer).isNotEqualTo(dataEngineer);
+ }
+
+ @Test
+ @DisplayName("Admin holds every permission and ignores overrides")
+ void adminIsAFixedPoint() {
+ when(overrideRepository.findByRoleIgnoreCase("ADMIN")).thenReturn(List.of(
+ new RolePermissionOverride("ADMIN", Permission.MANAGE_USERS, false, "tester")));
+
+ Set permissions = permissionService.getEffectivePermissions("ADMIN");
+
+ assertThat(permissions).containsExactlyInAnyOrder(Permission.values());
+ assertThat(permissions).contains(Permission.MANAGE_USERS);
+ }
+
+ @Test
+ @DisplayName("A custom role grants exactly its ticked permissions")
+ void customRoleGrantsItsOwnSet() {
+ CustomRole analyst = new CustomRole();
+ analyst.setCode("ANALYST");
+ analyst.setName("Analyst");
+ analyst.setPermissions(Set.of(Permission.VIEW_DASHBOARDS, Permission.USE_CHAT));
+ when(customRoleRepository.findByCodeIgnoreCase("ANALYST")).thenReturn(Optional.of(analyst));
+
+ Set permissions = permissionService.getEffectivePermissions("ANALYST");
+
+ assertThat(permissions).containsExactlyInAnyOrder(Permission.VIEW_DASHBOARDS, Permission.USE_CHAT);
+ assertThat(permissions).doesNotContain(Permission.VIEW_EDITOR, Permission.MANAGE_USERS);
+ }
+
+ @Test
+ @DisplayName("An unknown role code grants nothing rather than falling back to Developer")
+ void unknownRoleGrantsNothing() {
+ // The old Role.fromString collapsed anything unrecognised to DEVELOPER. Keeping
+ // that behaviour here would turn a deleted custom role into silent Developer
+ // access for everyone who held it.
+ assertThat(permissionService.getEffectivePermissions("NO_SUCH_ROLE")).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Overrides add and remove permissions for a non-admin role")
+ void overridesApply() {
+ when(overrideRepository.findByRoleIgnoreCase(Role.DATA_ENGINEER.name())).thenReturn(List.of(
+ new RolePermissionOverride(Role.DATA_ENGINEER.name(), Permission.VIEW_DIGEST, true, "tester"),
+ new RolePermissionOverride(Role.DATA_ENGINEER.name(), Permission.VIEW_EDITOR, false, "tester")));
+
+ Set permissions = permissionService.getEffectivePermissions(Role.DATA_ENGINEER.name());
+
+ assertThat(permissions).contains(Permission.VIEW_DIGEST);
+ assertThat(permissions).doesNotContain(Permission.VIEW_EDITOR);
+ }
+
+ @Test
+ @DisplayName("An override cannot be recorded against Admin")
+ void adminOverrideRejected() {
+ when(customRoleRepository.existsByCodeIgnoreCase(anyString())).thenReturn(false);
+ org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
+ () -> permissionService.setOverride("ADMIN", Permission.MANAGE_USERS, false, null, "tester"));
+ }
+
+ @Test
+ @DisplayName("Legacy role names still resolve to Developer")
+ void legacyRoleNamesMigrate() {
+ // Installs upgrading from the two-role model carry EDITOR/VIEWER/USER values.
+ assertThat(Role.fromString("EDITOR")).isEqualTo(Role.DEVELOPER);
+ assertThat(Role.fromString("VIEWER")).isEqualTo(Role.DEVELOPER);
+ assertThat(Role.fromString("developer")).isEqualTo(Role.DEVELOPER);
+ assertThat(Role.fromString("DBA")).isEqualTo(Role.DBA);
+ assertThat(Role.fromString("ANALYST")).isNull();
+ }
+}
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 1590e3a..2c07f30 100644
--- a/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java
+++ b/backend/src/test/java/com/dbaagent/service/security/AccessControlServiceTest.java
@@ -270,6 +270,16 @@ void feedbackAccessUsesUnderlyingConnectionOwnership() {
assertDoesNotThrow(() -> accessControlService.assertCanAccessFeedback("fb-1"));
}
+ /**
+ * Guards the CHAT_EDITOR branch of {@link EffectiveConnectionAccess} itself.
+ *
+ * Note this state is no longer reachable from a real grant: connection access
+ * levels collapsed to a single tier, so {@code ConnectionAccessService.resolveAccess}
+ * returns FULL_CONTENT for every grant (see
+ * {@code ConnectionAccessLevelCollapseTest}). This test stubs the resolver directly,
+ * so it exercises the enum's semantics, not the resolution path — keep it for the
+ * former, do not read it as evidence about the latter.
+ */
@Test
void assignedChatEditorUserCanUseChatEditor() {
SecurityContextHolder.getContext().setAuthentication(
diff --git a/backend/src/test/java/com/dbaagent/service/security/ConnectionAccessLevelCollapseTest.java b/backend/src/test/java/com/dbaagent/service/security/ConnectionAccessLevelCollapseTest.java
new file mode 100644
index 0000000..8447adc
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/service/security/ConnectionAccessLevelCollapseTest.java
@@ -0,0 +1,131 @@
+package com.dbaagent.service.security;
+
+import com.dbaagent.model.*;
+import com.dbaagent.repository.ConnectionAccessGrantRepository;
+import com.dbaagent.repository.CredentialRepository;
+import com.dbaagent.repository.UserRepository;
+import com.dbaagent.service.ConnectionChatAccessPolicyService;
+import com.dbaagent.service.SecurityEventService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * The connection access tiers collapsed into a single level: any grant means full
+ * content access.
+ *
+ *
These exercise the real {@link ConnectionAccessService#resolveAccess}
+ * path. {@code AccessControlServiceTest} cannot cover this: it stubs
+ * {@code resolveAccess} to hand back a fixed {@link EffectiveConnectionAccess}, so it
+ * keeps passing no matter what the resolution logic does — it still contains a
+ * CHAT_EDITOR case that passes vacuously. A mock cannot catch a change to the thing it
+ * replaces.
+ */
+class ConnectionAccessLevelCollapseTest {
+
+ private static final String CONN = "conn-1";
+
+ private CredentialRepository credentialRepository;
+ private ConnectionAccessGrantRepository grantRepository;
+ private UserRepository userRepository;
+ private ConnectionAccessService service;
+
+ @BeforeEach
+ void setUp() {
+ credentialRepository = mock(CredentialRepository.class);
+ grantRepository = mock(ConnectionAccessGrantRepository.class);
+ userRepository = mock(UserRepository.class);
+
+ service = new ConnectionAccessService(
+ credentialRepository,
+ grantRepository,
+ userRepository,
+ mock(SecurityEventService.class),
+ mock(ConnectionChatAccessPolicyService.class)
+ );
+
+ // The connection must be admin-owned to be assignable at all.
+ User owner = new User();
+ owner.setUsername("admin");
+ owner.setRole("ADMIN");
+ when(userRepository.findByUsername("admin")).thenReturn(Optional.of(owner));
+ }
+
+ private DatabaseConnection connection() {
+ DatabaseConnection c = new DatabaseConnection();
+ c.setId(CONN);
+ c.setOwnerUsername("admin");
+ return c;
+ }
+
+ private void grantWith(ConnectionAccessLevel level) {
+ ConnectionAccessGrant grant = new ConnectionAccessGrant();
+ grant.setConnectionId(CONN);
+ grant.setUsername("dave");
+ grant.setAccessLevel(level);
+ when(grantRepository.findByConnectionIdAndUsernameIgnoreCase(CONN, "dave"))
+ .thenReturn(Optional.of(grant));
+ }
+
+ @Test
+ @DisplayName("A legacy CHAT_EDITOR grant row now resolves to FULL_CONTENT")
+ void legacyChatEditorRowIsUpgraded() {
+ // Installs predating the collapse still have CHAT_EDITOR rows on disk. They must
+ // resolve to full access rather than a tier the UI no longer explains — this is
+ // what makes the change work with no DB migration.
+ grantWith(ConnectionAccessLevel.CHAT_EDITOR);
+
+ var access = service.resolveAccess(connection(), "dave", false).getEffectiveAccess();
+
+ assertThat(access).isEqualTo(EffectiveConnectionAccess.FULL_CONTENT);
+ assertThat(access.canManageContent()).isTrue();
+ assertThat(access.canReadContent()).isTrue();
+ }
+
+ @Test
+ @DisplayName("A FULL_CONTENT grant still resolves to FULL_CONTENT")
+ void fullContentUnchanged() {
+ grantWith(ConnectionAccessLevel.FULL_CONTENT);
+
+ assertThat(service.resolveAccess(connection(), "dave", false).getEffectiveAccess())
+ .isEqualTo(EffectiveConnectionAccess.FULL_CONTENT);
+ }
+
+ @Test
+ @DisplayName("No grant still means NONE — the collapse must not hand access to strangers")
+ void noGrantStillDenied() {
+ when(grantRepository.findByConnectionIdAndUsernameIgnoreCase(CONN, "dave"))
+ .thenReturn(Optional.empty());
+
+ var access = service.resolveAccess(connection(), "dave", false).getEffectiveAccess();
+
+ assertThat(access).isEqualTo(EffectiveConnectionAccess.NONE);
+ assertThat(access.canUseConnection()).isFalse();
+ assertThat(access.canManageContent()).isFalse();
+ }
+
+ @Test
+ @DisplayName("The owner is still OWNER, not merely FULL_CONTENT")
+ void ownerUnchanged() {
+ assertThat(service.resolveAccess(connection(), "admin", false).getEffectiveAccess())
+ .isEqualTo(EffectiveConnectionAccess.OWNER);
+ }
+
+ @Test
+ @DisplayName("Every stored access-level string parses to FULL_CONTENT")
+ void fromStringCollapses() {
+ assertThat(ConnectionAccessLevel.fromString("CHAT_EDITOR")).isEqualTo(ConnectionAccessLevel.FULL_CONTENT);
+ assertThat(ConnectionAccessLevel.fromString("FULL_CONTENT")).isEqualTo(ConnectionAccessLevel.FULL_CONTENT);
+ assertThat(ConnectionAccessLevel.fromString("FULL_ACCESS")).isEqualTo(ConnectionAccessLevel.FULL_CONTENT);
+ // Blank is no longer an error: assignment implies full access.
+ assertThat(ConnectionAccessLevel.fromString(null)).isEqualTo(ConnectionAccessLevel.FULL_CONTENT);
+ assertThat(ConnectionAccessLevel.fromString(" ")).isEqualTo(ConnectionAccessLevel.FULL_CONTENT);
+ }
+}
diff --git a/backend/src/test/java/com/dbaagent/service/security/ConnectionCreateAuthorizationTest.java b/backend/src/test/java/com/dbaagent/service/security/ConnectionCreateAuthorizationTest.java
new file mode 100644
index 0000000..e17a3fd
--- /dev/null
+++ b/backend/src/test/java/com/dbaagent/service/security/ConnectionCreateAuthorizationTest.java
@@ -0,0 +1,142 @@
+package com.dbaagent.service.security;
+
+import com.dbaagent.model.Permission;
+import com.dbaagent.repository.AnalysisHistoryRepository;
+import com.dbaagent.repository.ChatFeedbackRepository;
+import com.dbaagent.repository.ChatRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Creating a connection requires MANAGE_CONNECTIONS.
+ *
+ *
{@code POST /connections} previously had no authorization whatsoever: it went
+ * straight to test-and-save. A Data Engineer could create a connection and then edit and
+ * delete it — verified against a running install, where the row persisted with
+ * {@code owner_username = analyst}. Only the sidebar button was hidden, which is not a
+ * control.
+ *
+ *
The permission is read from the principal's granted authorities, which
+ * {@code CustomUserDetailsService} stamps from the fully-resolved effective permission
+ * set — so overrides and custom roles are honoured here without a second lookup.
+ */
+class ConnectionCreateAuthorizationTest {
+
+ private AccessControlService accessControlService;
+
+ @BeforeEach
+ void setUp() {
+ accessControlService = new AccessControlService(
+ mock(ConnectionAccessService.class),
+ mock(ChatRepository.class),
+ mock(ChatFeedbackRepository.class),
+ mock(AnalysisHistoryRepository.class)
+ );
+ // Every real deployment runs with auth on; the dev-mode bypass is covered below.
+ ReflectionTestUtils.setField(accessControlService, "authEnabled", true);
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ private void authenticateAs(String username, String roleCode, String... permissions) {
+ var authorities = new java.util.ArrayList();
+ authorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
+ for (String p : permissions) {
+ authorities.add(new SimpleGrantedAuthority(p));
+ }
+ SecurityContextHolder.getContext().setAuthentication(
+ new UsernamePasswordAuthenticationToken(username, null, authorities));
+ }
+
+ @Test
+ @DisplayName("A Developer cannot create a connection")
+ void developerRefused() {
+ // Developer's effective set has no MANAGE_CONNECTIONS (see PermissionResolutionTest).
+ authenticateAs("analyst", "DEVELOPER",
+ Permission.VIEW_AGENT.name(), Permission.VIEW_DASHBOARDS.name(),
+ Permission.VIEW_EDITOR.name(), Permission.EXECUTE_QUERIES.name());
+
+ assertThatThrownBy(() -> accessControlService.assertCanManageConnections())
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("403");
+ assertThat(accessControlService.hasPermission(Permission.MANAGE_CONNECTIONS)).isFalse();
+ }
+
+ @Test
+ @DisplayName("A Data Engineer cannot create a connection")
+ void dataEngineerRefused() {
+ authenticateAs("analyst", "DATA_ENGINEER",
+ Permission.VIEW_AGENT.name(), Permission.VIEW_DASHBOARDS.name(),
+ Permission.VIEW_EDITOR.name());
+
+ assertThatThrownBy(() -> accessControlService.assertCanManageConnections())
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("403");
+ }
+
+ @Test
+ @DisplayName("A DBA can create a connection — the check is permission-based, not admin-only")
+ void dbaAllowed() {
+ // DBA holds MANAGE_CONNECTIONS by design but is not an admin, so an
+ // isCurrentUserAdmin() check here would have broken it.
+ authenticateAs("dba-user", "DBA", Permission.MANAGE_CONNECTIONS.name());
+
+ assertDoesNotThrow(() -> accessControlService.assertCanManageConnections());
+ }
+
+ @Test
+ @DisplayName("An admin can create a connection")
+ void adminAllowed() {
+ authenticateAs("admin", "ADMIN");
+
+ assertDoesNotThrow(() -> accessControlService.assertCanManageConnections());
+ assertThat(accessControlService.hasPermission(Permission.MANAGE_CONNECTIONS)).isTrue();
+ }
+
+ @Test
+ @DisplayName("A custom role granted MANAGE_CONNECTIONS can create a connection")
+ void customRoleWithPermissionAllowed() {
+ authenticateAs("analyst", "PLATFORM_ENG", Permission.MANAGE_CONNECTIONS.name());
+
+ assertDoesNotThrow(() -> accessControlService.assertCanManageConnections());
+ }
+
+ @Test
+ @DisplayName("An unauthenticated caller cannot create a connection")
+ void anonymousRefused() {
+ SecurityContextHolder.clearContext();
+
+ assertThatThrownBy(() -> accessControlService.assertCanManageConnections())
+ .isInstanceOf(ResponseStatusException.class)
+ .hasMessageContaining("403");
+ }
+
+ @Test
+ @DisplayName("With auth disabled the check is a no-op, matching every other guard here")
+ void devModeBypass() {
+ // Consistency matters: requireCurrentUsername/isCurrentUserAdmin both honour this
+ // flag, and a guard that ignored it would switch connection creation off in the
+ // documented dev-mode bypass instead of opening it.
+ ReflectionTestUtils.setField(accessControlService, "authEnabled", false);
+ SecurityContextHolder.clearContext();
+
+ assertDoesNotThrow(() -> accessControlService.assertCanManageConnections());
+ }
+}
diff --git a/src/components/ManageConnectionsModal.js b/src/components/ManageConnectionsModal.js
index 06cdf7e..54ec35d 100644
--- a/src/components/ManageConnectionsModal.js
+++ b/src/components/ManageConnectionsModal.js
@@ -17,6 +17,7 @@ import ConnectionSlowQueryConfig from "./ConnectionSlowQueryConfig";
import SlowQuerySourceModal from "./SlowQuerySourceModal";
import { connectionAPI, brainAPI } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
+import { PERMISSIONS } from "@/lib/permissions";
import { getConnectionAccessBadge, getConnectionAccessLabel } from "@/lib/features";
export default function ManageConnectionsModal({
@@ -25,7 +26,7 @@ export default function ManageConnectionsModal({
onConnectionDeleted,
onConnectionSaved,
}) {
- const { isAdmin } = useAuth();
+ const { isAdmin, hasPermission } = useAuth();
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState(null);
@@ -118,6 +119,12 @@ export default function ManageConnectionsModal({
if (!isOpen) return null;
+ // Enforced here rather than only at the call sites: this modal adds, edits and deletes
+ // database connections, and it is opened from the sidebar, the Agent view and the user
+ // menu. Gating each entry point separately means the next new one silently reopens the
+ // hole. The backend already 403s these writes; this keeps the UI honest about it.
+ if (!hasPermission(PERMISSIONS.MANAGE_CONNECTIONS)) return null;
+
return (
e.stopPropagation()}>
diff --git a/src/components/SettingsModal.js b/src/components/SettingsModal.js
index 9e8218d..4b5f83a 100644
--- a/src/components/SettingsModal.js
+++ b/src/components/SettingsModal.js
@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { X, Users, Settings, Shield, Activity, KeyRound } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
+import { PERMISSIONS } from "@/lib/permissions";
import AdminWorkspaceSettings from "@/components/settings/AdminWorkspaceSettings";
import SlackAccessCodePanel from "@/components/settings/SlackAccessCodePanel";
import McpTokensPanel from "@/components/settings/McpTokensPanel";
@@ -11,9 +12,14 @@ import AuditLogsTab from "./tabs/admin/AuditLogsTab";
import styles from "./SettingsModal.module.css";
export default function SettingsModal({ isOpen, onClose }) {
- const { isAdmin, role } = useAuth();
+ const { isAdmin, role, hasPermission } = useAuth();
+ const canManageWorkspaceSettings = hasPermission(PERMISSIONS.MANAGE_SETTINGS);
+ const canOpenSettings =
+ canManageWorkspaceSettings ||
+ hasPermission(PERMISSIONS.MANAGE_USERS) ||
+ hasPermission(PERMISSIONS.MANAGE_PERMISSIONS);
const [activeSection, setActiveSection] = useState(() =>
- isAdmin ? "users" : "general",
+ isAdmin ? "users" : "mcp-tokens",
);
// Handle Escape key to close
@@ -29,6 +35,12 @@ export default function SettingsModal({ isOpen, onClose }) {
if (!isOpen) return null;
+ // Settings is administrative only. Developer and Data Engineer hold none of these
+ // permissions and must not reach this panel at all. Enforced inside the modal, not
+ // just at the call site, because any future entry point would otherwise reopen it.
+ // Note this also removes MCP tokens from those roles, which is the intended trade.
+ if (!canOpenSettings) return null;
+
const sections = [
...(isAdmin
? [
@@ -52,12 +64,20 @@ export default function SettingsModal({ isOpen, onClose }) {
label: "MCP Tokens",
description: "Create and revoke personal access tokens",
},
- {
- id: "general",
- icon: Settings,
- label: "Workspace",
- description: isAdmin ? "SMTP, Slack, and security" : "Application preferences",
- },
+ // The Workspace tab is admin configuration (SMTP, Slack, workspace security).
+ // Roles without MANAGE_SETTINGS — Developer and Data Engineer — only see the
+ // personal surfaces above (MCP tokens), so Settings stays useful to them without
+ // exposing workspace configuration.
+ ...(canManageWorkspaceSettings
+ ? [
+ {
+ id: "general",
+ icon: Settings,
+ label: "Workspace",
+ description: "SMTP, Slack, and security",
+ },
+ ]
+ : []),
];
const renderContent = () => {
diff --git a/src/components/layout/AppSidebar.jsx b/src/components/layout/AppSidebar.jsx
index d6d12ce..efe9d05 100644
--- a/src/components/layout/AppSidebar.jsx
+++ b/src/components/layout/AppSidebar.jsx
@@ -3,6 +3,7 @@ import { Brain, Code2, Database, Settings, PanelLeftClose, PanelLeftOpen, LogOut
import { useActiveSection, useSetActiveSection } from '@/lib/stores/useNavStore'
import { useConnectionManager } from '@/lib/hooks/useConnectionManager'
import { AGENTS_ENABLED, canAccessHomeSection, getConnectionAccessBadge, getConnectionAccessLabel } from '@/lib/features'
+import { PERMISSIONS } from '@/lib/permissions'
import ManageConnectionsModal from '@/components/ManageConnectionsModal'
import SettingsModal from '@/components/SettingsModal'
import { useAuth } from '@/hooks/useAuth'
@@ -28,9 +29,18 @@ export default function AppSidebar() {
const [showConnectionDropdown, setShowConnectionDropdown] = useState(false)
const userMenuRef = useRef(null)
const connectionDropdownRef = useRef(null)
- const { logout, role, username, impersonating } = useAuth()
+ const { logout, role, username, impersonating, permissions, hasPermission } = useAuth()
+ // Connections (add/edit/delete a database) is administrative: the backend already
+ // refuses it to Developer and Data Engineer with 403, so hiding the button stops the
+ // UI offering a door that only leads to an error. Settings is likewise administrative
+ // only — Developer and Data Engineer do not see it at all.
+ const canManageConnections = hasPermission(PERMISSIONS.MANAGE_CONNECTIONS)
+ const canOpenSettings =
+ hasPermission(PERMISSIONS.MANAGE_SETTINGS) ||
+ hasPermission(PERMISSIONS.MANAGE_USERS) ||
+ hasPermission(PERMISSIONS.MANAGE_PERMISSIONS)
const { connections, connectionId, selectedConnection, changeConnection, isLoading, refetch } = useConnectionManager()
- const visibleNavItems = NAV_ITEMS.filter(({ id }) => canAccessHomeSection(id, role, selectedConnection))
+ const visibleNavItems = NAV_ITEMS.filter(({ id }) => canAccessHomeSection(id, role, selectedConnection, permissions))
const initials = username.slice(0, 2).toUpperCase()
const connectionLabel =
@@ -164,16 +174,18 @@ export default function AppSidebar() {
)}
-
setShowConnections(true)}
- title={collapsed ? 'Connections' : undefined}
- >
-
-
- Connections
-
-
+ {canManageConnections && (
+
setShowConnections(true)}
+ title={collapsed ? 'Connections' : undefined}
+ >
+
+
+ Connections
+
+
+ )}
-
{ setShowUserMenu(false); setShowSettings(true) }}
- >
-
- Settings
-
+ {canOpenSettings && (
+
{ setShowUserMenu(false); setShowSettings(true) }}
+ >
+
+ Settings
+
+ )}
{ setShowUserMenu(false); logout() }}
diff --git a/src/components/layout/ProfileSwitch.jsx b/src/components/layout/ProfileSwitch.jsx
index 18c5ff9..318fd05 100644
--- a/src/components/layout/ProfileSwitch.jsx
+++ b/src/components/layout/ProfileSwitch.jsx
@@ -63,7 +63,7 @@ export default function ProfileSwitch() {
try {
const payload = await startImpersonation(userId)
setOpen(false)
- setActiveSection(getDefaultHomeSection(payload?.role))
+ setActiveSection(getDefaultHomeSection(payload?.role, null, payload?.permissions))
} catch (err) {
setError(err?.response?.data?.message || err.message || 'Could not switch profile')
} finally {
@@ -78,7 +78,7 @@ export default function ProfileSwitch() {
try {
const payload = await stopImpersonation()
setOpen(false)
- setActiveSection(getDefaultHomeSection(payload?.role))
+ setActiveSection(getDefaultHomeSection(payload?.role, null, payload?.permissions))
} catch (err) {
setError(err?.response?.data?.message || err.message || 'Could not exit profile')
} finally {
diff --git a/src/components/sections/DashboardsHome.jsx b/src/components/sections/DashboardsHome.jsx
index 6b48bc0..9f27bd4 100644
--- a/src/components/sections/DashboardsHome.jsx
+++ b/src/components/sections/DashboardsHome.jsx
@@ -1,6 +1,8 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
-import { Plus, Sparkles, Clock, RefreshCw, Trash2, Loader2, LayoutDashboard, AlertTriangle, Search, Star, Copy, Folder, X } from 'lucide-react'
+import { Plus, Sparkles, Clock, RefreshCw, Trash2, Loader2, LayoutDashboard, AlertTriangle, Search, Star, Copy, Folder, X, Users, Settings2 } from 'lucide-react'
import { savedDashboardsAPI } from '@/lib/api/client'
+import { dashboardWorkspacesAPI } from '@/lib/api/client'
+import WorkspaceManager from './WorkspaceManager'
import styles from './DashboardsHome.module.css'
// A dashboard is "live" once it's published to the web (has a public link).
@@ -74,11 +76,17 @@ export default function DashboardsHome({ connectionId, onOpen }) {
// some embedded/webview hosts suppress it outright, silently no-opping the delete).
const [confirmId, setConfirmId] = useState(null)
const [deleteError, setDeleteError] = useState(null)
+ const [workspaces, setWorkspaces] = useState([])
+ const [workspaceFilter, setWorkspaceFilter] = useState(null) // null = all workspaces
+ const [showWorkspaces, setShowWorkspaces] = useState(false)
+ const [workspaceMenuId, setWorkspaceMenuId] = useState(null)
+ const [movingWorkspaceId, setMovingWorkspaceId] = useState(null)
const [folderMenuId, setFolderMenuId] = useState(null)
const [folderInput, setFolderInput] = useState('')
const [movingFolderId, setMovingFolderId] = useState(null)
const confirmRef = useRef(null)
const folderMenuRef = useRef(null)
+ const workspaceMenuRef = useRef(null)
useEffect(() => {
if (!folderMenuId) return
@@ -89,6 +97,15 @@ export default function DashboardsHome({ connectionId, onOpen }) {
return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey) }
}, [folderMenuId])
+ useEffect(() => {
+ if (!workspaceMenuId) return
+ const onDocClick = (e) => { if (workspaceMenuRef.current && !workspaceMenuRef.current.contains(e.target)) setWorkspaceMenuId(null) }
+ const onKey = (e) => { if (e.key === 'Escape') setWorkspaceMenuId(null) }
+ document.addEventListener('mousedown', onDocClick)
+ document.addEventListener('keydown', onKey)
+ return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey) }
+ }, [workspaceMenuId])
+
useEffect(() => {
if (!confirmId) return
const onDocClick = (e) => { if (confirmRef.current && !confirmRef.current.contains(e.target)) setConfirmId(null) }
@@ -162,6 +179,36 @@ export default function DashboardsHome({ connectionId, onOpen }) {
}
}, [movingFolderId])
+ const loadWorkspaces = useCallback(async () => {
+ if (!connectionId) return
+ try {
+ const list = await dashboardWorkspacesAPI.listByConnection(connectionId)
+ setWorkspaces(Array.isArray(list) ? list : [])
+ } catch {
+ // A user with no workspace membership simply has none to show; the
+ // dashboards list itself is already filtered server-side.
+ setWorkspaces([])
+ }
+ }, [connectionId])
+
+ // Move a dashboard into a workspace, or out of one with an empty workspaceId.
+ const moveToWorkspace = useCallback(async (d, workspaceId) => {
+ if (movingWorkspaceId) return
+ setMovingWorkspaceId(d.id)
+ try {
+ await dashboardWorkspacesAPI.moveDashboard(d.id, workspaceId || '')
+ setDashboards((list) => list.map((x) => (
+ x.id === d.id ? { ...x, workspaceId: workspaceId || null } : x
+ )))
+ setWorkspaceMenuId(null)
+ loadWorkspaces()
+ } catch (err) {
+ setDeleteError(err?.response?.data?.message || err?.message || 'Couldn’t move this dashboard.')
+ } finally {
+ setMovingWorkspaceId(null)
+ }
+ }, [movingWorkspaceId, loadWorkspaces])
+
const load = useCallback(async () => {
setLoading(true)
try {
@@ -180,6 +227,9 @@ export default function DashboardsHome({ connectionId, onOpen }) {
// the new fetch is in flight.
useEffect(() => { setHasLoadedOnce(false); setLoading(true) }, [connectionId])
useEffect(() => { load() }, [load])
+ useEffect(() => { loadWorkspaces() }, [loadWorkspaces])
+ // A workspace filter from a previous connection must not survive the switch.
+ useEffect(() => { setWorkspaceFilter(null) }, [connectionId])
const folders = useMemo(() => {
const set = new Set(dashboards.map((d) => d.folder).filter(Boolean))
@@ -191,10 +241,11 @@ export default function DashboardsHome({ connectionId, onOpen }) {
return dashboards.filter((d) => {
if (filter !== 'all' && statusOf(d) !== filter) return false
if (folder && d.folder !== folder) return false
+ if (workspaceFilter && d.workspaceId !== workspaceFilter) return false
if (q && !(d.name || '').toLowerCase().includes(q) && !(d.description || '').toLowerCase().includes(q)) return false
return true
})
- }, [dashboards, filter, folder, search])
+ }, [dashboards, filter, folder, search, workspaceFilter])
return (
@@ -206,6 +257,14 @@ export default function DashboardsHome({ connectionId, onOpen }) {
)}
+ setShowWorkspaces(true)}
+ title="Manage workspaces"
+ aria-label="Manage workspaces"
+ >
+
+
@@ -254,6 +313,28 @@ export default function DashboardsHome({ connectionId, onOpen }) {
{f}
))}
+ {workspaces.length > 0 && }
+ {workspaces.map((w) => (
+ setWorkspaceFilter(workspaceFilter === w.id ? null : w.id)}
+ title={w.description || `${w.dashboardCount ?? 0} dashboard(s)`}
+ >
+
+ {w.name}
+
+ ))}
{!hasLoadedOnce ? (
@@ -335,6 +416,50 @@ export default function DashboardsHome({ connectionId, onOpen }) {
)}
+ { e.stopPropagation(); setWorkspaceMenuId(workspaceMenuId === d.id ? null : d.id) }}
+ title="Move to workspace"
+ aria-label={`Move ${d.name || 'dashboard'} to a workspace`}
+ >
+
+
+ {workspaceMenuId === d.id && (
+ e.stopPropagation()}>
+
Move to workspace
+ {workspaces.length === 0 ? (
+
+ No workspaces yet — create one from the Workspaces button above.
+
+ ) : (
+
+ moveToWorkspace(d, '')}
+ disabled={movingWorkspaceId === d.id}
+ >
+ No workspace
+
+ {workspaces.map((w) => (
+ moveToWorkspace(d, w.id)}
+ disabled={movingWorkspaceId === d.id}
+ >
+ {w.name}
+
+ ))}
+
+ )}
+
+ )}
{ e.stopPropagation(); if (!deletingId) setConfirmId(d.id) }}
@@ -381,6 +506,13 @@ export default function DashboardsHome({ connectionId, onOpen }) {
)}
+ setShowWorkspaces(false)}
+ onChanged={() => { loadWorkspaces(); load() }}
+ />
+
{deleteError && (
diff --git a/src/components/sections/WorkspaceManager.jsx b/src/components/sections/WorkspaceManager.jsx
new file mode 100644
index 0000000..d792998
--- /dev/null
+++ b/src/components/sections/WorkspaceManager.jsx
@@ -0,0 +1,337 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { Loader2, Plus, Trash2, Users, X, AlertTriangle, Shield, Eye } from 'lucide-react'
+import { dashboardWorkspacesAPI, adminAPI } from '@/lib/api/client'
+import { useAuth } from '@/hooks/useAuth'
+import styles from './WorkspaceManager.module.css'
+
+const COLORS = ['#534AB7', '#0E7C66', '#B4530A', '#9B2C64', '#2563EB', '#525252']
+
+/**
+ * Create and administer dashboard workspaces for one connection.
+ *
+ *
A workspace groups dashboards and carries its own member list. Access is an AND:
+ * a member still needs read access to the connection, so adding someone here can never
+ * grant them a database they were not already given.
+ */
+export default function WorkspaceManager({ connectionId, open, onClose, onChanged }) {
+ const { isAdmin, username } = useAuth()
+ const [workspaces, setWorkspaces] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [creating, setCreating] = useState(false)
+ const [newName, setNewName] = useState('')
+ const [newDescription, setNewDescription] = useState('')
+ const [newColor, setNewColor] = useState(COLORS[0])
+
+ const [selectedId, setSelectedId] = useState(null)
+ const [members, setMembers] = useState([])
+ const [membersLoading, setMembersLoading] = useState(false)
+ const [users, setUsers] = useState([])
+ const [addUsername, setAddUsername] = useState('')
+ const [addRole, setAddRole] = useState('VIEWER')
+ const [busy, setBusy] = useState(null)
+ const [confirmDeleteId, setConfirmDeleteId] = useState(null)
+
+ const load = useCallback(async () => {
+ if (!connectionId) return
+ setLoading(true)
+ setError(null)
+ try {
+ const list = await dashboardWorkspacesAPI.listByConnection(connectionId)
+ setWorkspaces(Array.isArray(list) ? list : [])
+ } catch (e) {
+ setError(e?.response?.data?.message || 'Could not load workspaces.')
+ } finally {
+ setLoading(false)
+ }
+ }, [connectionId])
+
+ useEffect(() => { if (open) load() }, [open, load])
+
+ // The member picker needs the user directory, which only admins can read. For
+ // everyone else the field stays a free-text username entry rather than showing a
+ // broken or empty dropdown.
+ useEffect(() => {
+ if (!open || !isAdmin) return
+ adminAPI.listUsers()
+ .then((res) => setUsers(Array.isArray(res) ? res : res?.users || []))
+ .catch(() => setUsers([]))
+ }, [open, isAdmin])
+
+ const loadMembers = useCallback(async (workspaceId) => {
+ setMembersLoading(true)
+ try {
+ const list = await dashboardWorkspacesAPI.listMembers(workspaceId)
+ setMembers(Array.isArray(list) ? list : [])
+ } catch (e) {
+ setError(e?.response?.data?.message || 'Could not load members.')
+ setMembers([])
+ } finally {
+ setMembersLoading(false)
+ }
+ }, [])
+
+ const select = useCallback((workspace) => {
+ setSelectedId(workspace.id)
+ setError(null)
+ loadMembers(workspace.id)
+ }, [loadMembers])
+
+ const create = useCallback(async () => {
+ const name = newName.trim()
+ if (!name || creating) return
+ setCreating(true)
+ setError(null)
+ try {
+ const created = await dashboardWorkspacesAPI.create({
+ connectionId,
+ name,
+ description: newDescription.trim(),
+ color: newColor,
+ })
+ setWorkspaces((list) => [...list, created].sort((a, b) => a.name.localeCompare(b.name)))
+ setNewName('')
+ setNewDescription('')
+ onChanged?.()
+ } catch (e) {
+ setError(e?.response?.data?.message || 'Could not create the workspace.')
+ } finally {
+ setCreating(false)
+ }
+ }, [newName, newDescription, newColor, connectionId, creating, onChanged])
+
+ const remove = useCallback(async (workspace) => {
+ setBusy(workspace.id)
+ setError(null)
+ try {
+ await dashboardWorkspacesAPI.remove(workspace.id)
+ setWorkspaces((list) => list.filter((w) => w.id !== workspace.id))
+ if (selectedId === workspace.id) {
+ setSelectedId(null)
+ setMembers([])
+ }
+ setConfirmDeleteId(null)
+ onChanged?.()
+ } catch (e) {
+ setError(e?.response?.data?.message || 'Could not delete the workspace.')
+ } finally {
+ setBusy(null)
+ }
+ }, [selectedId, onChanged])
+
+ const addMember = useCallback(async () => {
+ const name = addUsername.trim()
+ if (!name || !selectedId || busy) return
+ setBusy('add-member')
+ setError(null)
+ try {
+ await dashboardWorkspacesAPI.addMember(selectedId, { username: name, workspaceRole: addRole })
+ await loadMembers(selectedId)
+ setAddUsername('')
+ } catch (e) {
+ setError(e?.response?.data?.message || 'Could not add the member.')
+ } finally {
+ setBusy(null)
+ }
+ }, [addUsername, addRole, selectedId, busy, loadMembers])
+
+ const removeMember = useCallback(async (member) => {
+ if (!selectedId || busy) return
+ setBusy(member.username)
+ setError(null)
+ try {
+ await dashboardWorkspacesAPI.removeMember(selectedId, member.username)
+ await loadMembers(selectedId)
+ } catch (e) {
+ setError(e?.response?.data?.message || 'Could not remove the member.')
+ } finally {
+ setBusy(null)
+ }
+ }, [selectedId, busy, loadMembers])
+
+ if (!open) return null
+
+ const selected = workspaces.find((w) => w.id === selectedId) || null
+
+ return (
+
+
e.stopPropagation()} role="dialog" aria-label="Manage workspaces">
+
+
+ {error && (
+
+ )}
+
+