# 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 | |---|---| | `cameleer3-server-app/src/main/resources/db/migration/V1__init.sql` | Consolidated schema (replaces V1–V10) | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java` | Inheritance computation interface | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupRepository.java` | Group CRUD interface | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleRepository.java` | Role CRUD interface | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserDetail.java` | Enriched user record | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupDetail.java` | Group detail record | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleDetail.java` | Role detail record | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserSummary.java` | Embedded user ref | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupSummary.java` | Embedded group ref | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleSummary.java` | Embedded role ref | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacStats.java` | Dashboard stats record | | `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java` | System role constants + fixed UUIDs | | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresGroupRepository.java` | Group repository impl | | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresRoleRepository.java` | Role repository impl | | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java` | Inheritance computation impl | | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java` | Group CRUD endpoints | | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RoleAdminController.java` | Role CRUD endpoints | | `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RbacStatsController.java` | Dashboard stats endpoint | ### Backend — Modified Files | File | Change | |---|---| | `cameleer3-server-core/.../security/UserInfo.java` | Remove `roles` field | | `cameleer3-server-core/.../security/UserRepository.java` | Remove `updateRoles`, add `findSystemRoles` | | `cameleer3-server-core/.../admin/AuditCategory.java` | Add `RBAC` category | | `cameleer3-server-app/.../storage/PostgresUserRepository.java` | Rewrite for new schema (no roles column, use user_roles join) | | `cameleer3-server-app/.../controller/UserAdminController.java` | Rewrite: use RbacService, new endpoints | | `cameleer3-server-app/.../security/UiAuthController.java` | Use user_roles instead of UserInfo.roles | | `cameleer3-server-app/.../security/OidcAuthController.java` | Use user_roles for role resolution | | `cameleer3-server-app/.../security/JwtAuthenticationFilter.java` | No change (reads roles from JWT, not DB) | | `cameleer3-server-app/.../security/AgentRegistrationController.java` | Use user_roles for AGENT role | | `cameleer3-server-app/src/test/.../TestSecurityHelper.java` | No change (creates JWT directly) | ### Backend — Deleted Files | File | |---| | `cameleer3-server-app/src/main/resources/db/migration/V2__executions.sql` | | `cameleer3-server-app/src/main/resources/db/migration/V3__processor_executions.sql` | | `cameleer3-server-app/src/main/resources/db/migration/V4__agent_metrics.sql` | | `cameleer3-server-app/src/main/resources/db/migration/V5__route_diagrams.sql` | | `cameleer3-server-app/src/main/resources/db/migration/V6__users.sql` | | `cameleer3-server-app/src/main/resources/db/migration/V7__oidc_config.sql` | | `cameleer3-server-app/src/main/resources/db/migration/V8__continuous_aggregates.sql` | | `cameleer3-server-app/src/main/resources/db/migration/V9__admin_thresholds.sql` | | `cameleer3-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: `cameleer3-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 (V1–V10) 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 Cameleer3 -- 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 cameleer3-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/cameleer3-server mvn clean compile -pl cameleer3-server-app ``` Expected: BUILD SUCCESS (Flyway doesn't run at compile time, just packaging) - [ ] **Step 4: Commit** ```bash git add -A cameleer3-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: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/SystemRole.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserDetail.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupDetail.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleDetail.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/UserSummary.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupSummary.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleSummary.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacStats.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/GroupRepository.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RoleRepository.java` - Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java` - Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java` - Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java` - Modify: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/admin/AuditCategory.java` - [ ] **Step 1: Create SystemRole constants** ```java package com.cameleer3.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 IDS = Set.of(AGENT_ID, VIEWER_ID, OPERATOR_ID, ADMIN_ID); public static final Map 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.cameleer3.server.core.rbac; public record UserSummary(String userId, String displayName, String provider) {} ``` `GroupSummary.java`: ```java package com.cameleer3.server.core.rbac; import java.util.UUID; public record GroupSummary(UUID id, String name) {} ``` `RoleSummary.java`: ```java package com.cameleer3.server.core.rbac; import java.util.UUID; public record RoleSummary(UUID id, String name, boolean system, String source) {} ``` `RbacStats.java`: ```java package com.cameleer3.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.cameleer3.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 directRoles, List directGroups, List effectiveRoles, List effectiveGroups ) {} ``` `GroupDetail.java`: ```java package com.cameleer3.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 directRoles, List effectiveRoles, List members, List childGroups ) {} ``` `RoleDetail.java`: ```java package com.cameleer3.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 assignedGroups, List directUsers, List effectivePrincipals ) {} ``` - [ ] **Step 4: Create repository interfaces** `GroupRepository.java`: ```java package com.cameleer3.server.core.rbac; import java.util.List; import java.util.Optional; import java.util.UUID; public interface GroupRepository { List findAll(); Optional 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 findChildGroups(UUID parentId); List findAncestorChain(UUID groupId); } ``` `RoleRepository.java`: ```java package com.cameleer3.server.core.rbac; import java.util.List; import java.util.Optional; import java.util.UUID; public interface RoleRepository { List findAll(); Optional 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.cameleer3.server.core.rbac; import java.util.List; import java.util.UUID; public interface RbacService { List 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 getEffectiveRolesForUser(String userId); List getEffectiveGroupsForUser(String userId); List getEffectiveRolesForGroup(UUID groupId); List getEffectivePrincipalsForRole(UUID roleId); /** Returns system role names for JWT/auth — only system roles. */ List getSystemRoleNames(String userId); RbacStats getStats(); } ``` - [ ] **Step 5: Update UserInfo — remove roles field** In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserInfo.java`, change to: ```java package com.cameleer3.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 `cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java`, change to: ```java package com.cameleer3.server.core.security; import java.util.List; import java.util.Optional; public interface UserRepository { Optional findById(String userId); List findAll(); void upsert(UserInfo user); void delete(String userId); } ``` Remove `updateRoles` method (role management moves to `RbacService`). - [ ] **Step 7: Add RBAC audit category** In `cameleer3-server-core/src/main/java/com/cameleer3/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 cameleer3-server-core ``` Expected: BUILD SUCCESS **Do NOT commit individually** — Tasks 2–6 form an atomic batch. Commit after Task 6. --- ### Task 3: PostgresUserRepository — Adapt to New Schema **Files:** - Modify: `cameleer3-server-app/src/main/java/com/cameleer3/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.cameleer3.server.app.storage; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.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 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 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 2–6 form an atomic batch. Commit after Task 6 when the full project compiles. --- ### Task 4: PostgresGroupRepository and PostgresRoleRepository **Files:** - Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresGroupRepository.java` - Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresRoleRepository.java` - [ ] **Step 1: Implement PostgresGroupRepository** ```java package com.cameleer3.server.app.storage; import com.cameleer3.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 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 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 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 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 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 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 findAncestorChain(UUID groupId) { // Walk parent chain iteratively (max depth is small) List chain = new ArrayList<>(); UUID current = groupId; Set 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.cameleer3.server.app.storage; import com.cameleer3.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 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 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 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 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 cameleer3-server-app ``` Expected: May still have errors from files referencing `UserInfo.roles()` — those are fixed in Task 6. **Do NOT commit individually** — Tasks 2–6 form an atomic batch. Commit after Task 6 when the full project compiles. --- ### Task 5: RbacServiceImpl — Inheritance Logic **Files:** - Create: `cameleer3-server-app/src/main/java/com/cameleer3/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.cameleer3.server.app.rbac; import com.cameleer3.server.core.rbac.*; import com.cameleer3.server.core.security.UserInfo; import com.cameleer3.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 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 directRoles = getDirectRolesForUser(user.userId()); List directGroups = getDirectGroupsForUser(user.userId()); List effectiveRoles = getEffectiveRolesForUser(user.userId()); List 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 getEffectiveRolesForUser(String userId) { // 1. Direct roles List direct = getDirectRolesForUser(userId); // 2. Roles inherited from groups List effectiveGroups = getEffectiveGroupsForUser(userId); Map roleMap = new LinkedHashMap<>(); for (RoleSummary r : direct) { roleMap.put(r.id(), r); } for (GroupSummary group : effectiveGroups) { List 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 getEffectiveGroupsForUser(String userId) { List directGroups = getDirectGroupsForUser(userId); Set visited = new LinkedHashSet<>(); List all = new ArrayList<>(); for (GroupSummary g : directGroups) { collectAncestors(g.id(), visited, all); } return all; } private void collectAncestors(UUID groupId, Set visited, List 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 getEffectiveRolesForGroup(UUID groupId) { // Direct roles on group List 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 roleMap = new LinkedHashMap<>(); for (RoleSummary r : direct) roleMap.put(r.id(), r); List ancestors = groupRepository.findAncestorChain(groupId); // ancestors includes self — skip self, iterate parents for (GroupSummary ancestor : ancestors) { if (ancestor.id().equals(groupId)) continue; List 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 getEffectivePrincipalsForRole(UUID roleId) { // Users with direct assignment Set seen = new LinkedHashSet<>(); List result = new ArrayList<>(); List 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 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 allGroups = new LinkedHashSet<>(groupsWithRole); for (UUID gid : groupsWithRole) { collectDescendants(gid, allGroups); } for (UUID gid : allGroups) { List 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 result) { List 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 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 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 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 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 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 2–6 form an atomic batch. Commit after Task 6 when the full project compiles. --- ### Task 6: Auth Integration — Update Login and OIDC Flows **Files:** - Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java` - Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java` - Modify: `cameleer3-server-app/src/main/java/com/cameleer3/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 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` 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 2–6)** This is the single commit for the entire backend RBAC model. Tasks 2–6 must all be done before committing since intermediate states don't compile. ```bash git add cameleer3-server-core/ cameleer3-server-app/src/main/java/ git commit -m "feat: replace flat users.roles with relational RBAC model New package com.cameleer3.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: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java` - Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/GroupAdminController.java` - Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RoleAdminController.java` - Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/RbacStatsController.java` - [ ] **Step 1: Rewrite UserAdminController** ```java package com.cameleer3.server.app.controller; import com.cameleer3.server.core.admin.*; import com.cameleer3.server.core.rbac.*; import com.cameleer3.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> listUsers() { return ResponseEntity.ok(rbacService.listUsers()); } @GetMapping("/{userId}") @Operation(summary = "Get user detail") public ResponseEntity 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 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 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 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 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 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.cameleer3.server.app.controller; import com.cameleer3.server.core.admin.*; import com.cameleer3.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> listGroups() { List all = groupRepository.findAll(); List 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 getGroup(@PathVariable UUID id) { return groupRepository.findById(id) .map(g -> { // Enrich with effective roles List 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> 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 updateGroup(@PathVariable UUID id, @RequestBody UpdateGroupRequest request, HttpServletRequest httpRequest) { // Cycle detection if (request.parentGroupId() != null) { List 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 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 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 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.cameleer3.server.app.controller; import com.cameleer3.server.core.admin.*; import com.cameleer3.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> listRoles() { return ResponseEntity.ok(roleRepository.findAll()); } @GetMapping("/{id}") @Operation(summary = "Get role detail") public ResponseEntity getRole(@PathVariable UUID id) { return roleRepository.findById(id) .map(r -> { List 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> 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 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 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.cameleer3.server.app.controller; import com.cameleer3.server.core.rbac.RbacService; import com.cameleer3.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 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 cameleer3-server-app/src/main/java/com/cameleer3/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(" cameleer3-server-app/src/test/ grep -rn "\.roles()" cameleer3-server-app/src/test/ ``` Fix each reference. `TestSecurityHelper` creates JWT directly with roles — this doesn't change since JWT creation still takes `List 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('/users'), }); } export function useUser(userId: string | null) { return useQuery({ queryKey: ['admin', 'rbac', 'users', userId], queryFn: () => adminFetch(`/users/${encodeURIComponent(userId!)}`), enabled: !!userId, }); } export function useGroups() { return useQuery({ queryKey: ['admin', 'rbac', 'groups'], queryFn: () => adminFetch('/groups'), }); } export function useGroup(groupId: string | null) { return useQuery({ queryKey: ['admin', 'rbac', 'groups', groupId], queryFn: () => adminFetch(`/groups/${groupId}`), enabled: !!groupId, }); } export function useRoles() { return useQuery({ queryKey: ['admin', 'rbac', 'roles'], queryFn: () => adminFetch('/roles'), }); } export function useRole(roleId: string | null) { return useQuery({ queryKey: ['admin', 'rbac', 'roles', roleId], queryFn: () => adminFetch(`/roles/${roleId}`), enabled: !!roleId, }); } export function useRbacStats() { return useQuery({ queryKey: ['admin', 'rbac', 'stats'], queryFn: () => adminFetch('/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: } ``` - [ ] **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:`). 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" ```