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