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

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

87 KiB
Raw Blame History

RBAC Management Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace flat users.roles text array with a full RBAC system — groups, roles, inheritance — plus a management UI at /admin/rbac.

Architecture: Consolidated Flyway migration (V1__init.sql), new RBAC domain records and RbacService for inheritance computation, three new admin controllers, React UI with tab-based navigation and split-pane entity views.

Tech Stack: Java 17, Spring Boot 3.4, PostgreSQL/TimescaleDB, Flyway, React 19, TypeScript, TanStack Query, CSS Modules.

Spec: docs/superpowers/specs/2026-03-17-rbac-management-design.md


File Map

Backend — New Files

File Responsibility
cameleer-server-app/src/main/resources/db/migration/V1__init.sql Consolidated schema (replaces V1V10)
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacService.java Inheritance computation interface
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/GroupRepository.java Group CRUD interface
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RoleRepository.java Role CRUD interface
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/UserDetail.java Enriched user record
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/GroupDetail.java Group detail record
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RoleDetail.java Role detail record
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/UserSummary.java Embedded user ref
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/GroupSummary.java Embedded group ref
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RoleSummary.java Embedded role ref
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacStats.java Dashboard stats record
cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/SystemRole.java System role constants + fixed UUIDs
cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresGroupRepository.java Group repository impl
cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresRoleRepository.java Role repository impl
cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java Inheritance computation impl
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/GroupAdminController.java Group CRUD endpoints
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RoleAdminController.java Role CRUD endpoints
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RbacStatsController.java Dashboard stats endpoint

Backend — Modified Files

File Change
cameleer-server-core/.../security/UserInfo.java Remove roles field
cameleer-server-core/.../security/UserRepository.java Remove updateRoles, add findSystemRoles
cameleer-server-core/.../admin/AuditCategory.java Add RBAC category
cameleer-server-app/.../storage/PostgresUserRepository.java Rewrite for new schema (no roles column, use user_roles join)
cameleer-server-app/.../controller/UserAdminController.java Rewrite: use RbacService, new endpoints
cameleer-server-app/.../security/UiAuthController.java Use user_roles instead of UserInfo.roles
cameleer-server-app/.../security/OidcAuthController.java Use user_roles for role resolution
cameleer-server-app/.../security/JwtAuthenticationFilter.java No change (reads roles from JWT, not DB)
cameleer-server-app/.../security/AgentRegistrationController.java Use user_roles for AGENT role
cameleer-server-app/src/test/.../TestSecurityHelper.java No change (creates JWT directly)

Backend — Deleted Files

File
cameleer-server-app/src/main/resources/db/migration/V2__executions.sql
cameleer-server-app/src/main/resources/db/migration/V3__processor_executions.sql
cameleer-server-app/src/main/resources/db/migration/V4__agent_metrics.sql
cameleer-server-app/src/main/resources/db/migration/V5__route_diagrams.sql
cameleer-server-app/src/main/resources/db/migration/V6__users.sql
cameleer-server-app/src/main/resources/db/migration/V7__oidc_config.sql
cameleer-server-app/src/main/resources/db/migration/V8__continuous_aggregates.sql
cameleer-server-app/src/main/resources/db/migration/V9__admin_thresholds.sql
cameleer-server-app/src/main/resources/db/migration/V10__audit_log.sql

Frontend — New Files

File Responsibility
ui/src/api/queries/admin/rbac.ts React Query hooks + mutation hooks
ui/src/pages/admin/rbac/RbacPage.tsx ADMIN gate + tab navigation
ui/src/pages/admin/rbac/RbacPage.module.css All RBAC styles
ui/src/pages/admin/rbac/DashboardTab.tsx Stats + inheritance diagram
ui/src/pages/admin/rbac/UsersTab.tsx User split pane
ui/src/pages/admin/rbac/GroupsTab.tsx Group split pane
ui/src/pages/admin/rbac/RolesTab.tsx Role split pane
ui/src/pages/admin/rbac/components/EntityListPane.tsx Reusable filtered list
ui/src/pages/admin/rbac/components/EntityCard.tsx List row component
ui/src/pages/admin/rbac/components/EntityAvatar.tsx Avatar (circle/square)
ui/src/pages/admin/rbac/components/UserDetail.tsx User detail pane
ui/src/pages/admin/rbac/components/GroupDetail.tsx Group detail pane
ui/src/pages/admin/rbac/components/RoleDetail.tsx Role detail pane
ui/src/pages/admin/rbac/components/InheritanceChip.tsx Dashed chip + source
ui/src/pages/admin/rbac/components/GroupTree.tsx Indented hierarchy
ui/src/pages/admin/rbac/components/OidcBadge.tsx OIDC provider badge
ui/src/pages/admin/rbac/components/InheritanceDiagram.tsx Dashboard diagram
ui/src/pages/admin/rbac/components/InheritanceNote.tsx Explanation block

Frontend — Modified Files

File Change
ui/src/router.tsx Add /admin/rbac lazy route
ui/src/components/layout/AppSidebar.tsx Add "User Management" to ADMIN_LINKS

Tasks

Task 1: Consolidate Flyway Migrations

Files:

  • Create: cameleer-server-app/src/main/resources/db/migration/V1__init.sql

  • Delete: V1__extensions.sql through V10__audit_log.sql (10 files)

  • Step 1: Create consolidated V1__init.sql

Combine all existing migration content (V1V10) into a single file, replacing the users table definition to drop roles TEXT[], and adding the new RBAC tables. Order: extensions → users (new) → roles (with seeds) → groups → join tables → executions → processor_executions → agent_metrics → route_diagrams → oidc_config → continuous_aggregates → admin_thresholds → audit_log → indexes.

-- 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 26 form an atomic batch. Commit after Task 6.


Task 3: PostgresUserRepository — Adapt to New Schema

Files:

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java

  • Step 1: Rewrite PostgresUserRepository

The upsert no longer writes roles (no roles column). The mapUser no longer reads a roles array. Remove updateRoles method.

package com.cameleer.server.app.storage;

import com.cameleer.server.core.security.UserInfo;
import com.cameleer.server.core.security.UserRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;

@Repository
public class PostgresUserRepository implements UserRepository {

    private final JdbcTemplate jdbc;

    public PostgresUserRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    @Override
    public Optional<UserInfo> findById(String userId) {
        var list = jdbc.query(
                "SELECT user_id, provider, email, display_name, created_at FROM users WHERE user_id = ?",
                (rs, _) -> mapUser(rs), userId);
        return list.isEmpty() ? Optional.empty() : Optional.of(list.getFirst());
    }

    @Override
    public List<UserInfo> findAll() {
        return jdbc.query(
                "SELECT user_id, provider, email, display_name, created_at FROM users ORDER BY user_id",
                (rs, _) -> mapUser(rs));
    }

    @Override
    public void upsert(UserInfo user) {
        jdbc.update("""
                INSERT INTO users (user_id, provider, email, display_name, created_at, updated_at)
                VALUES (?, ?, ?, ?, ?, now())
                ON CONFLICT (user_id) DO UPDATE SET
                    provider = EXCLUDED.provider,
                    email = EXCLUDED.email,
                    display_name = EXCLUDED.display_name,
                    updated_at = now()
                """,
                user.userId(), user.provider(), user.email(), user.displayName(), user.createdAt());
    }

    @Override
    public void delete(String userId) {
        jdbc.update("DELETE FROM users WHERE user_id = ?", userId);
    }

    private UserInfo mapUser(ResultSet rs) throws SQLException {
        return new UserInfo(
                rs.getString("user_id"),
                rs.getString("provider"),
                rs.getString("email"),
                rs.getString("display_name"),
                rs.getTimestamp("created_at").toInstant()
        );
    }
}

Do NOT commit individually — Tasks 26 form an atomic batch. Commit after Task 6 when the full project compiles.


Task 4: PostgresGroupRepository and PostgresRoleRepository

Files:

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresGroupRepository.java

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresRoleRepository.java

  • Step 1: Implement PostgresGroupRepository

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 26 form an atomic batch. Commit after Task 6 when the full project compiles.


Task 5: RbacServiceImpl — Inheritance Logic

Files:

  • Create: cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java

  • Step 1: Implement RbacServiceImpl

This is the core service that computes inheritance. It reads from user_roles, user_groups, group_roles, and groups tables to compute effective roles/groups.

package com.cameleer.server.app.rbac;

import com.cameleer.server.core.rbac.*;
import com.cameleer.server.core.security.UserInfo;
import com.cameleer.server.core.security.UserRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

@Service
public class RbacServiceImpl implements RbacService {

    private final JdbcTemplate jdbc;
    private final UserRepository userRepository;
    private final GroupRepository groupRepository;
    private final RoleRepository roleRepository;

    public RbacServiceImpl(JdbcTemplate jdbc, UserRepository userRepository,
                           GroupRepository groupRepository, RoleRepository roleRepository) {
        this.jdbc = jdbc;
        this.userRepository = userRepository;
        this.groupRepository = groupRepository;
        this.roleRepository = roleRepository;
    }

    @Override
    public List<UserDetail> listUsers() {
        return userRepository.findAll().stream()
                .map(u -> buildUserDetail(u))
                .toList();
    }

    @Override
    public UserDetail getUser(String userId) {
        UserInfo user = userRepository.findById(userId).orElse(null);
        if (user == null) return null;
        return buildUserDetail(user);
    }

    private UserDetail buildUserDetail(UserInfo user) {
        List<RoleSummary> directRoles = getDirectRolesForUser(user.userId());
        List<GroupSummary> directGroups = getDirectGroupsForUser(user.userId());
        List<RoleSummary> effectiveRoles = getEffectiveRolesForUser(user.userId());
        List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(user.userId());
        return new UserDetail(user.userId(), user.provider(), user.email(),
                user.displayName(), user.createdAt(),
                directRoles, directGroups, effectiveRoles, effectiveGroups);
    }

    @Override
    public void assignRoleToUser(String userId, UUID roleId) {
        jdbc.update("INSERT INTO user_roles (user_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
                userId, roleId);
    }

    @Override
    public void removeRoleFromUser(String userId, UUID roleId) {
        jdbc.update("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", userId, roleId);
    }

    @Override
    public void addUserToGroup(String userId, UUID groupId) {
        jdbc.update("INSERT INTO user_groups (user_id, group_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
                userId, groupId);
    }

    @Override
    public void removeUserFromGroup(String userId, UUID groupId) {
        jdbc.update("DELETE FROM user_groups WHERE user_id = ? AND group_id = ?", userId, groupId);
    }

    @Override
    public List<RoleSummary> getEffectiveRolesForUser(String userId) {
        // 1. Direct roles
        List<RoleSummary> direct = getDirectRolesForUser(userId);

        // 2. Roles inherited from groups
        List<GroupSummary> effectiveGroups = getEffectiveGroupsForUser(userId);
        Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
        for (RoleSummary r : direct) {
            roleMap.put(r.id(), r);
        }
        for (GroupSummary group : effectiveGroups) {
            List<RoleSummary> groupRoles = jdbc.query("""
                    SELECT r.id, r.name, r.system FROM group_roles gr
                    JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
                    """, (rs, _) -> new RoleSummary(
                    rs.getObject("id", UUID.class),
                    rs.getString("name"),
                    rs.getBoolean("system"),
                    group.name()
            ), group.id());
            for (RoleSummary r : groupRoles) {
                roleMap.putIfAbsent(r.id(), r);
            }
        }
        return new ArrayList<>(roleMap.values());
    }

    @Override
    public List<GroupSummary> getEffectiveGroupsForUser(String userId) {
        List<GroupSummary> directGroups = getDirectGroupsForUser(userId);
        Set<UUID> visited = new LinkedHashSet<>();
        List<GroupSummary> all = new ArrayList<>();
        for (GroupSummary g : directGroups) {
            collectAncestors(g.id(), visited, all);
        }
        return all;
    }

    private void collectAncestors(UUID groupId, Set<UUID> visited, List<GroupSummary> result) {
        if (!visited.add(groupId)) return;
        var rows = jdbc.query("SELECT id, name, parent_group_id FROM groups WHERE id = ?",
                (rs, _) -> new Object[]{
                        new GroupSummary(rs.getObject("id", UUID.class), rs.getString("name")),
                        rs.getObject("parent_group_id", UUID.class)
                }, groupId);
        if (rows.isEmpty()) return;
        result.add((GroupSummary) rows.getFirst()[0]);
        UUID parentId = (UUID) rows.getFirst()[1];
        if (parentId != null) {
            collectAncestors(parentId, visited, result);
        }
    }

    @Override
    public List<RoleSummary> getEffectiveRolesForGroup(UUID groupId) {
        // Direct roles on group
        List<RoleSummary> direct = jdbc.query("""
                SELECT r.id, r.name, r.system FROM group_roles gr
                JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
                """, (rs, _) -> new RoleSummary(rs.getObject("id", UUID.class),
                rs.getString("name"), rs.getBoolean("system"), "direct"), groupId);

        // Walk parent chain
        Map<UUID, RoleSummary> roleMap = new LinkedHashMap<>();
        for (RoleSummary r : direct) roleMap.put(r.id(), r);

        List<GroupSummary> ancestors = groupRepository.findAncestorChain(groupId);
        // ancestors includes self — skip self, iterate parents
        for (GroupSummary ancestor : ancestors) {
            if (ancestor.id().equals(groupId)) continue;
            List<RoleSummary> parentRoles = jdbc.query("""
                    SELECT r.id, r.name, r.system FROM group_roles gr
                    JOIN roles r ON r.id = gr.role_id WHERE gr.group_id = ?
                    """, (rs, _) -> new RoleSummary(rs.getObject("id", UUID.class),
                    rs.getString("name"), rs.getBoolean("system"),
                    ancestor.name()), ancestor.id());
            for (RoleSummary r : parentRoles) roleMap.putIfAbsent(r.id(), r);
        }
        return new ArrayList<>(roleMap.values());
    }

    @Override
    public List<UserSummary> getEffectivePrincipalsForRole(UUID roleId) {
        // Users with direct assignment
        Set<String> seen = new LinkedHashSet<>();
        List<UserSummary> result = new ArrayList<>();

        List<UserSummary> direct = jdbc.query("""
                SELECT u.user_id, u.display_name, u.provider FROM user_roles ur
                JOIN users u ON u.user_id = ur.user_id WHERE ur.role_id = ?
                """, (rs, _) -> new UserSummary(rs.getString("user_id"),
                rs.getString("display_name"), rs.getString("provider")), roleId);
        for (UserSummary u : direct) {
            if (seen.add(u.userId())) result.add(u);
        }

        // Users in groups that have this role (transitively)
        // Find all groups with this role directly
        List<UUID> groupsWithRole = jdbc.query(
                "SELECT group_id FROM group_roles WHERE role_id = ?",
                (rs, _) -> rs.getObject("group_id", UUID.class), roleId);

        // For each group, find all descendant groups + self, then their members
        Set<UUID> allGroups = new LinkedHashSet<>(groupsWithRole);
        for (UUID gid : groupsWithRole) {
            collectDescendants(gid, allGroups);
        }
        for (UUID gid : allGroups) {
            List<UserSummary> members = jdbc.query("""
                    SELECT u.user_id, u.display_name, u.provider FROM user_groups ug
                    JOIN users u ON u.user_id = ug.user_id WHERE ug.group_id = ?
                    """, (rs, _) -> new UserSummary(rs.getString("user_id"),
                    rs.getString("display_name"), rs.getString("provider")), gid);
            for (UserSummary u : members) {
                if (seen.add(u.userId())) result.add(u);
            }
        }
        return result;
    }

    private void collectDescendants(UUID groupId, Set<UUID> result) {
        List<UUID> children = jdbc.query(
                "SELECT id FROM groups WHERE parent_group_id = ?",
                (rs, _) -> rs.getObject("id", UUID.class), groupId);
        for (UUID child : children) {
            if (result.add(child)) {
                collectDescendants(child, result);
            }
        }
    }

    @Override
    public List<String> getSystemRoleNames(String userId) {
        // Uses effective roles (direct + inherited via groups) filtered to system roles
        return getEffectiveRolesForUser(userId).stream()
                .filter(RoleSummary::system)
                .map(RoleSummary::name)
                .toList();
    }

    @Override
    public RbacStats getStats() {
        int userCount = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
        // "active" = users who have at least one role assigned
        int activeUserCount = jdbc.queryForObject(
                "SELECT COUNT(DISTINCT user_id) FROM user_roles", Integer.class);
        int groupCount = jdbc.queryForObject("SELECT COUNT(*) FROM groups", Integer.class);
        int roleCount = jdbc.queryForObject("SELECT COUNT(*) FROM roles", Integer.class);
        int maxDepth = computeMaxGroupDepth();
        return new RbacStats(userCount, activeUserCount, groupCount, maxDepth, roleCount);
    }

    private int computeMaxGroupDepth() {
        // Find all root groups and walk down
        List<UUID> roots = jdbc.query(
                "SELECT id FROM groups WHERE parent_group_id IS NULL",
                (rs, _) -> rs.getObject("id", UUID.class));
        int max = 0;
        for (UUID root : roots) {
            max = Math.max(max, measureDepth(root, 1));
        }
        return max;
    }

    private int measureDepth(UUID groupId, int currentDepth) {
        List<UUID> children = jdbc.query(
                "SELECT id FROM groups WHERE parent_group_id = ?",
                (rs, _) -> rs.getObject("id", UUID.class), groupId);
        if (children.isEmpty()) return currentDepth;
        int max = currentDepth;
        for (UUID child : children) {
            max = Math.max(max, measureDepth(child, currentDepth + 1));
        }
        return max;
    }

    private List<RoleSummary> getDirectRolesForUser(String userId) {
        return jdbc.query("""
                SELECT r.id, r.name, r.system FROM user_roles ur
                JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?
                """, (rs, _) -> new RoleSummary(rs.getObject("id", UUID.class),
                rs.getString("name"), rs.getBoolean("system"), "direct"), userId);
    }

    private List<GroupSummary> getDirectGroupsForUser(String userId) {
        return jdbc.query("""
                SELECT g.id, g.name FROM user_groups ug
                JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = ?
                """, (rs, _) -> new GroupSummary(rs.getObject("id", UUID.class),
                rs.getString("name")), userId);
    }
}

Do NOT commit individually — Tasks 26 form an atomic batch. Commit after Task 6 when the full project compiles.


Task 6: Auth Integration — Update Login and OIDC Flows

Files:

  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/security/UiAuthController.java
  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java
  • Modify: cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java

These controllers currently embed roles in UserInfo and pass them to JWT creation. After the change, they must:

  1. Create/upsert the UserInfo (without roles)
  2. Assign roles via RbacService.assignRoleToUser()
  3. Read system roles via RbacService.getSystemRoleNames() for JWT creation
  • Step 1: Update UiAuthController

Key changes in login():

  • Remove List<String> roles = List.of("ADMIN")
  • Upsert UserInfo without roles
  • Assign ADMIN role via rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID)
  • Read roles from rbacService.getSystemRoleNames(subject) for JWT

Key changes in refresh():

  • Roles still come from the refresh token JWT claims (no DB read needed during refresh)
  • No change needed here since refresh preserves existing claims

Read the actual file to determine exact line changes, then apply edits. The RbacService and SystemRole need to be injected.

  • Step 2: Update OidcAuthController

Key changes in callback():

  • After upserting UserInfo (without roles), resolve roles:
    • Existing user: read from rbacService.getSystemRoleNames(userId)
    • New user with OIDC claims: map claim role names to system role IDs, call rbacService.assignRoleToUser() for each
    • New user without claims: use config.defaultRoles(), map to IDs, assign via rbacService
  • Read system roles for JWT from rbacService.getSystemRoleNames(userId)

The resolveRoles method changes from returning List<String> to assigning roles via RbacService and returning system role names.

  • Step 3: Update AgentRegistrationController

In register():

  • After creating agent user, assign AGENT role: rbacService.assignRoleToUser(agentId, SystemRole.AGENT_ID)

  • JWT still gets List.of("AGENT")

  • Step 4: Verify full project compiles

mvn clean compile

Expected: BUILD SUCCESS (all UserInfo.roles() references resolved)

  • Step 5: Commit (atomic — covers Tasks 26)

This is the single commit for the entire backend RBAC model. Tasks 26 must all be done before committing since intermediate states don't compile.

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 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.

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"