# RBAC Management — Design Specification

## Goal

Implement a full RBAC management system (issue #41) with group hierarchy, role inheritance, and a management UI integrated into the admin section. Replace the flat `users.roles` text array with a proper relational model.

## References

- Functional spec: `examples/RBAC/rbac-ui-spec.md`
- Visual prototype: `examples/RBAC/rbac_management_ui.html`

---

## Backend

### Database Schema

Squash V1–V10 Flyway migrations into a single `V1__init.sql`. The `users` table drops the `roles TEXT[]` column. New tables:

```sql
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()
);

-- RBAC: all roles — system roles seeded with fixed UUIDs, custom roles created by admins
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()
);

-- Seed system roles with fixed UUIDs (stable across environments)
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);

-- RBAC: groups with self-referential hierarchy
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()
);

-- Join: roles assigned to groups (system + custom)
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)
);

-- Join: direct group membership for users
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)
);

-- Join: direct role assignments to users (system + custom)
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)
);

-- Indexes for join query performance
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);
```

Note: `roles TEXT[]` column is removed from `users`. All roles (system and custom) live in the `roles` table. System roles are seeded rows with `system = true` and fixed UUIDs — the application prevents their deletion or modification.

### System Roles

The four system roles (AGENT, VIEWER, OPERATOR, ADMIN) are:
- Seeded as rows in the `roles` table with `system = true` and fixed UUIDs
- Assigned to users via the same `user_roles` join table as custom roles
- Protected by application logic: creation, deletion, and name/scope modification are rejected
- Displayed in the UI as read-only entries (lock icon, non-deletable)
- Used by Spring Security / JWT for authorization decisions

Custom roles (`system = false`) are application-defined and have no effect on Spring Security — they serve the RBAC management model only (for future permission expansion).

The `scope` field distinguishes role domains: system roles use `system-wide`, custom roles can use descriptive scopes like `monitoring:read`, `config:write` for future permission gating.

### Domain Model (Java)

```java
// Existing, modified — drop roles field
public record UserInfo(
    String userId,
    String provider,
    String email,
    String displayName,
    Instant createdAt
) {}

// New — enriched user for admin API responses
public record UserDetail(
    String userId,
    String provider,
    String email,
    String displayName,
    Instant createdAt,
    List<RoleSummary> directRoles,    // from user_roles join (system + custom)
    List<GroupSummary> directGroups,  // from user_groups join
    List<RoleSummary> effectiveRoles, // computed: union of direct + inherited via groups
    List<GroupSummary> effectiveGroups // computed: direct groups + their ancestor chain
) {}

public record GroupDetail(
    UUID id,
    String name,
    UUID parentGroupId,              // nullable
    Instant createdAt,
    List<RoleSummary> directRoles,
    List<RoleSummary> effectiveRoles, // direct + inherited from parent chain
    List<UserSummary> members,       // direct members
    List<GroupSummary> childGroups
) {}

public record RoleDetail(
    UUID id,
    String name,
    String description,
    String scope,
    boolean system,                  // true for AGENT/VIEWER/OPERATOR/ADMIN
    Instant createdAt,
    List<GroupSummary> assignedGroups,
    List<UserSummary> directUsers,
    List<UserSummary> effectivePrincipals // all users who hold this role
) {}

// Summaries for embedding in detail responses
public record UserSummary(String userId, String displayName, String provider) {}
public record GroupSummary(UUID id, String name) {}
public record RoleSummary(UUID id, String name, boolean system, String source) {}
// source: "direct" | group name (for inherited)
```

### Inheritance Logic

Server-side computation in a service class (e.g., `RbacService`):

1. **Effective groups for user**: Start from `user_groups` (direct memberships), then for each group walk `parent_group_id` chain upward to collect all ancestor groups. The union is every group the user is transitively a member of.
2. **Effective roles for user**: Direct `user_roles` + all `group_roles` for every effective group. Both system and custom roles flow through the same path.
3. **Effective roles for group**: Direct `group_roles` + inherited from parent chain.
4. **Effective principals for role**: All users who hold the role directly + all users in any group that has the role (transitively).

No role negation — roles only grant, never deny.

**Cycle detection**: When setting `parent_group_id` on a group, the application must walk the proposed parent chain upward and reject the update if it would create a cycle (i.e., the group appears in its own ancestor chain). Return HTTP 409 Conflict.

### Auth Integration

`JwtService` and `SecurityConfig` read system roles from `user_roles` joined to `roles WHERE system = true`, instead of `users.roles`. The `UserRepository` methods that currently read/write `users.roles` are updated to use the join table. JWT claims remain unchanged (`roles: ["ADMIN", "VIEWER"]`).

OIDC auto-signup: When a user is auto-registered via OIDC token exchange, they get a row in `users` with `provider = "oidc:<issuer>"` and a default system role (VIEWER) via `user_roles`. No group membership by default.

### API Endpoints

All under `/api/v1/admin/` prefix, protected by `@PreAuthorize("hasRole('ADMIN')")`.

The existing `PUT /users/{userId}/roles` bulk endpoint is removed. Role assignments use individual add/remove endpoints.

All mutation endpoints log to the `AuditService` (category: `USER_MGMT` for user operations, `RBAC` for group/role operations).

**Users** — response type: `UserDetail`

| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | `/users` | List all users with effective roles/groups | — |
| GET | `/users/{id}` | Full user detail | — |
| POST | `/users/{id}/roles/{roleId}` | Assign role to user (system or custom) | — |
| DELETE | `/users/{id}/roles/{roleId}` | Remove role from user | — |
| POST | `/users/{id}/groups/{groupId}` | Add user to group | — |
| DELETE | `/users/{id}/groups/{groupId}` | Remove user from group | — |
| DELETE | `/users/{id}` | Delete user | — |

**Groups** — response type: `GroupDetail`

| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | `/groups` | List all groups with hierarchy | — |
| GET | `/groups/{id}` | Full group detail | — |
| POST | `/groups` | Create group | `{ name, parentGroupId? }` |
| PUT | `/groups/{id}` | Update group | `{ name?, parentGroupId? }` — returns 409 on cycle |
| DELETE | `/groups/{id}` | Delete group — cascades role/member associations; child groups become top-level (parent set to null) | — |
| POST | `/groups/{id}/roles/{roleId}` | Assign role to group | — |
| DELETE | `/groups/{id}/roles/{roleId}` | Remove role from group | — |

**Roles** — response type: `RoleDetail`

| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | `/roles` | List all roles (system + custom) | — |
| GET | `/roles/{id}` | Role detail | — |
| POST | `/roles` | Create custom role | `{ name, description?, scope? }` |
| PUT | `/roles/{id}` | Update custom role (rejects system roles) | `{ name?, description?, scope? }` |
| DELETE | `/roles/{id}` | Delete custom role (rejects system roles) | — |

**Dashboard:**

| Method | Path | Description |
|---|---|---|
| GET | `/rbac/stats` | `{ userCount, activeUserCount, groupCount, maxGroupDepth, roleCount }` |

---

## Frontend

### Routing

New route at `/admin/rbac` in `router.tsx`, lazy-loaded:

```tsx
const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage })));
// ...
{ path: 'admin/rbac', element: <Suspense fallback={null}><RbacPage /></Suspense> }
```

Update `AppSidebar` ADMIN_LINKS to add `{ to: '/admin/rbac', label: 'User Management' }`.

### Component Structure

```
pages/admin/rbac/
├── RbacPage.tsx                 ← ADMIN role gate + tab navigation
├── RbacPage.module.css          ← All RBAC-specific styles
├── DashboardTab.tsx             ← Stat cards + inheritance diagram
├── UsersTab.tsx                 ← Split pane orchestrator
├── GroupsTab.tsx                ← Split pane orchestrator
├── RolesTab.tsx                 ← Split pane orchestrator
├── components/
│   ├── EntityListPane.tsx       ← Reusable: search input + scrollable card list
│   ├── EntityCard.tsx           ← Single list row: avatar, name, meta, tags, status dot
│   ├── UserDetail.tsx           ← Header, fields, groups, effective roles, group tree
│   ├── GroupDetail.tsx          ← Header, fields, members, children, roles, hierarchy
│   ├── RoleDetail.tsx           ← Header, fields, assigned groups/users, effective principals
│   ├── InheritanceChip.tsx      ← Chip with dashed border + "↑ Source" annotation
│   ├── GroupTree.tsx            ← Indented tree with corner connectors
│   ├── EntityAvatar.tsx         ← Circle (user), rounded-square (group/role), color by type
│   ├── OidcBadge.tsx            ← Small badge showing OIDC provider origin
│   ├── InheritanceDiagram.tsx   ← Three-column Groups→Roles→Users read-only diagram
│   └── InheritanceNote.tsx      ← Green-bordered explanation block
api/queries/admin/
│   └── rbac.ts                  ← useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats + mutation hooks
```

### Tab Navigation

`RbacPage` uses a horizontal tab bar (Dashboard | Users | Groups | Roles) with URL-synced active state via query parameter (`?tab=users`). Each tab renders its content below the tab bar in the full main panel area.

### Split Pane Layout

Users, Groups, and Roles tabs share the same layout:
- **Left (52%)**: `EntityListPane` with search input + scrollable entity cards
- **Right (48%)**: Detail pane showing selected entity, or empty state prompt
- Resizable via `ResizableDivider` (existing shared component)

### Entity Card Patterns

**User card:** Circle avatar (initials, blue tint) + name + email/primary-group meta + role tags (amber) + group tags (green) + status dot + OIDC badge if `provider !== "local"`

**Group card:** Rounded-square avatar (initials, green/amber/red by domain) + name + parent/member-count meta + role tags (direct solid, inherited faded+italic)

**Role card:** Rounded-square avatar (initials, amber tint) + name + description/assignment-count meta + assigned-to tags. System roles show a lock icon.

### Badge/Chip Styling

Following the spec and existing CSS token system:

| Chip type | Background | Border | Text |
|---|---|---|---|
| Role (direct) | `var(--amber-dim)` | solid `var(--amber)` | amber text |
| Role (inherited) | transparent | dashed `var(--amber)` | faded amber, italic |
| Group | `var(--green-dim)` / `#E1F5EE` | solid green | green text |
| OIDC badge | `var(--cyan-dim)` | solid cyan | cyan text, shows provider |
| System role | Same as role but with lock icon | — | — |

Inherited role chips include `↑ GroupName` annotation in the detail pane.

### OIDC Badge

Displayed on user cards and user detail when `provider !== "local"`. Shows a small cyan-tinted pill with the provider name (e.g., "OIDC" or the issuer hostname). Positioned after the user's name in the card, and as a field in the detail pane.

### Search

Client-side filtering on entity list panes — filter by any visible text (name, email, group, role). Sufficient for the expected user count.

### State Management

- React Query for all server state (users, groups, roles, stats)
- Local `useState` for selected entity, search filter, active tab
- Mutations invalidate related queries (e.g., updating a user's groups invalidates both user and group queries)

---

## Migration Strategy

1. Delete all V1–V10 migration files
2. Create single `V1__init.sql` containing the full consolidated schema
3. Deployed environments: drop and recreate the database (data loss accepted)
4. CI/CD: no special handling — clean database on deploy
5. Update `application.yml` if needed: `spring.flyway.clean-on-validation-error: true` or manual DB drop

---

## Out of Scope

- Permission-based access control (custom roles don't gate endpoints — system roles do)
- Audit log panel within RBAC (existing audit log page covers this)
- Bulk import/export of users or groups
- SCIM provisioning
- Role negation / deny rules
