The execution-related "group" concept actually represents the application name. Rename all Java fields, API parameters, and frontend types from groupName→applicationName and group→application for clarity. - Java records: ExecutionSummary, ExecutionDetail, ExecutionDocument, ExecutionRecord, ProcessorRecord - API params: SearchRequest.group→application, SearchController @RequestParam group→application - Services: IngestionService, DetailService, SearchIndexer, StatsStore - Frontend: schema.d.ts, Dashboard, ExchangeDetail, RouteDetail, executions query hooks Database column names (group_name) and OpenSearch field names are unchanged — only the API-facing Java/TS field names are renamed. RBAC group references (groups table, GroupRepository, GroupsTab) are a separate domain concept and are NOT affected by this change. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
15 KiB
RBAC Management — Design Specification
Goal
Implement a full RBAC management system (issue #41) with group hierarchy, role inheritance, and a management UI integrated into the admin section. Replace the flat users.roles text array with a proper relational model.
References
- Functional spec:
examples/RBAC/rbac-ui-spec.md - Visual prototype:
examples/RBAC/rbac_management_ui.html
Backend
Database Schema
Squash V1–V10 Flyway migrations into a single V1__init.sql. The users table drops the roles TEXT[] column. New tables:
CREATE TABLE users (
user_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
email TEXT,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- RBAC: all roles — system roles seeded with fixed UUIDs, custom roles created by admins
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'custom',
system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed system roles with fixed UUIDs (stable across environments)
INSERT INTO roles (id, name, description, scope, system) VALUES
('00000000-0000-0000-0000-000000000001', 'AGENT', 'Agent registration and data ingestion', 'system-wide', true),
('00000000-0000-0000-0000-000000000002', 'VIEWER', 'Read-only access to dashboards and data', 'system-wide', true),
('00000000-0000-0000-0000-000000000003', 'OPERATOR', 'Operational commands (start/stop/configure agents)', 'system-wide', true),
('00000000-0000-0000-0000-000000000004', 'ADMIN', 'Full administrative access', 'system-wide', true);
-- RBAC: groups with self-referential hierarchy
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Join: roles assigned to groups (system + custom)
CREATE TABLE group_roles (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, role_id)
);
-- Join: direct group membership for users
CREATE TABLE user_groups (
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
-- Join: direct role assignments to users (system + custom)
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- Indexes for join query performance
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX idx_user_groups_user_id ON user_groups(user_id);
CREATE INDEX idx_group_roles_group_id ON group_roles(group_id);
CREATE INDEX idx_groups_parent ON groups(parent_group_id);
Note: roles TEXT[] column is removed from users. All roles (system and custom) live in the roles table. System roles are seeded rows with system = true and fixed UUIDs — the application prevents their deletion or modification.
System Roles
The four system roles (AGENT, VIEWER, OPERATOR, ADMIN) are:
- Seeded as rows in the
rolestable withsystem = trueand fixed UUIDs - Assigned to users via the same
user_rolesjoin table as custom roles - Protected by application logic: creation, deletion, and name/scope modification are rejected
- Displayed in the UI as read-only entries (lock icon, non-deletable)
- Used by Spring Security / JWT for authorization decisions
Custom roles (system = false) are application-defined and have no effect on Spring Security — they serve the RBAC management model only (for future permission expansion).
The scope field distinguishes role domains: system roles use system-wide, custom roles can use descriptive scopes like monitoring:read, config:write for future permission gating.
Domain Model (Java)
// Existing, modified — drop roles field
public record UserInfo(
String userId,
String provider,
String email,
String displayName,
Instant createdAt
) {}
// New — enriched user for admin API responses
public record UserDetail(
String userId,
String provider,
String email,
String displayName,
Instant createdAt,
List<RoleSummary> directRoles, // from user_roles join (system + custom)
List<GroupSummary> directGroups, // from user_groups join
List<RoleSummary> effectiveRoles, // computed: union of direct + inherited via groups
List<GroupSummary> effectiveGroups // computed: direct groups + their ancestor chain
) {}
public record GroupDetail(
UUID id,
String name,
UUID parentGroupId, // nullable
Instant createdAt,
List<RoleSummary> directRoles,
List<RoleSummary> effectiveRoles, // direct + inherited from parent chain
List<UserSummary> members, // direct members
List<GroupSummary> childGroups
) {}
public record RoleDetail(
UUID id,
String name,
String description,
String scope,
boolean system, // true for AGENT/VIEWER/OPERATOR/ADMIN
Instant createdAt,
List<GroupSummary> assignedGroups,
List<UserSummary> directUsers,
List<UserSummary> effectivePrincipals // all users who hold this role
) {}
// Summaries for embedding in detail responses
public record UserSummary(String userId, String displayName, String provider) {}
public record GroupSummary(UUID id, String name) {}
public record RoleSummary(UUID id, String name, boolean system, String source) {}
// source: "direct" | group name (for inherited)
Inheritance Logic
Server-side computation in a service class (e.g., RbacService):
- Effective groups for user: Start from
user_groups(direct memberships), then for each group walkparent_group_idchain upward to collect all ancestor groups. The union is every group the user is transitively a member of. - Effective roles for user: Direct
user_roles+ allgroup_rolesfor every effective group. Both system and custom roles flow through the same path. - Effective roles for group: Direct
group_roles+ inherited from parent chain. - Effective principals for role: All users who hold the role directly + all users in any group that has the role (transitively).
No role negation — roles only grant, never deny.
Cycle detection: When setting parent_group_id on a group, the application must walk the proposed parent chain upward and reject the update if it would create a cycle (i.e., the group appears in its own ancestor chain). Return HTTP 409 Conflict.
Auth Integration
JwtService and SecurityConfig read system roles from user_roles joined to roles WHERE system = true, instead of users.roles. The UserRepository methods that currently read/write users.roles are updated to use the join table. JWT claims remain unchanged (roles: ["ADMIN", "VIEWER"]).
OIDC auto-signup: When a user is auto-registered via OIDC token exchange, they get a row in users with provider = "oidc:<issuer>" and a default system role (VIEWER) via user_roles. No group membership by default.
API Endpoints
All under /api/v1/admin/ prefix, protected by @PreAuthorize("hasRole('ADMIN')").
The existing PUT /users/{userId}/roles bulk endpoint is removed. Role assignments use individual add/remove endpoints.
All mutation endpoints log to the AuditService (category: USER_MGMT for user operations, RBAC for group/role operations).
Users — response type: UserDetail
| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | /users |
List all users with effective roles/groups | — |
| GET | /users/{id} |
Full user detail | — |
| POST | /users/{id}/roles/{roleId} |
Assign role to user (system or custom) | — |
| DELETE | /users/{id}/roles/{roleId} |
Remove role from user | — |
| POST | /users/{id}/groups/{groupId} |
Add user to group | — |
| DELETE | /users/{id}/groups/{groupId} |
Remove user from group | — |
| DELETE | /users/{id} |
Delete user | — |
Groups — response type: GroupDetail
| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | /groups |
List all groups with hierarchy | — |
| GET | /groups/{id} |
Full group detail | — |
| POST | /groups |
Create group | { name, parentGroupId? } |
| PUT | /groups/{id} |
Update group | { name?, parentGroupId? } — returns 409 on cycle |
| DELETE | /groups/{id} |
Delete group — cascades role/member associations; child groups become top-level (parent set to null) | — |
| POST | /groups/{id}/roles/{roleId} |
Assign role to group | — |
| DELETE | /groups/{id}/roles/{roleId} |
Remove role from group | — |
Roles — response type: RoleDetail
| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | /roles |
List all roles (system + custom) | — |
| GET | /roles/{id} |
Role detail | — |
| POST | /roles |
Create custom role | { name, description?, scope? } |
| PUT | /roles/{id} |
Update custom role (rejects system roles) | { name?, description?, scope? } |
| DELETE | /roles/{id} |
Delete custom role (rejects system roles) | — |
Dashboard:
| Method | Path | Description |
|---|---|---|
| GET | /rbac/stats |
{ userCount, activeUserCount, groupCount, maxGroupDepth, roleCount } |
Frontend
Routing
New route at /admin/rbac in router.tsx, lazy-loaded:
const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage })));
// ...
{ path: 'admin/rbac', element: <Suspense fallback={null}><RbacPage /></Suspense> }
Update AppSidebar ADMIN_LINKS to add { to: '/admin/rbac', label: 'User Management' }.
Component Structure
pages/admin/rbac/
├── RbacPage.tsx ← ADMIN role gate + tab navigation
├── RbacPage.module.css ← All RBAC-specific styles
├── DashboardTab.tsx ← Stat cards + inheritance diagram
├── UsersTab.tsx ← Split pane orchestrator
├── GroupsTab.tsx ← Split pane orchestrator
├── RolesTab.tsx ← Split pane orchestrator
├── components/
│ ├── EntityListPane.tsx ← Reusable: search input + scrollable card list
│ ├── EntityCard.tsx ← Single list row: avatar, name, meta, tags, status dot
│ ├── UserDetail.tsx ← Header, fields, groups, effective roles, group tree
│ ├── GroupDetail.tsx ← Header, fields, members, children, roles, hierarchy
│ ├── RoleDetail.tsx ← Header, fields, assigned groups/users, effective principals
│ ├── InheritanceChip.tsx ← Chip with dashed border + "↑ Source" annotation
│ ├── GroupTree.tsx ← Indented tree with corner connectors
│ ├── EntityAvatar.tsx ← Circle (user), rounded-square (group/role), color by type
│ ├── OidcBadge.tsx ← Small badge showing OIDC provider origin
│ ├── InheritanceDiagram.tsx ← Three-column Groups→Roles→Users read-only diagram
│ └── InheritanceNote.tsx ← Green-bordered explanation block
api/queries/admin/
│ └── rbac.ts ← useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats + mutation hooks
Tab Navigation
RbacPage uses a horizontal tab bar (Dashboard | Users | Groups | Roles) with URL-synced active state via query parameter (?tab=users). Each tab renders its content below the tab bar in the full main panel area.
Split Pane Layout
Users, Groups, and Roles tabs share the same layout:
- Left (52%):
EntityListPanewith search input + scrollable entity cards - Right (48%): Detail pane showing selected entity, or empty state prompt
- Resizable via
ResizableDivider(existing shared component)
Entity Card Patterns
User card: Circle avatar (initials, blue tint) + name + email/primary-group meta + role tags (amber) + group tags (green) + status dot + OIDC badge if provider !== "local"
Group card: Rounded-square avatar (initials, green/amber/red by domain) + name + parent/member-count meta + role tags (direct solid, inherited faded+italic)
Role card: Rounded-square avatar (initials, amber tint) + name + description/assignment-count meta + assigned-to tags. System roles show a lock icon.
Badge/Chip Styling
Following the spec and existing CSS token system:
| Chip type | Background | Border | Text |
|---|---|---|---|
| Role (direct) | var(--amber-dim) |
solid var(--amber) |
amber text |
| Role (inherited) | transparent | dashed var(--amber) |
faded amber, italic |
| Group | var(--green-dim) / #E1F5EE |
solid green | green text |
| OIDC badge | var(--cyan-dim) |
solid cyan | cyan text, shows provider |
| System role | Same as role but with lock icon | — | — |
Inherited role chips include ↑ GroupName annotation in the detail pane.
OIDC Badge
Displayed on user cards and user detail when provider !== "local". Shows a small cyan-tinted pill with the provider name (e.g., "OIDC" or the issuer hostname). Positioned after the user's name in the card, and as a field in the detail pane.
Search
Client-side filtering on entity list panes — filter by any visible text (name, email, group, role). Sufficient for the expected user count.
State Management
- React Query for all server state (users, groups, roles, stats)
- Local
useStatefor selected entity, search filter, active tab - Mutations invalidate related queries (e.g., updating a user's groups invalidates both user and group queries)
Migration Strategy
- Delete all V1–V10 migration files
- Create single
V1__init.sqlcontaining the full consolidated schema - Deployed environments: drop and recreate the database (data loss accepted)
- CI/CD: no special handling — clean database on deploy
- Update
application.ymlif needed:spring.flyway.clean-on-validation-error: trueor manual DB drop
Out of Scope
- Permission-based access control (custom roles don't gate endpoints — system roles do)
- Audit log panel within RBAC (existing audit log page covers this)
- Bulk import/export of users or groups
- SCIM provisioning
- Role negation / deny rules