Skip to content

feat: role-based access control (RBAC) with per-workspace permission matrix #1608

@ilblackdragon

Description

@ilblackdragon

Problem / Motivation

All authenticated users currently have identical permissions. The auth_middleware in src/channels/web/auth.rs checks token validity but nothing else — any valid token can call any endpoint. The workspace_members.role column from Issue #2 stores roles but nothing enforces them.

This means:

Depends on #1605 (User Management) and #1607 (Workspaces).

Proposed Solution

Permission Model

Four roles in ascending privilege order:

Role Description
viewer Read-only access to workspace data (jobs, routines, memory, settings)
member Full read/write within the workspace (create jobs, write memory, manage own routines)
admin Member + manage workspace members, update workspace settings
owner Admin + delete workspace, transfer ownership, manage admins

Additionally, a system-level superadmin flag on users for cross-workspace operations (user suspension, global settings).

Database Changes

ALTER TABLE users ADD COLUMN is_superadmin BOOLEAN NOT NULL DEFAULT FALSE;

No other schema changes needed — workspace_members.role already exists from Issue #2.

Permission Matrix

Operation viewer member admin owner superadmin
View workspace data (jobs, routines, memory)
Search memory
Send chat messages
Create/cancel jobs
Write memory
Manage own routines
Manage workspace settings
Invite/remove members
Change member roles (up to admin)
Promote to admin
Archive/delete workspace
Transfer ownership
Create/suspend users
View all workspaces

Implementation: Permission Middleware Layer

Add a permission check layer that sits between auth and handlers:

// src/channels/web/permissions.rs

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Role {
    Viewer,
    Member,
    Admin,
    Owner,
}

#[derive(Debug, Clone, Copy)]
pub enum Permission {
    WorkspaceRead,
    WorkspaceWrite,
    WorkspaceManageMembers,
    WorkspaceManageSettings,
    WorkspaceManageAdmins,
    WorkspaceDelete,
    SystemManageUsers,
    SystemViewAll,
}

impl Role {
    pub fn has_permission(&self, perm: Permission) -> bool {
        // Matrix lookup
    }
}

/// Axum extractor that verifies the authenticated user has the required
/// role in the target workspace. Returns 403 Forbidden on failure.
pub struct RequireRole<const ROLE: u8>;

// Or a middleware function approach:
pub async fn require_workspace_role(
    user: &UserIdentity,
    workspace_id: Uuid,
    min_role: Role,
    store: &dyn Database,
) -> Result<(), (StatusCode, &'static str)> {
    if user.is_superadmin {
        return Ok(());
    }
    let role = store.get_member_role(workspace_id, user.db_user_id).await?;
    match role {
        Some(r) if r >= min_role => Ok(()),
        Some(_) => Err((StatusCode::FORBIDDEN, "Insufficient permissions")),
        None => Err((StatusCode::FORBIDDEN, "Not a workspace member")),
    }
}

Handler Integration Pattern

Handlers call the permission check after extracting auth and workspace context:

pub async fn routines_delete_handler(
    State(state): State<Arc<GatewayState>>,
    AuthenticatedUser(user): AuthenticatedUser,
    WorkspaceContext(ws): WorkspaceContext,
    Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
    // Personal workspace: user owns everything
    // Team workspace: check role
    if let Some(ref ws) = ws {
        require_workspace_role(&user, ws.id, Role::Member, store).await?;
    }
    // ... existing logic
}

Personal Workspace Permissions

For personal workspace (no workspace_id), the user is implicitly owner of all their own data. No DB lookup needed — just verify user_id matches, same as today.

API Token Scopes (Future-Ready)

The api_tokens.scopes column from Issue #1 can optionally restrict a token below its user's role. For example, a token with scopes: ["read"] on a member user acts as viewer. This issue should wire the plumbing but not implement fine-grained scope parsing — just ensure the architecture supports it.

Architecture Notes

  • Permission checks are NOT in the auth middleware. Auth middleware validates identity (who are you). Permission checks happen in handlers or a per-route middleware layer (are you allowed to do this). Keep these concerns separated.
  • Role comparison uses PartialOrd: Viewer < Member < Admin < Owner. This lets you do role >= Role::Member instead of matching each variant.
  • Cache workspace membership in GatewayState or alongside the auth cache from Issue Move whatsapp channel source to channels-src/ for consistency #1. Membership changes less frequently than requests. A 60s TTL LRU keyed on (user_id, workspace_id) avoids per-request DB hits.
  • Superadmin is intentionally not a role — it's a user flag. A superadmin still needs to be a member of a workspace to interact with it normally, but can bypass membership checks for administrative operations.

Code Pointers

  • src/channels/web/auth.rsauth_middleware, AuthenticatedUser — identity extraction (don't modify, build on top)
  • src/channels/web/handlers/jobs.rsjobs_cancel_handler, jobs_restart_handler — examples of mutation endpoints that need Role::Member check
  • src/channels/web/handlers/routines.rsroutines_delete_handler, routines_toggle_handler — need Role::Member for own routines, Role::Admin for others'
  • src/channels/web/handlers/settings.rs — workspace settings need Role::Admin
  • src/channels/web/server.rsGatewayState — add membership cache here
  • src/db/mod.rsWorkspaceMgmtStore.get_member_role() from Issue feat: adding Web UI #2 — the DB method permissions will call

Acceptance Criteria

  • is_superadmin column added to users table (both backends)
  • Role enum with PartialOrd ordering (Viewer < Member < Admin < Owner)
  • Permission enum covering all operations in the permission matrix
  • require_workspace_role() function (or equivalent) callable from handlers
  • All workspace-scoped mutation endpoints check Role::Member minimum
  • Workspace member management endpoints check Role::Admin minimum
  • Workspace deletion/archival checks Role::Owner
  • User management endpoints (create, suspend) check is_superadmin
  • viewer role can read but not write memory, cannot send chat, cannot create jobs
  • member role can do everything except manage members or workspace settings
  • admin can manage members (up to admin) and workspace settings
  • owner can promote to admin, transfer ownership, delete workspace
  • Personal workspace (no workspace_id) bypasses role checks — user is implicit owner
  • superadmin bypasses all workspace role checks
  • Permission denied returns 403 with clear error message (not 404)
  • Membership cache with configurable TTL, eviction on role change
  • Unit tests for Role::has_permission() — test every cell in the matrix
  • Integration tests: viewer tries mutation → 403, member succeeds, role change reflected
  • cargo clippy zero warnings, cargo fmt clean

Pitfalls & Landmines

  • Don't put permission checks in auth_middleware. Auth is "who are you?" — permissions are "can you do this?". Mixing them makes it impossible to have endpoints with different permission requirements on the same route prefix.
  • Role escalation: an admin must NOT be able to promote someone to owner. Only owner can do that. Enforce max_assignable_role based on the caller's role: members can't change roles at all, admins can assign up to admin, owners can assign anything.
  • Ownership transfer must be atomic: old owner becomes admin, new owner becomes owner, in a single transaction. If the process crashes between the two updates, you have zero owners or two owners.
  • Don't forget SSE subscriptions: a viewer should receive workspace events (they can read), but a non-member should not. The SSE subscription logic in src/channels/web/sse.rs needs to verify membership, not just user_id matching.
  • The superadmin flag is a security-critical column. Default to false. The first user created (or migrated from env-var) should be superadmin. Add an explicit startup log line when a superadmin user is active.
  • Race condition on role checks: if an admin removes a member while that member has an in-flight request, the request should still complete (check at handler entry, not at DB write). Don't add permission checks deep in the DB layer.

Non-Goals

  • Fine-grained API token scopes — the column exists, the architecture supports it, but parsing/enforcement is a follow-up.
  • Custom roles — only the four built-in roles. Custom role definitions are out of scope.
  • Per-resource permissions (e.g., "this user can see job X but not job Y within the same workspace") — out of scope.
  • Admin UI for role management — that's Issue feat: Sandbox jobs #4.

Part 3 of 4 — depends on #1605 (User Management) and #1607 (Workspaces). See also: Admin Panel (#4)

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Lower priority — UX/polishenhancementNew feature or requestrisk: highSafety, secrets, auth, or critical infrastructurescope: channel/webWeb gateway channelscope: dbDatabase trait / abstraction

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions