Skip to content

Commit f1c03f3

Browse files
Feat/workspaces and roles (#80)
Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com>
1 parent 93553f4 commit f1c03f3

58 files changed

Lines changed: 4709 additions & 585 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

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

332+
### Roles, Permissions & Custom Roles
333+
334+
Roles are **not a hierarchy**. The old model ranked `DEVELOPER < ADMIN` and compared
335+
`ordinal()`; the shipped roles deliberately overlap without nesting, so an ordering
336+
comparison has no meaning and `Role.isAtLeast` is gone.
337+
338+
| Role | Sections | Notes |
339+
|---|---|---|
340+
| `ADMIN` | everything | Fixed point: holds **every** permission; overrides against it are refused, so the last admin cannot be locked out of user management. |
341+
| `DBA` | all menus + connection settings | **No** user creation / invite codes / role management. |
342+
| `DATA_ENGINEER` | Agent, Dashboards, Editor | No Digest, no Performance. |
343+
| `DEVELOPER` | Agent, Digest, Dashboards, Performance, Editor | No connection settings. |
344+
| custom | whatever an admin ticks | `custom_roles` rows; the `code` is written to `users.role`. |
345+
346+
- **Permissions are the unit of authorization.** `Permission` carries the built-in roles
347+
that hold it by default (`defaultRoles`); one `VIEW_*` permission per sidebar section
348+
(`VIEW_AGENT`, `VIEW_DASHBOARDS`, `VIEW_DIGEST`, `VIEW_BRAIN`, `VIEW_PERFORMANCE`,
349+
`VIEW_EDITOR`). The frontend gates nav on those codes (`SECTION_PERMISSION` in
350+
`src/lib/features.js`), not on a minimum role.
351+
- **A "role code" is either a built-in `Role` name or a `CustomRole.code`** — they share
352+
the `users.role` namespace, so `CustomRoleService` refuses a code colliding with a
353+
built-in one. `Role.fromString` returns **null** for anything unrecognised instead of
354+
collapsing to DEVELOPER: mapping a custom role onto a built-in one would hand its
355+
holders the wrong permissions. Use `PermissionService.getEffectivePermissions(roleCode)`
356+
`User.getRoleEnum()` is null for a custom role and `Role.getPermissions()` skips
357+
overrides.
358+
- **Every token-minting path must resolve by role code.** `AuthSessionService`,
359+
`PasswordlessAuthService`, `AuthInternalController`, `CustomUserDetailsService` and the
360+
`/auth/me` payload all use `user.getRoleCode()` + `PermissionService`; `JwtUtil` gained
361+
a `String roleCode` overload for exactly this. A `Role`-typed path cannot represent a
362+
custom role, so a custom-role user would silently get the wrong claim.
363+
- **An unknown role code grants nothing** rather than falling back — a deleted custom role
364+
must not become silent Developer access. Deleting a custom role is refused while any
365+
user still holds it.
366+
- `RolePermissionOverride.role` is now a role-code **string** (same column), so overrides
367+
work for custom roles too. Built-in role permission sets are code, not data: the API
368+
refuses to edit them directly and points at overrides instead, so an admin's change
369+
survives an upgrade.
370+
371+
### Connection access levels & the create-connection guard
372+
373+
- **There is one access level.** `ConnectionAccessLevel.CHAT_EDITOR` is `@Deprecated` and
374+
retained only so pre-existing rows parse; `fromString` folds it (and a blank value) into
375+
`FULL_CONTENT`, and `ConnectionAccessService.resolveAccess` returns `FULL_CONTENT` for
376+
**every** grant. Assigning a connection therefore implies content access — no migration
377+
was needed, legacy rows upgrade themselves on read. The "Full Access" / "Chat + Editor"
378+
badges are gone; only Owner/Admin are surfaced.
379+
- **`AccessControlServiceTest` cannot prove anything about this.** It stubs
380+
`resolveAccess` to return a fixed `EffectiveConnectionAccess`, so its CHAT_EDITOR case
381+
passes vacuously no matter what the resolver does. `ConnectionAccessLevelCollapseTest`
382+
exercises the real path — add coverage there, not to the stubbed test.
383+
- **`POST /connections` had no authorization at all.** It went straight to test-and-save,
384+
so any authenticated user could create — then edit and delete — their own connection
385+
(verified live: the row persisted with `owner_username = analyst` for a DATA_ENGINEER).
386+
Hiding the sidebar button is not a control. It now calls
387+
`accessControlService.assertCanManageConnections()`, which is **permission-based, not
388+
admin-only**, so DBA and any custom role holding `MANAGE_CONNECTIONS` still work.
389+
Creation is not scoped to a connection id, so none of the `assertCanManage*Connection*`
390+
helpers apply — a new unscoped endpoint needs this guard explicitly.
391+
- **Settings and Connections are admin surfaces in the UI.** `SettingsModal` and
392+
`ManageConnectionsModal` each refuse to render without the relevant permission, enforced
393+
*inside* the component rather than only at the call site: both are opened from several
394+
places, and gating each entry point separately means the next one silently reopens the
395+
hole. Hiding Settings also removes MCP tokens from those roles — that is intended.
396+
397+
### Dashboard workspaces
398+
399+
`DashboardWorkspace` groups dashboards within one connection and carries its own member
400+
list (`DashboardWorkspaceMember`, keyed by **username** to match `connection_access_grant`
401+
so "View as" resolves membership as the target user).
402+
403+
- **The rule is an AND, and it only ever narrows.** Connection access is checked first and
404+
unchanged (`assertCanReadConnectionContent`); workspace membership is an *additional*
405+
gate. Adding someone to a workspace can never grant them a connection they were not
406+
already given. `saved_dashboards.workspace_id` is nullable — NULL means "not grouped",
407+
governed purely by the connection ACL exactly as before.
408+
- Admins bypass the membership half, matching how they already bypass connection grants.
409+
- **Non-membership reports 404, not 403** — a user outside the workspace must not learn
410+
the dashboard exists.
411+
- **Deleting a workspace detaches its dashboards, never deletes them** (the FK is
412+
deliberately non-cascading). Removing the last MANAGER is refused, otherwise the
413+
workspace could never be changed again by anyone but an admin.
414+
- `DashboardWorkspaceService.filterReadable` resolves a whole list in one membership
415+
query; use it for any new dashboard-list endpoint rather than checking per row.
416+
- **`/saved-dashboards` had no connection authorization at all** before this change —
417+
create, list, get, update and delete took a caller-supplied `connectionId`/id and
418+
checked nothing, so any authenticated user could read every dashboard on every
419+
connection (verified live against a running install, not inferred). All of them now
420+
assert connection access *and* the workspace gate; `DashboardAlertController` does the
421+
same through its single `requireDashboard` choke point. This is the same
422+
"authentication is not authorization" trap `BrainController` documents — there is still
423+
no filter doing it for you.
424+
332425
### Admin profile switch
333426
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.
334427

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.dbaagent.config;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.context.annotation.DependsOn;
7+
import org.springframework.jdbc.core.JdbcTemplate;
8+
9+
import javax.sql.DataSource;
10+
11+
/**
12+
* Drops the stale CHECK constraints Hibernate generated for
13+
* {@code role_permission_overrides} under the original two-role, twenty-permission
14+
* enums.
15+
*
16+
* <p>{@code ddl-auto=update} adds columns and tables but <em>never drops a constraint it
17+
* previously created</em>, so on any database created before the role model changed both
18+
* checks survive and reject every new value. Inserting an override for the DBA role, or
19+
* for any of the new section permissions, fails with:
20+
*
21+
* <pre>
22+
* ERROR: new row for relation "role_permission_overrides" violates check constraint
23+
* "role_permission_overrides_permission_code_check"
24+
* </pre>
25+
*
26+
* <p>That was observed against a live install, not inferred — the tables looked correct
27+
* and only an actual INSERT revealed it. There is no Flyway runtime in this repo
28+
* (see CLAUDE.md), so this initializer is what actually applies the matching statements
29+
* in {@code V117__create_dashboard_workspaces_and_custom_roles.sql}.
30+
*
31+
* <p>Dropping rather than rewriting the constraints is deliberate: the {@code Role} /
32+
* {@code Permission} enums plus {@code PermissionService} are the authority for these
33+
* values, and a database-level copy of an enum has to be re-migrated on every future
34+
* addition — which is precisely how this broke.
35+
*/
36+
@Configuration
37+
@Slf4j
38+
public class RolePermissionConstraintInitializer {
39+
40+
private static final String TABLE = "role_permission_overrides";
41+
42+
@Bean("rolePermissionConstraintBootstrap")
43+
@DependsOn("entityManagerFactory")
44+
public Object rolePermissionConstraintBootstrap(DataSource dataSource) {
45+
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
46+
47+
if (!tableExists(jdbc, TABLE)) {
48+
return new Object();
49+
}
50+
51+
int dropped = 0;
52+
dropped += dropCheckIfPresent(jdbc, TABLE + "_role_check");
53+
dropped += dropCheckIfPresent(jdbc, TABLE + "_permission_code_check");
54+
55+
// Widen the role column so a custom role code fits; harmless if already wide.
56+
try {
57+
jdbc.execute("ALTER TABLE " + TABLE + " ALTER COLUMN role TYPE VARCHAR(64)");
58+
} catch (RuntimeException e) {
59+
log.warn("Could not widen {}.role: {}", TABLE, e.getMessage());
60+
}
61+
62+
if (dropped > 0) {
63+
log.info("Dropped {} stale CHECK constraint(s) on {} left over from the previous role model",
64+
dropped, TABLE);
65+
}
66+
return new Object();
67+
}
68+
69+
private int dropCheckIfPresent(JdbcTemplate jdbc, String constraintName) {
70+
Integer count = jdbc.queryForObject("""
71+
SELECT COUNT(*)
72+
FROM information_schema.table_constraints
73+
WHERE table_schema = 'public' AND table_name = ? AND constraint_name = ?
74+
""", Integer.class, TABLE, constraintName);
75+
if (count == null || count == 0) {
76+
return 0;
77+
}
78+
try {
79+
jdbc.execute("ALTER TABLE " + TABLE + " DROP CONSTRAINT IF EXISTS " + constraintName);
80+
return 1;
81+
} catch (RuntimeException e) {
82+
log.warn("Could not drop stale constraint {}: {}", constraintName, e.getMessage());
83+
return 0;
84+
}
85+
}
86+
87+
private boolean tableExists(JdbcTemplate jdbc, String tableName) {
88+
Integer count = jdbc.queryForObject("""
89+
SELECT COUNT(*)
90+
FROM information_schema.tables
91+
WHERE table_schema = 'public' AND table_name = ?
92+
""", Integer.class, tableName);
93+
return count != null && count > 0;
94+
}
95+
}

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ public ResponseEntity<?> completeGoogleLogin(
167167
return redirectToFrontend("/login?error=" + urlEncode(result.message()));
168168
}
169169

170-
if (result.sessionAuthentication() != null && result.user() != null && result.role() != null) {
170+
// Gate on roleCode, not role: role is null for a custom-role user.
171+
if (result.sessionAuthentication() != null && result.user() != null && result.roleCode() != null) {
171172
authSessionService.writeSessionCookies(httpResponse, result.sessionAuthentication());
172173
return redirectToFrontend("/dashboard");
173174
}
@@ -224,8 +225,8 @@ public ResponseEntity<?> refreshSession(HttpServletRequest httpRequest, HttpServ
224225
}
225226
Map<String, Object> payload = toAuthPayload(
226227
effectiveUser,
227-
effectiveUser.getRoleEnum(),
228-
permissionService.getEffectivePermissionCodes(effectiveUser.getRoleEnum())
228+
effectiveUser.getRoleCode(),
229+
permissionService.getEffectivePermissionCodes(effectiveUser.getRoleCode())
229230
);
230231
impersonationService.decorateAuthPayload(httpRequest, user, payload);
231232
return ResponseEntity.ok(payload);
@@ -332,9 +333,9 @@ public ResponseEntity<?> getCurrentUser(HttpServletRequest httpRequest) {
332333
return ResponseEntity.status(401).body(Map.of("message", "Not authenticated"));
333334
}
334335
User user = currentUserEntity();
335-
Role role = user.getRoleEnum();
336-
Set<String> permissions = permissionService.getEffectivePermissionCodes(role);
337-
Map<String, Object> response = toAuthPayload(user, role, permissions);
336+
String roleCode = user.getRoleCode();
337+
Set<String> permissions = permissionService.getEffectivePermissionCodes(roleCode);
338+
Map<String, Object> response = toAuthPayload(user, roleCode, permissions);
338339
impersonationService.decorateAuthPayload(httpRequest, user, response);
339340
return ResponseEntity.ok(response);
340341
}
@@ -378,13 +379,17 @@ private ResponseEntity<?> authResponse(PasswordlessAuthService.AuthFlowResult re
378379
if (!result.success()) {
379380
return ResponseEntity.status(400).body(Map.of("message", result.message()));
380381
}
381-
if (result.sessionAuthentication() != null && result.user() != null && result.role() != null) {
382+
// Gate on roleCode, not role: result.role() is null for a user holding a custom
383+
// role, which sent an otherwise-successful login down the "challenge required"
384+
// branch below and then NPE'd in Map.of on a null challengeId — a 500 on every
385+
// custom-role login. Observed live, not inferred.
386+
if (result.sessionAuthentication() != null && result.user() != null && result.roleCode() != null) {
382387
authSessionService.writeSessionCookies(httpResponse, result.sessionAuthentication());
383388
authSessionService.clearImpersonationCookie(httpResponse);
384389
Set<String> permissionNames = result.permissions() == null ? Set.of() : result.permissions().stream()
385390
.map(Enum::name)
386391
.collect(Collectors.toSet());
387-
return ResponseEntity.ok(toAuthPayload(result.user(), result.role(), permissionNames));
392+
return ResponseEntity.ok(toAuthPayload(result.user(), result.roleCode(), permissionNames));
388393
}
389394
return ResponseEntity.ok(Map.of(
390395
"challengeId", result.nextChallengeId(),
@@ -401,10 +406,19 @@ private ResponseEntity<?> authError(ResponseStatusException e) {
401406
}
402407

403408
private Map<String, Object> toAuthPayload(User user, Role role, Set<String> permissions) {
409+
return toAuthPayload(user, role != null ? role.name() : user.getRoleCode(), permissions);
410+
}
411+
412+
/**
413+
* Auth payload keyed by role <em>code</em>, so a user holding a custom role reports
414+
* that role rather than the nearest built-in one.
415+
*/
416+
private Map<String, Object> toAuthPayload(User user, String roleCode, Set<String> permissions) {
404417
Map<String, Object> response = new LinkedHashMap<>();
405418
response.put("username", user.getUsername());
406419
response.put("email", user.getEmail());
407-
response.put("role", role.name());
420+
response.put("role", roleCode);
421+
response.put("roleName", permissionService.describeRole(roleCode));
408422
response.put("permissions", permissions);
409423
response.put("emailVerified", user.isEmailVerified());
410424
response.put("accountStatus", user.getAccountStatus());

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ public ResponseEntity<?> issueToken(
6060
.body(Map.of("message", "Admin user not found"));
6161
}
6262

63-
Role role = admin.getRoleEnum();
64-
Set<Permission> permissions = permissionService.getEffectivePermissions(role);
63+
String roleCode = admin.getRoleCode();
64+
Set<Permission> permissions = permissionService.getEffectivePermissions(roleCode);
6565

6666
AuthSessionService.SessionAuthentication session = authSessionService.createSession(
67-
admin, role, permissions,
67+
admin, roleCode, permissions,
6868
request.getRemoteAddr(),
6969
"DeepSQL-TestSuite/1.0",
7070
true

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

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

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.dbaagent.model.SavedDashboard;
55
import com.dbaagent.service.DashboardAlertService;
66
import com.dbaagent.service.SavedDashboardService;
7+
import com.dbaagent.service.DashboardWorkspaceService;
78
import com.dbaagent.service.security.AccessControlService;
89
import lombok.RequiredArgsConstructor;
910
import lombok.extern.slf4j.Slf4j;
@@ -25,6 +26,7 @@ public class DashboardAlertController {
2526
private final DashboardAlertService alertService;
2627
private final SavedDashboardService savedDashboardService;
2728
private final AccessControlService accessControlService;
29+
private final DashboardWorkspaceService dashboardWorkspaceService;
2830

2931
@PostMapping
3032
public ResponseEntity<Map<String, Object>> create(@PathVariable UUID dashboardId, @RequestBody DashboardAlert draft) {
@@ -93,8 +95,15 @@ public ResponseEntity<Map<String, Object>> delete(@PathVariable UUID dashboardId
9395
}
9496
}
9597

98+
/**
99+
* The single point every handler here resolves a dashboard through, so the workspace
100+
* membership gate applies to all of them at once. The connection check stays with
101+
* each caller because read and write paths need different assertions.
102+
*/
96103
private SavedDashboard requireDashboard(UUID dashboardId) {
97-
return savedDashboardService.getDashboardById(dashboardId)
104+
SavedDashboard dashboard = savedDashboardService.getDashboardById(dashboardId)
98105
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));
106+
dashboardWorkspaceService.assertCanReadDashboard(dashboard);
107+
return dashboard;
99108
}
100109
}

0 commit comments

Comments
 (0)