# 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"
```
