You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: CLAUDE.md
+93Lines changed: 93 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -329,6 +329,99 @@ returns a number).
329
329
4.**Tooltips**: Always use `HelpTooltip` component, never plain `title` attributes.
330
330
5.**Design**: Minimal black/white/grey palette, Inter font, subtle transitions. See UX guidelines in full CLAUDE.md.
331
331
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. |
`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
+
332
425
### Admin profile switch
333
426
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.
0 commit comments