Files
cameleer-server/docs/superpowers/plans/2026-03-17-rbac-management.md
hsiegeln cb3ebfea7c
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from com.cameleer3 to com.cameleer, module
directories from cameleer3-* to cameleer-*, and all references
throughout workflows, Dockerfiles, docs, migrations, and pom.xml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:42 +02:00

2393 lines
87 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RBAC Management Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace flat `users.roles` text array with a full RBAC system — groups, roles, inheritance — plus a management UI at `/admin/rbac`.
**Architecture:** Consolidated Flyway migration (`V1__init.sql`), new RBAC domain records and `RbacService` for inheritance computation, three new admin controllers, React UI with tab-based navigation and split-pane entity views.
**Tech Stack:** Java 17, Spring Boot 3.4, PostgreSQL/TimescaleDB, Flyway, React 19, TypeScript, TanStack Query, CSS Modules.
**Spec:** `docs/superpowers/specs/2026-03-17-rbac-management-design.md`
---
## File Map
### Backend — New Files
| File | Responsibility |
|---|---|
| `cameleer-server-app/src/main/resources/db/migration/V1__init.sql` | Consolidated schema (replaces V1V10) |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacService.java` | Inheritance computation interface |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/GroupRepository.java` | Group CRUD interface |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RoleRepository.java` | Role CRUD interface |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/UserDetail.java` | Enriched user record |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/GroupDetail.java` | Group detail record |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RoleDetail.java` | Role detail record |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/UserSummary.java` | Embedded user ref |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/GroupSummary.java` | Embedded group ref |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RoleSummary.java` | Embedded role ref |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacStats.java` | Dashboard stats record |
| `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/SystemRole.java` | System role constants + fixed UUIDs |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresGroupRepository.java` | Group repository impl |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresRoleRepository.java` | Role repository impl |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java` | Inheritance computation impl |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/GroupAdminController.java` | Group CRUD endpoints |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RoleAdminController.java` | Role CRUD endpoints |
| `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RbacStatsController.java` | Dashboard stats endpoint |
### Backend — Modified Files
| File | Change |
|---|---|
| `cameleer-server-core/.../security/UserInfo.java` | Remove `roles` field |
| `cameleer-server-core/.../security/UserRepository.java` | Remove `updateRoles`, add `findSystemRoles` |
| `cameleer-server-core/.../admin/AuditCategory.java` | Add `RBAC` category |
| `cameleer-server-app/.../storage/PostgresUserRepository.java` | Rewrite for new schema (no roles column, use user_roles join) |
| `cameleer-server-app/.../controller/UserAdminController.java` | Rewrite: use RbacService, new endpoints |
| `cameleer-server-app/.../security/UiAuthController.java` | Use user_roles instead of UserInfo.roles |
| `cameleer-server-app/.../security/OidcAuthController.java` | Use user_roles for role resolution |
| `cameleer-server-app/.../security/JwtAuthenticationFilter.java` | No change (reads roles from JWT, not DB) |
| `cameleer-server-app/.../security/AgentRegistrationController.java` | Use user_roles for AGENT role |
| `cameleer-server-app/src/test/.../TestSecurityHelper.java` | No change (creates JWT directly) |
### Backend — Deleted Files
| File |
|---|
| `cameleer-server-app/src/main/resources/db/migration/V2__executions.sql` |
| `cameleer-server-app/src/main/resources/db/migration/V3__processor_executions.sql` |
| `cameleer-server-app/src/main/resources/db/migration/V4__agent_metrics.sql` |
| `cameleer-server-app/src/main/resources/db/migration/V5__route_diagrams.sql` |
| `cameleer-server-app/src/main/resources/db/migration/V6__users.sql` |
| `cameleer-server-app/src/main/resources/db/migration/V7__oidc_config.sql` |
| `cameleer-server-app/src/main/resources/db/migration/V8__continuous_aggregates.sql` |
| `cameleer-server-app/src/main/resources/db/migration/V9__admin_thresholds.sql` |
| `cameleer-server-app/src/main/resources/db/migration/V10__audit_log.sql` |
### Frontend — New Files
| File | Responsibility |
|---|---|
| `ui/src/api/queries/admin/rbac.ts` | React Query hooks + mutation hooks |
| `ui/src/pages/admin/rbac/RbacPage.tsx` | ADMIN gate + tab navigation |
| `ui/src/pages/admin/rbac/RbacPage.module.css` | All RBAC styles |
| `ui/src/pages/admin/rbac/DashboardTab.tsx` | Stats + inheritance diagram |
| `ui/src/pages/admin/rbac/UsersTab.tsx` | User split pane |
| `ui/src/pages/admin/rbac/GroupsTab.tsx` | Group split pane |
| `ui/src/pages/admin/rbac/RolesTab.tsx` | Role split pane |
| `ui/src/pages/admin/rbac/components/EntityListPane.tsx` | Reusable filtered list |
| `ui/src/pages/admin/rbac/components/EntityCard.tsx` | List row component |
| `ui/src/pages/admin/rbac/components/EntityAvatar.tsx` | Avatar (circle/square) |
| `ui/src/pages/admin/rbac/components/UserDetail.tsx` | User detail pane |
| `ui/src/pages/admin/rbac/components/GroupDetail.tsx` | Group detail pane |
| `ui/src/pages/admin/rbac/components/RoleDetail.tsx` | Role detail pane |
| `ui/src/pages/admin/rbac/components/InheritanceChip.tsx` | Dashed chip + source |
| `ui/src/pages/admin/rbac/components/GroupTree.tsx` | Indented hierarchy |
| `ui/src/pages/admin/rbac/components/OidcBadge.tsx` | OIDC provider badge |
| `ui/src/pages/admin/rbac/components/InheritanceDiagram.tsx` | Dashboard diagram |
| `ui/src/pages/admin/rbac/components/InheritanceNote.tsx` | Explanation block |
### Frontend — Modified Files
| File | Change |
|---|---|
| `ui/src/router.tsx` | Add `/admin/rbac` lazy route |
| `ui/src/components/layout/AppSidebar.tsx` | Add "User Management" to ADMIN_LINKS |
---
## Tasks
### Task 1: Consolidate Flyway Migrations
**Files:**
- Create: `cameleer-server-app/src/main/resources/db/migration/V1__init.sql`
- Delete: `V1__extensions.sql` through `V10__audit_log.sql` (10 files)
- [ ] **Step 1: Create consolidated V1__init.sql**
Combine all existing migration content (V1V10) into a single file, replacing the `users` table definition to drop `roles TEXT[]`, and adding the new RBAC tables. Order: extensions → users (new) → roles (with seeds) → groups → join tables → executions → processor_executions → agent_metrics → route_diagrams → oidc_config → continuous_aggregates → admin_thresholds → audit_log → indexes.
```sql
-- V1__init.sql — Consolidated schema for Cameleer
-- Extensions
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS timescaledb_toolkit;
-- ═══════════════════════════════════════════════════════════
-- RBAC
-- ═══════════════════════════════════════════════════════════
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()
);
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()
);
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);
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()
);
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)
);
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)
);
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)
);
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);
-- ═══════════════════════════════════════════════════════════
-- Execution data (TimescaleDB hypertables)
-- ═══════════════════════════════════════════════════════════
CREATE TABLE executions (
execution_id TEXT NOT NULL,
route_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
group_name TEXT NOT NULL,
status TEXT NOT NULL,
correlation_id TEXT,
exchange_id TEXT,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_ms BIGINT,
error_message TEXT,
error_stacktrace TEXT,
diagram_content_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (execution_id, start_time)
);
SELECT create_hypertable('executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
CREATE INDEX idx_executions_group_time ON executions (group_name, start_time DESC);
CREATE INDEX idx_executions_correlation ON executions (correlation_id);
CREATE TABLE processor_executions (
id BIGSERIAL,
execution_id TEXT NOT NULL,
processor_id TEXT NOT NULL,
processor_type TEXT NOT NULL,
diagram_node_id TEXT,
group_name TEXT NOT NULL,
route_id TEXT NOT NULL,
depth INT NOT NULL,
parent_processor_id TEXT,
status TEXT NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_ms BIGINT,
error_message TEXT,
error_stacktrace TEXT,
input_body TEXT,
output_body TEXT,
input_headers JSONB,
output_headers JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (execution_id, processor_id, start_time)
);
SELECT create_hypertable('processor_executions', 'start_time', chunk_time_interval => INTERVAL '1 day');
CREATE INDEX idx_proc_exec_execution ON processor_executions (execution_id);
CREATE INDEX idx_proc_exec_type_time ON processor_executions (processor_type, start_time DESC);
-- ═══════════════════════════════════════════════════════════
-- Agent metrics
-- ═══════════════════════════════════════════════════════════
CREATE TABLE agent_metrics (
agent_id TEXT NOT NULL,
metric_name TEXT NOT NULL,
metric_value DOUBLE PRECISION NOT NULL,
tags JSONB,
collected_at TIMESTAMPTZ NOT NULL,
server_received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
SELECT create_hypertable('agent_metrics', 'collected_at', chunk_time_interval => INTERVAL '1 day');
CREATE INDEX idx_metrics_agent_name ON agent_metrics (agent_id, metric_name, collected_at DESC);
-- ═══════════════════════════════════════════════════════════
-- Route diagrams
-- ═══════════════════════════════════════════════════════════
CREATE TABLE route_diagrams (
content_hash TEXT PRIMARY KEY,
route_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
definition TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
-- ═══════════════════════════════════════════════════════════
-- OIDC configuration
-- ═══════════════════════════════════════════════════════════
CREATE TABLE oidc_config (
config_id TEXT PRIMARY KEY DEFAULT 'default',
enabled BOOLEAN NOT NULL DEFAULT false,
issuer_uri TEXT,
client_id TEXT,
client_secret TEXT,
roles_claim TEXT,
default_roles TEXT[] NOT NULL DEFAULT '{}',
auto_signup BOOLEAN DEFAULT false,
display_name_claim TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ═══════════════════════════════════════════════════════════
-- Continuous aggregates
-- ═══════════════════════════════════════════════════════════
CREATE MATERIALIZED VIEW stats_1m_all
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT
time_bucket('1 minute', start_time) AS bucket,
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
SUM(duration_ms) AS duration_sum,
MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM executions
WHERE status IS NOT NULL
GROUP BY bucket
WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_all',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
CREATE MATERIALIZED VIEW stats_1m_app
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT
time_bucket('1 minute', start_time) AS bucket,
group_name,
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
SUM(duration_ms) AS duration_sum,
MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM executions
WHERE status IS NOT NULL
GROUP BY bucket, group_name
WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_app',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
CREATE MATERIALIZED VIEW stats_1m_route
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT
time_bucket('1 minute', start_time) AS bucket,
group_name,
route_id,
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
SUM(duration_ms) AS duration_sum,
MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM executions
WHERE status IS NOT NULL
GROUP BY bucket, group_name, route_id
WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_route',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
CREATE MATERIALIZED VIEW stats_1m_processor
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT
time_bucket('1 minute', start_time) AS bucket,
group_name,
route_id,
processor_type,
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
SUM(duration_ms) AS duration_sum,
MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM processor_executions
GROUP BY bucket, group_name, route_id, processor_type
WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_processor',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
-- ═══════════════════════════════════════════════════════════
-- Admin
-- ═══════════════════════════════════════════════════════════
CREATE TABLE admin_thresholds (
id INTEGER PRIMARY KEY DEFAULT 1,
config JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT NOT NULL,
CONSTRAINT single_row CHECK (id = 1)
);
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
username TEXT NOT NULL,
action TEXT NOT NULL,
category TEXT NOT NULL,
target TEXT,
detail JSONB,
result TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
CREATE INDEX idx_audit_log_username ON audit_log (username);
CREATE INDEX idx_audit_log_category ON audit_log (category);
CREATE INDEX idx_audit_log_action ON audit_log (action);
CREATE INDEX idx_audit_log_target ON audit_log (target);
```
- [ ] **Step 2: Delete old migration files**
```bash
cd cameleer-server-app/src/main/resources/db/migration
rm V2__executions.sql V3__processor_executions.sql V4__agent_metrics.sql V5__route_diagrams.sql V6__users.sql V7__oidc_config.sql V8__continuous_aggregates.sql V9__admin_thresholds.sql V10__audit_log.sql
# Keep V1__extensions.sql but it's been replaced by V1__init.sql — rename the old one first
mv V1__extensions.sql V1__extensions.sql.bak
```
Actually: the new file is `V1__init.sql`. Flyway requires unique version numbers. Since the old `V1__extensions.sql` exists, rename the new file to match:
Delete ALL old files (V1 through V10). The single new `V1__init.sql` replaces everything.
```bash
rm V1__extensions.sql V2__executions.sql V3__processor_executions.sql V4__agent_metrics.sql V5__route_diagrams.sql V6__users.sql V7__oidc_config.sql V8__continuous_aggregates.sql V9__admin_thresholds.sql V10__audit_log.sql
```
- [ ] **Step 3: Verify the migration compiles**
```bash
cd /c/Users/Hendrik/Documents/projects/cameleer-server
mvn clean compile -pl cameleer-server-app
```
Expected: BUILD SUCCESS (Flyway doesn't run at compile time, just packaging)
- [ ] **Step 4: Commit**
```bash
git add -A cameleer-server-app/src/main/resources/db/migration/
git commit -m "refactor: consolidate V1-V10 Flyway migrations into single V1__init.sql
Add RBAC tables (roles, groups, group_roles, user_groups, user_roles)
with system role seeds and join indexes. Drop users.roles TEXT[] column."
```
---
### Task 2: RBAC Domain Model (core module)
**Files:**
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/SystemRole.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/UserDetail.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/GroupDetail.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RoleDetail.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/UserSummary.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/GroupSummary.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RoleSummary.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacStats.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/GroupRepository.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RoleRepository.java`
- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacService.java`
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserInfo.java`
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java`
- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java`
- [ ] **Step 1: Create SystemRole constants**
```java
package com.cameleer.server.core.rbac;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
public final class SystemRole {
private SystemRole() {}
public static final UUID AGENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
public static final UUID VIEWER_ID = UUID.fromString("00000000-0000-0000-0000-000000000002");
public static final UUID OPERATOR_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
public static final UUID ADMIN_ID = UUID.fromString("00000000-0000-0000-0000-000000000004");
public static final Set<UUID> IDS = Set.of(AGENT_ID, VIEWER_ID, OPERATOR_ID, ADMIN_ID);
public static final Map<String, UUID> BY_NAME = Map.of(
"AGENT", AGENT_ID,
"VIEWER", VIEWER_ID,
"OPERATOR", OPERATOR_ID,
"ADMIN", ADMIN_ID
);
public static boolean isSystem(UUID id) {
return IDS.contains(id);
}
}
```
- [ ] **Step 2: Create summary records**
`UserSummary.java`:
```java
package com.cameleer.server.core.rbac;
public record UserSummary(String userId, String displayName, String provider) {}
```
`GroupSummary.java`:
```java
package com.cameleer.server.core.rbac;
import java.util.UUID;
public record GroupSummary(UUID id, String name) {}
```
`RoleSummary.java`:
```java
package com.cameleer.server.core.rbac;
import java.util.UUID;
public record RoleSummary(UUID id, String name, boolean system, String source) {}
```
`RbacStats.java`:
```java
package com.cameleer.server.core.rbac;
public record RbacStats(int userCount, int activeUserCount, int groupCount, int maxGroupDepth, int roleCount) {}
```
- [ ] **Step 3: Create detail records**
`UserDetail.java`:
```java
package com.cameleer.server.core.rbac;
import java.time.Instant;
import java.util.List;
public record UserDetail(
String userId,
String provider,
String email,
String displayName,
Instant createdAt,
List<RoleSummary> directRoles,
List<GroupSummary> directGroups,
List<RoleSummary> effectiveRoles,
List<GroupSummary> effectiveGroups
) {}
```
`GroupDetail.java`:
```java
package com.cameleer.server.core.rbac;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record GroupDetail(
UUID id,
String name,
UUID parentGroupId,
Instant createdAt,
List<RoleSummary> directRoles,
List<RoleSummary> effectiveRoles,
List<UserSummary> members,
List<GroupSummary> childGroups
) {}
```
`RoleDetail.java`:
```java
package com.cameleer.server.core.rbac;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record RoleDetail(
UUID id,
String name,
String description,
String scope,
boolean system,
Instant createdAt,
List<GroupSummary> assignedGroups,
List<UserSummary> directUsers,
List<UserSummary> effectivePrincipals
) {}
```
- [ ] **Step 4: Create repository interfaces**
`GroupRepository.java`:
```java
package com.cameleer.server.core.rbac;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface GroupRepository {
List<GroupSummary> findAll();
Optional<GroupDetail> findById(UUID id);
UUID create(String name, UUID parentGroupId);
void update(UUID id, String name, UUID parentGroupId);
void delete(UUID id);
void addRole(UUID groupId, UUID roleId);
void removeRole(UUID groupId, UUID roleId);
List<GroupSummary> findChildGroups(UUID parentId);
List<GroupSummary> findAncestorChain(UUID groupId);
}
```
`RoleRepository.java`:
```java
package com.cameleer.server.core.rbac;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface RoleRepository {
List<RoleDetail> findAll();
Optional<RoleDetail> findById(UUID id);
UUID create(String name, String description, String scope);
void update(UUID id, String name, String description, String scope);
void delete(UUID id);
}
```
`RbacService.java`:
```java
package com.cameleer.server.core.rbac;
import java.util.List;
import java.util.UUID;
public interface RbacService {
List<UserDetail> listUsers();
UserDetail getUser(String userId);
void assignRoleToUser(String userId, UUID roleId);
void removeRoleFromUser(String userId, UUID roleId);
void addUserToGroup(String userId, UUID groupId);
void removeUserFromGroup(String userId, UUID groupId);
List<RoleSummary> getEffectiveRolesForUser(String userId);
List<GroupSummary> getEffectiveGroupsForUser(String userId);
List<RoleSummary> getEffectiveRolesForGroup(UUID groupId);
List<UserSummary> getEffectivePrincipalsForRole(UUID roleId);
/** Returns system role names for JWT/auth — only system roles. */
List<String> getSystemRoleNames(String userId);
RbacStats getStats();
}
```
- [ ] **Step 5: Update UserInfo — remove roles field**
In `cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserInfo.java`, change to:
```java
package com.cameleer.server.core.security;
import java.time.Instant;
public record UserInfo(
String userId,
String provider,
String email,
String displayName,
Instant createdAt
) {}
```
- [ ] **Step 6: Update UserRepository interface**
In `cameleer-server-core/src/main/java/com/cameleer/server/core/security/UserRepository.java`, change to:
```java
package com.cameleer.server.core.security;
import java.util.List;
import java.util.Optional;
public interface UserRepository {
Optional<UserInfo> findById(String userId);
List<UserInfo> findAll();
void upsert(UserInfo user);
void delete(String userId);
}
```
Remove `updateRoles` method (role management moves to `RbacService`).
- [ ] **Step 7: Add RBAC audit category**
In `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java`, add `RBAC`:
```java
public enum AuditCategory {
INFRA, AUTH, USER_MGMT, CONFIG, RBAC
}
```
- [ ] **Step 8: Verify core module compiles**
```bash
mvn clean compile -pl cameleer-server-core
```
Expected: BUILD SUCCESS
**Do NOT commit individually** — Tasks 26 form an atomic batch. Commit after Task 6.
---
### Task 3: PostgresUserRepository — Adapt to New Schema
**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java`
- [ ] **Step 1: Rewrite PostgresUserRepository**
The `upsert` no longer writes roles (no `roles` column). The `mapUser` no longer reads a roles array. Remove `updateRoles` method.
```java
package com.cameleer.server.app.storage;
import com.cameleer.server.core.security.UserInfo;
import com.cameleer.server.core.security.UserRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
@Repository
public class PostgresUserRepository implements UserRepository {
private final JdbcTemplate jdbc;
public PostgresUserRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public Optional<UserInfo> findById(String userId) {
var list = jdbc.query(
"SELECT user_id, provider, email, display_name, created_at FROM users WHERE user_id = ?",
(rs, _) -> mapUser(rs), userId);
return list.isEmpty() ? Optional.empty() : Optional.of(list.getFirst());
}
@Override
public List<UserInfo> findAll() {
return jdbc.query(
"SELECT user_id, provider, email, display_name, created_at FROM users ORDER BY user_id",
(rs, _) -> mapUser(rs));
}
@Override
public void upsert(UserInfo user) {
jdbc.update("""
INSERT INTO users (user_id, provider, email, display_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, now())
ON CONFLICT (user_id) DO UPDATE SET
provider = EXCLUDED.provider,
email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
updated_at = now()
""",
user.userId(), user.provider(), user.email(), user.displayName(), user.createdAt());
}
@Override
public void delete(String userId) {
jdbc.update("DELETE FROM users WHERE user_id = ?", userId);
}
private UserInfo mapUser(ResultSet rs) throws SQLException {
return new UserInfo(
rs.getString("user_id"),
rs.getString("provider"),
rs.getString("email"),
rs.getString("display_name"),
rs.getTimestamp("created_at").toInstant()
);
}
}
```
**Do NOT commit individually** — Tasks 26 form an atomic batch. Commit after Task 6 when the full project compiles.
---
### Task 4: PostgresGroupRepository and PostgresRoleRepository
**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresGroupRepository.java`
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresRoleRepository.java`
- [ ] **Step 1: Implement PostgresGroupRepository**
```java
package com.cameleer.server.app.storage;
import com.cameleer.server.core.rbac.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.*;
@Repository
public class PostgresGroupRepository implements GroupRepository {
private final JdbcTemplate jdbc;
public PostgresGroupRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public List<GroupSummary> findAll() {
return jdbc.query("SELECT id, name FROM groups ORDER BY name",
(rs, _) -> new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")));
}
@Override
public Optional<GroupDetail> findById(UUID id) {
var rows = jdbc.query(
"SELECT id, name, parent_group_id, created_at FROM groups WHERE id = ?",
(rs, _) -> new GroupDetail(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getObject("parent_group_id", UUID.class),
rs.getTimestamp("created_at").toInstant(),
List.of(), List.of(), List.of(), List.of()
), id);
if (rows.isEmpty()) return Optional.empty();
var g = rows.getFirst();
List<RoleSummary> directRoles = jdbc.query("""
SELECT r.id, r.name, r.system FROM group_roles gr
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
""", (rs, _) -> new RoleSummary(rs.getObject("id", UUID.class),
rs.getString("name"), rs.getBoolean("system"), "direct"), id);
List<UserSummary> members = jdbc.query("""
SELECT u.user_id, u.display_name, u.provider FROM user_groups ug
JOIN users u ON u.user_id = ug.user_id WHERE ug.group_id = ?
""", (rs, _) -> new UserSummary(rs.getString("user_id"),
rs.getString("display_name"), rs.getString("provider")), id);
List<GroupSummary> children = findChildGroups(id);
return Optional.of(new GroupDetail(g.id(), g.name(), g.parentGroupId(),
g.createdAt(), directRoles, List.of(), members, children));
}
@Override
public UUID create(String name, UUID parentGroupId) {
UUID id = UUID.randomUUID();
jdbc.update("INSERT INTO groups (id, name, parent_group_id) VALUES (?, ?, ?)",
id, name, parentGroupId);
return id;
}
@Override
public void update(UUID id, String name, UUID parentGroupId) {
jdbc.update("UPDATE groups SET name = COALESCE(?, name), parent_group_id = ? WHERE id = ?",
name, parentGroupId, id);
}
@Override
public void delete(UUID id) {
jdbc.update("DELETE FROM groups WHERE id = ?", id);
}
@Override
public void addRole(UUID groupId, UUID roleId) {
jdbc.update("INSERT INTO group_roles (group_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
groupId, roleId);
}
@Override
public void removeRole(UUID groupId, UUID roleId) {
jdbc.update("DELETE FROM group_roles WHERE group_id = ? AND role_id = ?", groupId, roleId);
}
@Override
public List<GroupSummary> findChildGroups(UUID parentId) {
return jdbc.query("SELECT id, name FROM groups WHERE parent_group_id = ? ORDER BY name",
(rs, _) -> new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
parentId);
}
@Override
public List<GroupSummary> findAncestorChain(UUID groupId) {
// Walk parent chain iteratively (max depth is small)
List<GroupSummary> chain = new ArrayList<>();
UUID current = groupId;
Set<UUID> visited = new HashSet<>();
while (current != null && visited.add(current)) {
UUID id = current;
var rows = jdbc.query(
"SELECT id, name, parent_group_id FROM groups WHERE id = ?",
(rs, _) -> new Object[]{
new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
rs.getObject("parent_group_id", UUID.class)
}, id);
if (rows.isEmpty()) break;
chain.add((GroupSummary) rows.getFirst()[0]);
current = (UUID) rows.getFirst()[1];
}
Collections.reverse(chain); // root first
return chain;
}
}
```
- [ ] **Step 2: Implement PostgresRoleRepository**
```java
package com.cameleer.server.app.storage;
import com.cameleer.server.core.rbac.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.*;
@Repository
public class PostgresRoleRepository implements RoleRepository {
private final JdbcTemplate jdbc;
public PostgresRoleRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public List<RoleDetail> findAll() {
return jdbc.query("""
SELECT id, name, description, scope, system, created_at FROM roles ORDER BY system DESC, name
""", (rs, _) -> new RoleDetail(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("description"),
rs.getString("scope"),
rs.getBoolean("system"),
rs.getTimestamp("created_at").toInstant(),
List.of(), List.of(), List.of()
));
}
@Override
public Optional<RoleDetail> findById(UUID id) {
var rows = jdbc.query("""
SELECT id, name, description, scope, system, created_at FROM roles WHERE id = ?
""", (rs, _) -> new RoleDetail(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("description"),
rs.getString("scope"),
rs.getBoolean("system"),
rs.getTimestamp("created_at").toInstant(),
List.of(), List.of(), List.of()
), id);
if (rows.isEmpty()) return Optional.empty();
var r = rows.getFirst();
List<GroupSummary> assignedGroups = jdbc.query("""
SELECT g.id, g.name FROM group_roles gr
JOIN groups g ON g.id = gr.group_id WHERE gr.role_id = ?
""", (rs, _) -> new GroupSummary(rs.getObject("id", UUID.class),
rs.getString("name")), id);
List<UserSummary> directUsers = jdbc.query("""
SELECT u.user_id, u.display_name, u.provider FROM user_roles ur
JOIN users u ON u.user_id = ur.user_id WHERE ur.role_id = ?
""", (rs, _) -> new UserSummary(rs.getString("user_id"),
rs.getString("display_name"), rs.getString("provider")), id);
return Optional.of(new RoleDetail(r.id(), r.name(), r.description(),
r.scope(), r.system(), r.createdAt(), assignedGroups, directUsers, List.of()));
}
@Override
public UUID create(String name, String description, String scope) {
UUID id = UUID.randomUUID();
jdbc.update("INSERT INTO roles (id, name, description, scope, system) VALUES (?, ?, ?, ?, false)",
id, name, description, scope);
return id;
}
@Override
public void update(UUID id, String name, String description, String scope) {
jdbc.update("""
UPDATE roles SET name = COALESCE(?, name), description = COALESCE(?, description),
scope = COALESCE(?, scope) WHERE id = ? AND system = false
""", name, description, scope, id);
}
@Override
public void delete(UUID id) {
jdbc.update("DELETE FROM roles WHERE id = ? AND system = false", id);
}
}
```
- [ ] **Step 3: Verify compile**
```bash
mvn clean compile -pl cameleer-server-app
```
Expected: May still have errors from files referencing `UserInfo.roles()` — those are fixed in Task 6.
**Do NOT commit individually** — Tasks 26 form an atomic batch. Commit after Task 6 when the full project compiles.
---
### Task 5: RbacServiceImpl — Inheritance Logic
**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java`
- [ ] **Step 1: Implement RbacServiceImpl**
This is the core service that computes inheritance. It reads from `user_roles`, `user_groups`, `group_roles`, and `groups` tables to compute effective roles/groups.
```java
package com.cameleer.server.app.rbac;
import com.cameleer.server.core.rbac.*;
import com.cameleer.server.core.security.UserInfo;
import com.cameleer.server.core.security.UserRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class RbacServiceImpl implements RbacService {
private final JdbcTemplate jdbc;
private final UserRepository userRepository;
private final GroupRepository groupRepository;
private final RoleRepository roleRepository;
public RbacServiceImpl(JdbcTemplate jdbc, UserRepository userRepository,
GroupRepository groupRepository, RoleRepository roleRepository) {
this.jdbc = jdbc;
this.userRepository = userRepository;
this.groupRepository = groupRepository;
this.roleRepository = roleRepository;
}
@Override
public List<UserDetail> listUsers() {
return userRepository.findAll().stream()
.map(u -> buildUserDetail(u))
.toList();
}
@Override
public UserDetail getUser(String userId) {
UserInfo user = userRepository.findById(userId).orElse(null);
if (user == null) return null;
return buildUserDetail(user);
}
private UserDetail buildUserDetail(UserInfo user) {
List<RoleSummary> directRoles = getDirectRolesForUser(user.userId());
List<GroupSummary> directGroups = getDirectGroupsForUser(user.userId());
List<RoleSummary> effectiveRoles = getEffectiveRolesForUser(user.userId());
List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(user.userId());
return new UserDetail(user.userId(), user.provider(), user.email(),
user.displayName(), user.createdAt(),
directRoles, directGroups, effectiveRoles, effectiveGroups);
}
@Override
public void assignRoleToUser(String userId, UUID roleId) {
jdbc.update("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
userId, roleId);
}
@Override
public void removeRoleFromUser(String userId, UUID roleId) {
jdbc.update("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", userId, roleId);
}
@Override
public void addUserToGroup(String userId, UUID groupId) {
jdbc.update("INSERT INTO user_groups (user_id, group_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
userId, groupId);
}
@Override
public void removeUserFromGroup(String userId, UUID groupId) {
jdbc.update("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?", userId, groupId);
}
@Override
public List<RoleSummary> getEffectiveRolesForUser(String userId) {
// 1. Direct roles
List<RoleSummary> direct = getDirectRolesForUser(userId);
// 2. Roles inherited from groups
List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(userId);
Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
for (RoleSummary r : direct) {
roleMap.put(r.id(), r);
}
for (GroupSummary group : effectiveGroups) {
List<RoleSummary> groupRoles = jdbc.query("""
SELECT r.id, r.name, r.system FROM group_roles gr
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
""", (rs, _) -> new RoleSummary(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getBoolean("system"),
group.name()
), group.id());
for (RoleSummary r : groupRoles) {
roleMap.putIfAbsent(r.id(), r);
}
}
return new ArrayList<>(roleMap.values());
}
@Override
public List<GroupSummary> getEffectiveGroupsForUser(String userId) {
List<GroupSummary> directGroups = getDirectGroupsForUser(userId);
Set<UUID> visited = new LinkedHashSet<>();
List<GroupSummary> all = new ArrayList<>();
for (GroupSummary g : directGroups) {
collectAncestors(g.id(), visited, all);
}
return all;
}
private void collectAncestors(UUID groupId, Set<UUID> visited, List<GroupSummary> result) {
if (!visited.add(groupId)) return;
var rows = jdbc.query("SELECT id, name, parent_group_id FROM groups WHERE id = ?",
(rs, _) -> new Object[]{
new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
rs.getObject("parent_group_id", UUID.class)
}, groupId);
if (rows.isEmpty()) return;
result.add((GroupSummary) rows.getFirst()[0]);
UUID parentId = (UUID) rows.getFirst()[1];
if (parentId != null) {
collectAncestors(parentId, visited, result);
}
}
@Override
public List<RoleSummary> getEffectiveRolesForGroup(UUID groupId) {
// Direct roles on group
List<RoleSummary> direct = jdbc.query("""
SELECT r.id, r.name, r.system FROM group_roles gr
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
""", (rs, _) -> new RoleSummary(rs.getObject("id", UUID.class),
rs.getString("name"), rs.getBoolean("system"), "direct"), groupId);
// Walk parent chain
Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
for (RoleSummary r : direct) roleMap.put(r.id(), r);
List<GroupSummary> ancestors = groupRepository.findAncestorChain(groupId);
// ancestors includes self — skip self, iterate parents
for (GroupSummary ancestor : ancestors) {
if (ancestor.id().equals(groupId)) continue;
List<RoleSummary> parentRoles = jdbc.query("""
SELECT r.id, r.name, r.system FROM group_roles gr
JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
""", (rs, _) -> new RoleSummary(rs.getObject("id", UUID.class),
rs.getString("name"), rs.getBoolean("system"),
ancestor.name()), ancestor.id());
for (RoleSummary r : parentRoles) roleMap.putIfAbsent(r.id(), r);
}
return new ArrayList<>(roleMap.values());
}
@Override
public List<UserSummary> getEffectivePrincipalsForRole(UUID roleId) {
// Users with direct assignment
Set<String> seen = new LinkedHashSet<>();
List<UserSummary> result = new ArrayList<>();
List<UserSummary> direct = jdbc.query("""
SELECT u.user_id, u.display_name, u.provider FROM user_roles ur
JOIN users u ON u.user_id = ur.user_id WHERE ur.role_id = ?
""", (rs, _) -> new UserSummary(rs.getString("user_id"),
rs.getString("display_name"), rs.getString("provider")), roleId);
for (UserSummary u : direct) {
if (seen.add(u.userId())) result.add(u);
}
// Users in groups that have this role (transitively)
// Find all groups with this role directly
List<UUID> groupsWithRole = jdbc.query(
"SELECT group_id FROM group_roles WHERE role_id = ?",
(rs, _) -> rs.getObject("group_id", UUID.class), roleId);
// For each group, find all descendant groups + self, then their members
Set<UUID> allGroups = new LinkedHashSet<>(groupsWithRole);
for (UUID gid : groupsWithRole) {
collectDescendants(gid, allGroups);
}
for (UUID gid : allGroups) {
List<UserSummary> members = jdbc.query("""
SELECT u.user_id, u.display_name, u.provider FROM user_groups ug
JOIN users u ON u.user_id = ug.user_id WHERE ug.group_id = ?
""", (rs, _) -> new UserSummary(rs.getString("user_id"),
rs.getString("display_name"), rs.getString("provider")), gid);
for (UserSummary u : members) {
if (seen.add(u.userId())) result.add(u);
}
}
return result;
}
private void collectDescendants(UUID groupId, Set<UUID> result) {
List<UUID> children = jdbc.query(
"SELECT id FROM groups WHERE parent_group_id = ?",
(rs, _) -> rs.getObject("id", UUID.class), groupId);
for (UUID child : children) {
if (result.add(child)) {
collectDescendants(child, result);
}
}
}
@Override
public List<String> getSystemRoleNames(String userId) {
// Uses effective roles (direct + inherited via groups) filtered to system roles
return getEffectiveRolesForUser(userId).stream()
.filter(RoleSummary::system)
.map(RoleSummary::name)
.toList();
}
@Override
public RbacStats getStats() {
int userCount = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
// "active" = users who have at least one role assigned
int activeUserCount = jdbc.queryForObject(
"SELECT COUNT(DISTINCT user_id) FROM user_roles", Integer.class);
int groupCount = jdbc.queryForObject("SELECT COUNT(*) FROM groups", Integer.class);
int roleCount = jdbc.queryForObject("SELECT COUNT(*) FROM roles", Integer.class);
int maxDepth = computeMaxGroupDepth();
return new RbacStats(userCount, activeUserCount, groupCount, maxDepth, roleCount);
}
private int computeMaxGroupDepth() {
// Find all root groups and walk down
List<UUID> roots = jdbc.query(
"SELECT id FROM groups WHERE parent_group_id IS NULL",
(rs, _) -> rs.getObject("id", UUID.class));
int max = 0;
for (UUID root : roots) {
max = Math.max(max, measureDepth(root, 1));
}
return max;
}
private int measureDepth(UUID groupId, int currentDepth) {
List<UUID> children = jdbc.query(
"SELECT id FROM groups WHERE parent_group_id = ?",
(rs, _) -> rs.getObject("id", UUID.class), groupId);
if (children.isEmpty()) return currentDepth;
int max = currentDepth;
for (UUID child : children) {
max = Math.max(max, measureDepth(child, currentDepth + 1));
}
return max;
}
private List<RoleSummary> getDirectRolesForUser(String userId) {
return jdbc.query("""
SELECT r.id, r.name, r.system FROM user_roles ur
JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?
""", (rs, _) -> new RoleSummary(rs.getObject("id", UUID.class),
rs.getString("name"), rs.getBoolean("system"), "direct"), userId);
}
private List<GroupSummary> getDirectGroupsForUser(String userId) {
return jdbc.query("""
SELECT g.id, g.name FROM user_groups ug
JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = ?
""", (rs, _) -> new GroupSummary(rs.getObject("id", UUID.class),
rs.getString("name")), userId);
}
}
```
**Do NOT commit individually** — Tasks 26 form an atomic batch. Commit after Task 6 when the full project compiles.
---
### Task 6: Auth Integration — Update Login and OIDC Flows
**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java`
These controllers currently embed roles in `UserInfo` and pass them to JWT creation. After the change, they must:
1. Create/upsert the `UserInfo` (without roles)
2. Assign roles via `RbacService.assignRoleToUser()`
3. Read system roles via `RbacService.getSystemRoleNames()` for JWT creation
- [ ] **Step 1: Update UiAuthController**
Key changes in `login()`:
- Remove `List<String> roles = List.of("ADMIN")`
- Upsert `UserInfo` without roles
- Assign ADMIN role via `rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID)`
- Read roles from `rbacService.getSystemRoleNames(subject)` for JWT
Key changes in `refresh()`:
- Roles still come from the refresh token JWT claims (no DB read needed during refresh)
- No change needed here since refresh preserves existing claims
Read the actual file to determine exact line changes, then apply edits. The `RbacService` and `SystemRole` need to be injected.
- [ ] **Step 2: Update OidcAuthController**
Key changes in `callback()`:
- After upserting `UserInfo` (without roles), resolve roles:
- Existing user: read from `rbacService.getSystemRoleNames(userId)`
- New user with OIDC claims: map claim role names to system role IDs, call `rbacService.assignRoleToUser()` for each
- New user without claims: use `config.defaultRoles()`, map to IDs, assign via rbacService
- Read system roles for JWT from `rbacService.getSystemRoleNames(userId)`
The `resolveRoles` method changes from returning `List<String>` to assigning roles via RbacService and returning system role names.
- [ ] **Step 3: Update AgentRegistrationController**
In `register()`:
- After creating agent user, assign AGENT role: `rbacService.assignRoleToUser(agentId, SystemRole.AGENT_ID)`
- JWT still gets `List.of("AGENT")`
- [ ] **Step 4: Verify full project compiles**
```bash
mvn clean compile
```
Expected: BUILD SUCCESS (all `UserInfo.roles()` references resolved)
- [ ] **Step 5: Commit (atomic — covers Tasks 26)**
This is the single commit for the entire backend RBAC model. Tasks 26 must all be done before committing since intermediate states don't compile.
```bash
git add cameleer-server-core/ cameleer-server-app/src/main/java/
git commit -m "feat: replace flat users.roles with relational RBAC model
New package com.cameleer.server.core.rbac with SystemRole constants,
detail/summary records, GroupRepository, RoleRepository, RbacService.
Remove roles field from UserInfo. Implement PostgresGroupRepository,
PostgresRoleRepository, RbacServiceImpl with inheritance computation.
Update UiAuthController, OidcAuthController, AgentRegistrationController
to assign roles via user_roles table. JWT populated from effective system roles."
```
---
### Task 7: Admin Controllers — Users, Groups, Roles, Stats
**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java`
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/GroupAdminController.java`
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RoleAdminController.java`
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RbacStatsController.java`
- [ ] **Step 1: Rewrite UserAdminController**
```java
package com.cameleer.server.app.controller;
import com.cameleer.server.core.admin.*;
import com.cameleer.server.core.rbac.*;
import com.cameleer.server.core.security.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/v1/admin/users")
@Tag(name = "User Admin", description = "User management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class UserAdminController {
private final RbacService rbacService;
private final UserRepository userRepository;
private final AuditService auditService;
public UserAdminController(RbacService rbacService, UserRepository userRepository,
AuditService auditService) {
this.rbacService = rbacService;
this.userRepository = userRepository;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List all users with effective roles/groups")
public ResponseEntity<List<UserDetail>> listUsers() {
return ResponseEntity.ok(rbacService.listUsers());
}
@GetMapping("/{userId}")
@Operation(summary = "Get user detail")
public ResponseEntity<UserDetail> getUser(@PathVariable String userId) {
UserDetail detail = rbacService.getUser(userId);
if (detail == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(detail);
}
@PostMapping("/{userId}/roles/{roleId}")
@Operation(summary = "Assign role to user")
public ResponseEntity<Void> assignRole(@PathVariable String userId,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
rbacService.assignRoleToUser(userId, roleId);
auditService.log("assign_user_role", AuditCategory.USER_MGMT, userId,
Map.of("roleId", roleId.toString()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{userId}/roles/{roleId}")
@Operation(summary = "Remove role from user")
public ResponseEntity<Void> removeRole(@PathVariable String userId,
@PathVariable UUID roleId,
HttpServletRequest httpRequest) {
rbacService.removeRoleFromUser(userId, roleId);
auditService.log("remove_user_role", AuditCategory.USER_MGMT, userId,
Map.of("roleId", roleId.toString()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@PostMapping("/{userId}/groups/{groupId}")
@Operation(summary = "Add user to group")
public ResponseEntity<Void> addToGroup(@PathVariable String userId,
@PathVariable UUID groupId,
HttpServletRequest httpRequest) {
rbacService.addUserToGroup(userId, groupId);
auditService.log("add_user_group", AuditCategory.USER_MGMT, userId,
Map.of("groupId", groupId.toString()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{userId}/groups/{groupId}")
@Operation(summary = "Remove user from group")
public ResponseEntity<Void> removeFromGroup(@PathVariable String userId,
@PathVariable UUID groupId,
HttpServletRequest httpRequest) {
rbacService.removeUserFromGroup(userId, groupId);
auditService.log("remove_user_group", AuditCategory.USER_MGMT, userId,
Map.of("groupId", groupId.toString()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{userId}")
@Operation(summary = "Delete user")
public ResponseEntity<Void> deleteUser(@PathVariable String userId,
HttpServletRequest httpRequest) {
userRepository.delete(userId);
auditService.log("delete_user", AuditCategory.USER_MGMT, userId,
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
}
```
- [ ] **Step 2: Create GroupAdminController**
```java
package com.cameleer.server.app.controller;
import com.cameleer.server.core.admin.*;
import com.cameleer.server.core.rbac.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/v1/admin/groups")
@Tag(name = "Group Admin", description = "Group management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class GroupAdminController {
private final GroupRepository groupRepository;
private final RbacService rbacService;
private final AuditService auditService;
public GroupAdminController(GroupRepository groupRepository, RbacService rbacService,
AuditService auditService) {
this.groupRepository = groupRepository;
this.rbacService = rbacService;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List all groups with hierarchy")
public ResponseEntity<List<GroupDetail>> listGroups() {
List<GroupSummary> all = groupRepository.findAll();
List<GroupDetail> details = all.stream()
.map(g -> groupRepository.findById(g.id()).orElse(null))
.filter(Objects::nonNull)
.map(g -> new GroupDetail(g.id(), g.name(), g.parentGroupId(), g.createdAt(),
g.directRoles(), rbacService.getEffectiveRolesForGroup(g.id()),
g.members(), g.childGroups()))
.toList();
return ResponseEntity.ok(details);
}
@GetMapping("/{id}")
@Operation(summary = "Get group detail")
public ResponseEntity<GroupDetail> getGroup(@PathVariable UUID id) {
return groupRepository.findById(id)
.map(g -> {
// Enrich with effective roles
List<RoleSummary> effectiveRoles = rbacService.getEffectiveRolesForGroup(id);
return ResponseEntity.ok(new GroupDetail(g.id(), g.name(), g.parentGroupId(),
g.createdAt(), g.directRoles(), effectiveRoles, g.members(), g.childGroups()));
})
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@Operation(summary = "Create group")
public ResponseEntity<Map<String, UUID>> createGroup(@RequestBody CreateGroupRequest request,
HttpServletRequest httpRequest) {
UUID id = groupRepository.create(request.name(), request.parentGroupId());
auditService.log("create_group", AuditCategory.RBAC, id.toString(),
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(Map.of("id", id));
}
@PutMapping("/{id}")
@Operation(summary = "Update group")
public ResponseEntity<Void> updateGroup(@PathVariable UUID id,
@RequestBody UpdateGroupRequest request,
HttpServletRequest httpRequest) {
// Cycle detection
if (request.parentGroupId() != null) {
List<GroupSummary> ancestors = groupRepository.findAncestorChain(request.parentGroupId());
boolean cycle = ancestors.stream().anyMatch(a -> a.id().equals(id));
if (cycle || request.parentGroupId().equals(id)) {
return ResponseEntity.status(409).build();
}
}
groupRepository.update(id, request.name(), request.parentGroupId());
auditService.log("update_group", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete group")
public ResponseEntity<Void> deleteGroup(@PathVariable UUID id, HttpServletRequest httpRequest) {
groupRepository.delete(id);
auditService.log("delete_group", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/roles/{roleId}")
@Operation(summary = "Assign role to group")
public ResponseEntity<Void> addRole(@PathVariable UUID id, @PathVariable UUID roleId,
HttpServletRequest httpRequest) {
groupRepository.addRole(id, roleId);
auditService.log("assign_group_role", AuditCategory.RBAC, id.toString(),
Map.of("roleId", roleId.toString()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{id}/roles/{roleId}")
@Operation(summary = "Remove role from group")
public ResponseEntity<Void> removeRole(@PathVariable UUID id, @PathVariable UUID roleId,
HttpServletRequest httpRequest) {
groupRepository.removeRole(id, roleId);
auditService.log("remove_group_role", AuditCategory.RBAC, id.toString(),
Map.of("roleId", roleId.toString()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
public record CreateGroupRequest(String name, UUID parentGroupId) {}
public record UpdateGroupRequest(String name, UUID parentGroupId) {}
}
```
- [ ] **Step 3: Create RoleAdminController**
```java
package com.cameleer.server.app.controller;
import com.cameleer.server.core.admin.*;
import com.cameleer.server.core.rbac.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/v1/admin/roles")
@Tag(name = "Role Admin", description = "Role management (ADMIN only)")
@PreAuthorize("hasRole('ADMIN')")
public class RoleAdminController {
private final RoleRepository roleRepository;
private final RbacService rbacService;
private final AuditService auditService;
public RoleAdminController(RoleRepository roleRepository, RbacService rbacService,
AuditService auditService) {
this.roleRepository = roleRepository;
this.rbacService = rbacService;
this.auditService = auditService;
}
@GetMapping
@Operation(summary = "List all roles (system + custom)")
public ResponseEntity<List<RoleDetail>> listRoles() {
return ResponseEntity.ok(roleRepository.findAll());
}
@GetMapping("/{id}")
@Operation(summary = "Get role detail")
public ResponseEntity<RoleDetail> getRole(@PathVariable UUID id) {
return roleRepository.findById(id)
.map(r -> {
List<UserSummary> principals = rbacService.getEffectivePrincipalsForRole(id);
return ResponseEntity.ok(new RoleDetail(r.id(), r.name(), r.description(),
r.scope(), r.system(), r.createdAt(), r.assignedGroups(),
r.directUsers(), principals));
})
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
@Operation(summary = "Create custom role")
public ResponseEntity<Map<String, UUID>> createRole(@RequestBody CreateRoleRequest request,
HttpServletRequest httpRequest) {
UUID id = roleRepository.create(request.name(), request.description(), request.scope());
auditService.log("create_role", AuditCategory.RBAC, id.toString(),
Map.of("name", request.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(Map.of("id", id));
}
@PutMapping("/{id}")
@Operation(summary = "Update custom role (rejects system roles)")
public ResponseEntity<Void> updateRole(@PathVariable UUID id,
@RequestBody UpdateRoleRequest request,
HttpServletRequest httpRequest) {
if (SystemRole.isSystem(id)) {
return ResponseEntity.status(403).build();
}
roleRepository.update(id, request.name(), request.description(), request.scope());
auditService.log("update_role", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete custom role (rejects system roles)")
public ResponseEntity<Void> deleteRole(@PathVariable UUID id, HttpServletRequest httpRequest) {
if (SystemRole.isSystem(id)) {
return ResponseEntity.status(403).build();
}
roleRepository.delete(id);
auditService.log("delete_role", AuditCategory.RBAC, id.toString(),
null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
public record CreateRoleRequest(String name, String description, String scope) {}
public record UpdateRoleRequest(String name, String description, String scope) {}
}
```
- [ ] **Step 4: Create RbacStatsController**
```java
package com.cameleer.server.app.controller;
import com.cameleer.server.core.rbac.RbacService;
import com.cameleer.server.core.rbac.RbacStats;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/admin/rbac")
@Tag(name = "RBAC Stats", description = "RBAC dashboard statistics")
@PreAuthorize("hasRole('ADMIN')")
public class RbacStatsController {
private final RbacService rbacService;
public RbacStatsController(RbacService rbacService) {
this.rbacService = rbacService;
}
@GetMapping("/stats")
@Operation(summary = "RBAC dashboard statistics")
public ResponseEntity<RbacStats> getStats() {
return ResponseEntity.ok(rbacService.getStats());
}
}
```
- [ ] **Step 5: Verify full project compiles**
```bash
mvn clean compile
```
Expected: BUILD SUCCESS
- [ ] **Step 6: Commit**
```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/
git commit -m "feat: add Group, Role, and RBAC stats admin controllers
GroupAdminController with cycle detection, RoleAdminController
with system role protection, RbacStatsController for dashboard.
Rewrite UserAdminController to use RbacService."
```
---
### Task 8: Fix Tests and Verify Build
**Files:**
- Modify: any test files that reference `UserInfo.roles()` or old `updateRoles` API
- Run: `mvn clean verify -DskipITs` (unit tests only, ITs need Docker)
- [ ] **Step 1: Find and fix all test compilation errors**
Search for references to `UserInfo` constructor with 6 args (now 5) or `.roles()` calls in test files. Update constructor calls, remove roles parameter.
```bash
grep -rn "UserInfo(" cameleer-server-app/src/test/
grep -rn "\.roles()" cameleer-server-app/src/test/
```
Fix each reference. `TestSecurityHelper` creates JWT directly with roles — this doesn't change since JWT creation still takes `List<String> roles`.
- [ ] **Step 2: Run unit tests**
```bash
mvn clean verify -DskipITs
```
Expected: BUILD SUCCESS, all unit tests pass.
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "fix: update tests for new UserInfo record without roles field"
```
---
### Task 9: Regenerate OpenAPI Spec
After backend changes, regenerate the OpenAPI spec so the frontend can use the new types.
- [ ] **Step 1: Build the backend**
```bash
mvn clean package -DskipTests
```
- [ ] **Step 2: Start the server locally (or use running backend)**
If a running backend is available, redeploy. Otherwise start locally temporarily.
- [ ] **Step 3: Regenerate OpenAPI types**
```bash
cd ui
npm run generate-api:live
```
Or if using local server:
```bash
curl -s http://localhost:8081/api/v1/api-docs -o ui/src/api/openapi.json
cd ui && npm run generate-api
```
- [ ] **Step 4: Verify UI still builds**
```bash
cd ui && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
git commit -m "chore: regenerate OpenAPI spec and TypeScript types for RBAC endpoints"
```
---
### Task 10: Frontend API Hooks
**Files:**
- Create: `ui/src/api/queries/admin/rbac.ts`
- [ ] **Step 1: Create RBAC query and mutation hooks**
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
// ─── Types ───
export interface RoleSummary {
id: string;
name: string;
system: boolean;
source: string;
}
export interface GroupSummary {
id: string;
name: string;
}
export interface UserSummary {
userId: string;
displayName: string;
provider: string;
}
export interface UserDetail {
userId: string;
provider: string;
email: string;
displayName: string;
createdAt: string;
directRoles: RoleSummary[];
directGroups: GroupSummary[];
effectiveRoles: RoleSummary[];
effectiveGroups: GroupSummary[];
}
export interface GroupDetail {
id: string;
name: string;
parentGroupId: string | null;
createdAt: string;
directRoles: RoleSummary[];
effectiveRoles: RoleSummary[];
members: UserSummary[];
childGroups: GroupSummary[];
}
export interface RoleDetail {
id: string;
name: string;
description: string;
scope: string;
system: boolean;
createdAt: string;
assignedGroups: GroupSummary[];
directUsers: UserSummary[];
effectivePrincipals: UserSummary[];
}
export interface RbacStats {
userCount: number;
activeUserCount: number;
groupCount: number;
maxGroupDepth: number;
roleCount: number;
}
// ─── Query hooks ───
export function useUsers() {
return useQuery({
queryKey: ['admin', 'rbac', 'users'],
queryFn: () => adminFetch<UserDetail[]>('/users'),
});
}
export function useUser(userId: string | null) {
return useQuery({
queryKey: ['admin', 'rbac', 'users', userId],
queryFn: () => adminFetch<UserDetail>(`/users/${encodeURIComponent(userId!)}`),
enabled: !!userId,
});
}
export function useGroups() {
return useQuery({
queryKey: ['admin', 'rbac', 'groups'],
queryFn: () => adminFetch<GroupSummary[]>('/groups'),
});
}
export function useGroup(groupId: string | null) {
return useQuery({
queryKey: ['admin', 'rbac', 'groups', groupId],
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
enabled: !!groupId,
});
}
export function useRoles() {
return useQuery({
queryKey: ['admin', 'rbac', 'roles'],
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
});
}
export function useRole(roleId: string | null) {
return useQuery({
queryKey: ['admin', 'rbac', 'roles', roleId],
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
enabled: !!roleId,
});
}
export function useRbacStats() {
return useQuery({
queryKey: ['admin', 'rbac', 'stats'],
queryFn: () => adminFetch<RbacStats>('/rbac/stats'),
});
}
// ─── Mutation hooks ───
export function useAssignRoleToUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useRemoveRoleFromUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useAddUserToGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useRemoveUserFromGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useCreateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; parentGroupId?: string }) =>
adminFetch<{ id: string }>('/groups', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useUpdateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; parentGroupId?: string | null }) =>
adminFetch(`/groups/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useDeleteGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
adminFetch(`/groups/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useAssignRoleToGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useRemoveRoleFromGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useCreateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; description?: string; scope?: string }) =>
adminFetch<{ id: string }>('/roles', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useUpdateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; description?: string; scope?: string }) =>
adminFetch(`/roles/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useDeleteRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
adminFetch(`/roles/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: string) =>
adminFetch(`/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
```
- [ ] **Step 2: Verify UI builds**
```bash
cd ui && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add ui/src/api/queries/admin/rbac.ts
git commit -m "feat: add React Query hooks for RBAC admin API"
```
---
### Task 11: Frontend — Routing, Sidebar, RbacPage Shell
**Files:**
- Modify: `ui/src/router.tsx`
- Modify: `ui/src/components/layout/AppSidebar.tsx`
- Create: `ui/src/pages/admin/rbac/RbacPage.tsx`
- Create: `ui/src/pages/admin/rbac/RbacPage.module.css`
- [ ] **Step 1: Add route to router.tsx**
Add lazy import and route entry for `/admin/rbac`:
```tsx
const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage })));
// In routes array:
{ path: 'admin/rbac', element: <Suspense fallback={null}><RbacPage /></Suspense> }
```
- [ ] **Step 2: Add sidebar link**
In `AppSidebar.tsx`, add to `ADMIN_LINKS`:
```tsx
{ to: '/admin/rbac', label: 'User Management' },
```
- [ ] **Step 3: Create RbacPage shell with tab navigation**
`RbacPage.tsx` — ADMIN role gate + tab bar (Dashboard | Users | Groups | Roles). Uses `?tab=` query param for active tab state. Each tab renders a placeholder initially.
- [ ] **Step 4: Create RbacPage.module.css**
Styles for tab bar, split pane layout, entity cards, detail pane, avatars, chips, badges, search input, trees, stat cards, inheritance diagram. Reference `examples/RBAC/rbac_management_ui.html` for visual conventions and map to project's CSS variables (`--bg-surface`, `--border`, `--text-primary`, etc.).
- [ ] **Step 5: Verify build**
```bash
cd ui && npm run build
```
- [ ] **Step 6: Commit**
```bash
git add ui/src/router.tsx ui/src/components/layout/AppSidebar.tsx ui/src/pages/admin/rbac/
git commit -m "feat: add RbacPage shell with tab navigation and routing"
```
---
### Task 12: Frontend — Shared Components (EntityListPane, EntityCard, EntityAvatar, chips, badges)
**Files:**
- Create: `ui/src/pages/admin/rbac/components/EntityListPane.tsx`
- Create: `ui/src/pages/admin/rbac/components/EntityCard.tsx`
- Create: `ui/src/pages/admin/rbac/components/EntityAvatar.tsx`
- Create: `ui/src/pages/admin/rbac/components/InheritanceChip.tsx`
- Create: `ui/src/pages/admin/rbac/components/OidcBadge.tsx`
- Create: `ui/src/pages/admin/rbac/components/InheritanceNote.tsx`
- Create: `ui/src/pages/admin/rbac/components/GroupTree.tsx`
- Create: `ui/src/pages/admin/rbac/components/InheritanceDiagram.tsx`
- [ ] **Step 1: Create EntityAvatar**
Renders circle (user) or rounded-square (group/role) avatar with initials and type-specific colors.
Props: `type: 'user' | 'group' | 'role'`, `name: string`, `size?: number`
Colors: user = blue tint (#E6F1FB / #0C447C), group = green tint (#E1F5EE / #0F6E56), role = amber tint (#FAEEDA / #633806). Initials = first two chars of name, uppercased.
- [ ] **Step 2: Create OidcBadge**
Props: `provider: string`. Renders a small cyan pill showing "OIDC" (or issuer hostname extracted from `oidc:<host>`). Only render when `provider !== "local"`.
- [ ] **Step 3: Create InheritanceChip**
Props: `name: string`, `source?: string`, `type: 'role' | 'group'`. Renders chip with dashed border if `source` is present (inherited), solid if direct. Includes `↑ source` annotation when inherited.
- [ ] **Step 4: Create InheritanceNote**
Props: `children: ReactNode`. Green-bordered explanation block. Styled per the spec: `border-left: 2px solid var(--green)`, subtle background.
- [ ] **Step 5: Create GroupTree**
Props: `groups: GroupSummary[]` (ordered root-first). Renders indented tree with corner connectors using CSS borders, matching the prototype.
- [ ] **Step 6: Create InheritanceDiagram**
Three-column layout (Groups → Roles → Users) for the dashboard. Read-only orientation aid.
- [ ] **Step 7: Create EntityCard**
Generic list row: avatar + entity info (name, meta line, tag row) + optional status dot. Props accept render functions for meta and tags to support user/group/role variations.
- [ ] **Step 8: Create EntityListPane**
Search input + scrollable card list. Props: `items`, `renderCard`, `searchFilter`, `onSelect`, `selectedId`, `placeholder`.
- [ ] **Step 9: Verify build**
```bash
cd ui && npm run build
```
- [ ] **Step 10: Commit**
```bash
git add ui/src/pages/admin/rbac/components/
git commit -m "feat: add RBAC shared components (avatars, chips, tree, entity list)"
```
---
### Task 13: Frontend — DashboardTab
**Files:**
- Create: `ui/src/pages/admin/rbac/DashboardTab.tsx`
- [ ] **Step 1: Implement DashboardTab**
Uses `useRbacStats()` hook. Renders three stat cards (Users, Groups, Roles) and the InheritanceDiagram. Uses `useGroups()` and `useRoles()` to populate the diagram with real data.
- [ ] **Step 2: Verify build**
```bash
cd ui && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add ui/src/pages/admin/rbac/DashboardTab.tsx
git commit -m "feat: add RBAC dashboard tab with stats and inheritance diagram"
```
---
### Task 14: Frontend — UsersTab with UserDetail
**Files:**
- Create: `ui/src/pages/admin/rbac/UsersTab.tsx`
- Create: `ui/src/pages/admin/rbac/components/UserDetail.tsx`
- [ ] **Step 1: Implement UserDetail**
Renders: avatar, name, email, OIDC badge, status fields, group membership chips, effective roles (with InheritanceChip), group tree. Uses data from `UserDetail` type.
- [ ] **Step 2: Implement UsersTab**
Split pane: EntityListPane on left (52%), UserDetail on right (48%). Uses `useUsers()` for list data. Clicking a user card selects it and shows UserDetail.
User card: EntityCard with circle avatar, name, email meta, role tags (amber, inherited italic), group tags (green), OIDC badge.
- [ ] **Step 3: Verify build**
```bash
cd ui && npm run build
```
- [ ] **Step 4: Commit**
```bash
git add ui/src/pages/admin/rbac/UsersTab.tsx ui/src/pages/admin/rbac/components/UserDetail.tsx
git commit -m "feat: add Users tab with split pane list and detail view"
```
---
### Task 15: Frontend — GroupsTab with GroupDetail
**Files:**
- Create: `ui/src/pages/admin/rbac/GroupsTab.tsx`
- Create: `ui/src/pages/admin/rbac/components/GroupDetail.tsx`
- [ ] **Step 1: Implement GroupDetail**
Renders: avatar, name, hierarchy level, ID field, member chips, child group chips, assigned roles with inheritance notes, group hierarchy tree.
- [ ] **Step 2: Implement GroupsTab**
Split pane with EntityListPane. Uses `useGroups()` for list, `useGroup(selectedId)` for detail. Group cards: rounded-square avatar, name, parent/member-count meta, role tags.
- [ ] **Step 3: Verify build and commit**
```bash
cd ui && npm run build
git add ui/src/pages/admin/rbac/GroupsTab.tsx ui/src/pages/admin/rbac/components/GroupDetail.tsx
git commit -m "feat: add Groups tab with hierarchy display and detail view"
```
---
### Task 16: Frontend — RolesTab with RoleDetail
**Files:**
- Create: `ui/src/pages/admin/rbac/RolesTab.tsx`
- Create: `ui/src/pages/admin/rbac/components/RoleDetail.tsx`
- [ ] **Step 1: Implement RoleDetail**
Renders: avatar, role name, description, ID/scope fields, assigned groups, direct users, effective principals, inheritance note. System roles show lock icon.
- [ ] **Step 2: Implement RolesTab**
Split pane with EntityListPane. Uses `useRoles()` for list, `useRole(selectedId)` for detail. Role cards: rounded-square avatar, name, description/assignment meta, group tags. System roles show lock icon.
- [ ] **Step 3: Verify build and commit**
```bash
cd ui && npm run build
git add ui/src/pages/admin/rbac/RolesTab.tsx ui/src/pages/admin/rbac/components/RoleDetail.tsx
git commit -m "feat: add Roles tab with system role protection and detail view"
```
---
### Task 17: Wire Tabs into RbacPage
**Files:**
- Modify: `ui/src/pages/admin/rbac/RbacPage.tsx`
- [ ] **Step 1: Import and render tab content**
Replace placeholder tab content with actual `DashboardTab`, `UsersTab`, `GroupsTab`, `RolesTab` components.
- [ ] **Step 2: Full build verification**
```bash
cd ui && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add ui/src/pages/admin/rbac/RbacPage.tsx
git commit -m "feat: wire all RBAC tabs into RbacPage"
```
---
### Task 18: Final Build Verification and Cleanup
- [ ] **Step 1: Full backend build**
```bash
mvn clean compile
```
- [ ] **Step 2: Full frontend build**
```bash
cd ui && npm run build
```
- [ ] **Step 3: Check for unused imports or dead code**
Review modified files for leftover references to old `UserInfo.roles()` pattern.
- [ ] **Step 4: Final commit if any cleanup needed**
```bash
git add -A
git commit -m "chore: cleanup unused imports and dead code from RBAC migration"
```