Files
cameleer-server/docs/superpowers/specs/2026-03-17-rbac-management-design.md
hsiegeln 8ad0016a8e
Some checks failed
CI / build (push) Failing after 40s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
refactor: rename group/groupName to application/applicationName
The execution-related "group" concept actually represents the
application name. Rename all Java fields, API parameters, and frontend
types from groupName→applicationName and group→application for clarity.

- Java records: ExecutionSummary, ExecutionDetail, ExecutionDocument,
  ExecutionRecord, ProcessorRecord
- API params: SearchRequest.group→application, SearchController
  @RequestParam group→application
- Services: IngestionService, DetailService, SearchIndexer, StatsStore
- Frontend: schema.d.ts, Dashboard, ExchangeDetail, RouteDetail,
  executions query hooks

Database column names (group_name) and OpenSearch field names are
unchanged — only the API-facing Java/TS field names are renamed.

RBAC group references (groups table, GroupRepository, GroupsTab) are
a separate domain concept and are NOT affected by this change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:21:38 +01:00

15 KiB
Raw Blame History

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 V1V10 Flyway migrations into a single V1__init.sql. The users table drops the roles TEXT[] column. New tables:

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)

// 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:

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.

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