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>
87 KiB
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 V1–V10) |
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.sqlthroughV10__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.
-- 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
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.
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
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
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
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:
package com.cameleer.server.core.rbac;
public record UserSummary(String userId, String displayName, String provider) {}
GroupSummary.java:
package com.cameleer.server.core.rbac;
import java.util.UUID;
public record GroupSummary(UUID id, String name) {}
RoleSummary.java:
package com.cameleer.server.core.rbac;
import java.util.UUID;
public record RoleSummary(UUID id, String name, boolean system, String source) {}
RbacStats.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:
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:
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:
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:
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:
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:
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:
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:
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:
public enum AuditCategory {
INFRA, AUTH, USER_MGMT, CONFIG, RBAC
}
- Step 8: Verify core module compiles
mvn clean compile -pl cameleer-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:
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.
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 2–6 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
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
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
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 2–6 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.
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 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:
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:
- Create/upsert the
UserInfo(without roles) - Assign roles via
RbacService.assignRoleToUser() - 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
UserInfowithout 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
- Existing user: read from
- 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
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.
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
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
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
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
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
mvn clean compile
Expected: BUILD SUCCESS
- Step 6: Commit
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 oldupdateRolesAPI -
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.
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
mvn clean verify -DskipITs
Expected: BUILD SUCCESS, all unit tests pass.
- Step 3: Commit
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
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
cd ui
npm run generate-api:live
Or if using local server:
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
cd ui && npm run build
- Step 5: Commit
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
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
cd ui && npm run build
- Step 3: Commit
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:
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:
{ 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
cd ui && npm run build
- Step 6: Commit
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
cd ui && npm run build
- Step 10: Commit
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
cd ui && npm run build
- Step 3: Commit
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
cd ui && npm run build
- Step 4: Commit
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
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
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
cd ui && npm run build
- Step 3: Commit
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
mvn clean compile
- Step 2: Full frontend build
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
git add -A
git commit -m "chore: cleanup unused imports and dead code from RBAC migration"