Skip to content

Role-Based Access Control (RBAC)

Priority: 🔵 LONG-TERM Estimated Time: 8 hours Risk Level: LOW Impact: Fine-grained access control for MCP tools

← Back to Enhancements


Issue Description

Current access control:

  • ✅ JWT-based authentication
  • ✅ User context extraction
  • ❌ No role-based access control
  • ❌ All authenticated users have same permissions
  • ❌ No tool-level permissions

Proposed Solution

Implement RBAC for MCP tools:

  • Extract roles from JWT groups claim
  • Define permissions per tool
  • Add permission checking middleware
  • Support role hierarchies

Implementation

Note: Redis is included in the local development environment when using ./scripts/local (part of the Docker stack).

Define Role System

Create src/types/rbac.ts:

typescript
export type Role = "admin" | "developer" | "operator" | "viewer";

export type Permission =
  | "tools:execute"
  | "tools:system_status"
  | "tools:user_info"
  | "resources:read"
  | "prompts:use";

export interface RolePermissions {
  [role: string]: Permission[];
}

export const rolePermissions: RolePermissions = {
  admin: [
    "tools:execute",
    "tools:system_status",
    "tools:user_info",
    "resources:read",
    "prompts:use",
  ],
  developer: [
    "tools:execute",
    "tools:user_info",
    "resources:read",
    "prompts:use",
  ],
  operator: [
    "tools:system_status",
    "resources:read",
  ],
  viewer: [
    "resources:read",
  ],
};

Permission Checking

Update src/mcp/tools/index.ts:

typescript
import { hasPermission } from "../rbac.js";

export function registerTools(server: McpServer): void {
  // Register healthcheck (public, no auth required)
  registerHealthcheck(server);

  // Register with permission checks
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
    const userContext = extra.userContext as UserContext | undefined;

    if (!userContext) {
      throw new Error("Authentication required");
    }

    // Check tool-specific permission
    const toolName = request.params.name;
    const requiredPermission = getToolPermission(toolName);

    if (requiredPermission && !hasPermission(userContext, requiredPermission)) {
      throw new Error(`Permission denied: ${requiredPermission} required`);
    }

    // Execute tool
    return executeToolWithPermissions(toolName, request.params.arguments, userContext);
  });
}

function getToolPermission(toolName: string): Permission | null {
  const toolPermissions: Record<string, Permission> = {
    system_status: "tools:system_status",
    user_info: "tools:user_info",
    // ... other tools
  };

  return toolPermissions[toolName] ?? "tools:execute";
}

RBAC Utility

Create src/services/rbac.ts:

typescript
import type { UserContext } from "../types/index.js";
import type { Permission } from "../types/rbac.js";
import { rolePermissions } from "../types/rbac.js";

export function hasPermission(user: UserContext, permission: Permission): boolean {
  // Extract roles from groups claim
  const userRoles = user.groups ?? [];

  // Check if any user role has the required permission
  return userRoles.some((role) => {
    const permissions = rolePermissions[role];
    return permissions?.includes(permission) ?? false;
  });
}

export function hasAnyPermission(user: UserContext, permissions: Permission[]): boolean {
  return permissions.some((permission) => hasPermission(user, permission));
}

export function hasAllPermissions(user: UserContext, permissions: Permission[]): boolean {
  return permissions.every((permission) => hasPermission(user, permission));
}

Configuration

Create src/config/rbac.ts:

typescript
export const rbacConfig = {
  enabled: process.env.RBAC_ENABLED !== "false",

  // Map OIDC groups to internal roles
  groupRoleMapping: {
    "admins": "admin",
    "developers": "developer",
    "ops": "operator",
    "users": "viewer",
  },

  // Default role if no groups match
  defaultRole: process.env.RBAC_DEFAULT_ROLE ?? "viewer",
} as const;

Add to .env.example:

env
# RBAC Configuration
RBAC_ENABLED=true
RBAC_DEFAULT_ROLE=viewer

Role Definitions

Admin

Full System Access

Permissions:

  • Execute all tools
  • View system status
  • Access user information
  • Read all resources
  • Use all prompts

Use Cases:

  • System administrators
  • Platform operators
  • Security team members

Developer

Development Access

Permissions:

  • Execute custom tools
  • View user information
  • Read resources
  • Use prompts

Use Cases:

  • Application developers
  • Integration engineers
  • API consumers

Operator

Operations Access

Permissions:

  • View system status
  • Read resources (monitoring data)

Use Cases:

  • SRE team members
  • Operations staff
  • Monitoring systems

Viewer

Read-Only Access

Permissions:

  • Read resources only

Use Cases:

  • Auditors
  • Stakeholders
  • Reporting systems

Testing

Create comprehensive tests in src/services/rbac.test.ts:

typescript
describe("RBAC", () => {
  it("should allow admin to execute all tools", async () => {
    const user: UserContext = {
      sub: "user-123",
      groups: ["admins"],
    };

    expect(hasPermission(user, "tools:execute")).toBe(true);
    expect(hasPermission(user, "tools:system_status")).toBe(true);
  });

  it("should deny viewer from executing system_status", async () => {
    const user: UserContext = {
      sub: "user-456",
      groups: ["users"],
    };

    expect(hasPermission(user, "tools:system_status")).toBe(false);
    expect(hasPermission(user, "resources:read")).toBe(true);
  });

  it("should handle multiple roles correctly", async () => {
    const user: UserContext = {
      sub: "user-789",
      groups: ["developers", "ops"],
    };

    // Should have permissions from both roles
    expect(hasPermission(user, "tools:execute")).toBe(true);
    expect(hasPermission(user, "tools:system_status")).toBe(true);
  });

  it("should use default role for users with no groups", async () => {
    const user: UserContext = {
      sub: "user-000",
      groups: [],
    };

    // Should have viewer permissions
    expect(hasPermission(user, "resources:read")).toBe(true);
    expect(hasPermission(user, "tools:execute")).toBe(false);
  });
});

IdP Configuration

Configure Groups in Your IdP

Authentik

  1. Navigate to Directory → Groups
  2. Create groups: admins, developers, ops, users
  3. Assign users to appropriate groups
  4. In Application settings, ensure groups are included in token claims

Auth0

  1. Navigate to User Management → Roles
  2. Create roles matching your group names
  3. Assign permissions to roles
  4. Add "Add user roles to tokens" rule

Keycloak

  1. Navigate to Groups
  2. Create groups with matching names
  3. In Client Scopes, map groups to JWT groups claim
  4. Assign users to groups

Monitoring

Add Prometheus metrics:

typescript
export const rbacDenials = new promClient.Counter({
  name: "rbac_denials_total",
  help: "Total RBAC permission denials",
  labelNames: ["role", "permission", "tool"],
});

export const rbacChecks = new promClient.Counter({
  name: "rbac_checks_total",
  help: "Total RBAC permission checks",
  labelNames: ["result"],
});

Set up alerts:

promql
# High rate of permission denials
rate(rbac_denials_total[5m]) > 50

# Users with no valid roles
rate(rbac_checks_total{result="no_role"}[5m]) > 10

Tool-Specific Permissions

Define granular permissions per tool:

typescript
const toolPermissions: Record<string, Permission> = {
  // System tools (admin only)
  system_status: "tools:system_status",
  server_metrics: "tools:system_status",

  // User tools (developer+)
  user_info: "tools:user_info",

  // General tools (all authenticated)
  healthcheck: null, // public

  // Custom tools (developer+)
  execute_query: "tools:execute",
  run_analysis: "tools:execute",
};

Dynamic Permission Loading

For advanced scenarios, load permissions from database:

typescript
export async function loadDynamicPermissions(): Promise<RolePermissions> {
  const redis = getRedisClient();
  const permissions = await redis.get("rbac:permissions");

  if (permissions) {
    return JSON.parse(permissions);
  }

  return rolePermissions; // fallback to static
}

Migration Path

Phase 1: Add Roles (No Enforcement)

  • Add role extraction
  • Log role information
  • Monitor for missing roles

Phase 2: Soft Enforcement (Warn Only)

  • Check permissions
  • Log denials
  • Allow all requests (backwards compatible)

Phase 3: Full Enforcement

  • Enforce permissions
  • Return 403 for unauthorized requests
  • Monitor denial rates


← Back to Enhancements

Released under the MIT License.