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>
328 lines
15 KiB
Markdown
328 lines
15 KiB
Markdown
# 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<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`):
|
||
|
||
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:<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:
|
||
|
||
```tsx
|
||
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%)**: `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
|