# 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: ```sql 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 `roles` table with `system = true` and fixed UUIDs - Assigned to users via the same `user_roles` join 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) ```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 directRoles, // from user_roles join (system + custom) List directGroups, // from user_groups join List effectiveRoles, // computed: union of direct + inherited via groups List effectiveGroups // computed: direct groups + their ancestor chain ) {} public record GroupDetail( UUID id, String name, UUID parentGroupId, // nullable Instant createdAt, List directRoles, List effectiveRoles, // direct + inherited from parent chain List members, // direct members List childGroups ) {} public record RoleDetail( UUID id, String name, String description, String scope, boolean system, // true for AGENT/VIEWER/OPERATOR/ADMIN Instant createdAt, List assignedGroups, List directUsers, List 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`): 1. **Effective groups for user**: Start from `user_groups` (direct memberships), then for each group walk `parent_group_id` chain upward to collect all ancestor groups. The union is every group the user is transitively a member of. 2. **Effective roles for user**: Direct `user_roles` + all `group_roles` for every effective group. Both system and custom roles flow through the same path. 3. **Effective roles for group**: Direct `group_roles` + inherited from parent chain. 4. **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:"` 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: ```tsx const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage }))); // ... { path: 'admin/rbac', element: } ``` 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%)**: `EntityListPane` with 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 `useState` for 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 1. Delete all V1–V10 migration files 2. Create single `V1__init.sql` containing the full consolidated schema 3. Deployed environments: drop and recreate the database (data loss accepted) 4. CI/CD: no special handling — clean database on deploy 5. Update `application.yml` if needed: `spring.flyway.clean-on-validation-error: true` or 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