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