Role-Based Access Control for Internal Tools

How to implement RBAC for internal operations dashboards with permission models, route guards, and component-level access control in React and TypeScript.

technical5 min readBy Klivvr Engineering
Share:

Internal tools handle sensitive operations — viewing customer data, modifying account settings, managing deployments, and investigating incidents. Not every team member should have access to every capability. A junior support agent should not be able to modify production configurations. A marketing analyst should not see individual customer financial data. Role-based access control (RBAC) ensures that users can only access the features and data appropriate to their role.

This article covers the RBAC implementation in Klivvr's Web Ops Console, from the permission model to route-level and component-level access enforcement.

Permission Model

The RBAC model defines roles, permissions, and the mapping between them. Permissions are fine-grained actions. Roles are named collections of permissions assigned to users.

type Permission =
  | "dashboard:view"
  | "services:view"
  | "services:restart"
  | "services:deploy"
  | "logs:view"
  | "logs:export"
  | "customers:view"
  | "customers:view_pii"
  | "customers:modify"
  | "incidents:view"
  | "incidents:create"
  | "incidents:resolve"
  | "config:view"
  | "config:modify"
  | "users:manage";
 
interface Role {
  name: string;
  description: string;
  permissions: Permission[];
}
 
const roles: Record<string, Role> = {
  viewer: {
    name: "Viewer",
    description: "Read-only access to dashboards and metrics",
    permissions: ["dashboard:view", "services:view", "logs:view", "incidents:view"],
  },
  operator: {
    name: "Operator",
    description: "Can manage services and respond to incidents",
    permissions: [
      "dashboard:view", "services:view", "services:restart",
      "logs:view", "logs:export", "incidents:view",
      "incidents:create", "incidents:resolve",
    ],
  },
  support: {
    name: "Support",
    description: "Can view customer data for support purposes",
    permissions: [
      "dashboard:view", "customers:view", "customers:view_pii",
      "incidents:view",
    ],
  },
  admin: {
    name: "Admin",
    description: "Full access to all features",
    permissions: [
      "dashboard:view", "services:view", "services:restart",
      "services:deploy", "logs:view", "logs:export",
      "customers:view", "customers:view_pii", "customers:modify",
      "incidents:view", "incidents:create", "incidents:resolve",
      "config:view", "config:modify", "users:manage",
    ],
  },
};

Auth Context and Hooks

A React context provides the current user's permissions to the entire component tree. Custom hooks enable permission checks at any level.

import { createContext, useContext, useMemo } from "react";
 
interface AuthUser {
  id: string;
  name: string;
  email: string;
  role: string;
  permissions: Permission[];
}
 
interface AuthContextValue {
  user: AuthUser | null;
  hasPermission: (permission: Permission) => boolean;
  hasAnyPermission: (permissions: Permission[]) => boolean;
  hasAllPermissions: (permissions: Permission[]) => boolean;
}
 
const AuthContext = createContext<AuthContextValue | null>(null);
 
function AuthProvider({ children }: { children: React.ReactNode }) {
  const user = useCurrentUser(); // From auth session
 
  const value = useMemo<AuthContextValue>(() => {
    const permissions = new Set(user?.permissions ?? []);
 
    return {
      user,
      hasPermission: (p) => permissions.has(p),
      hasAnyPermission: (ps) => ps.some((p) => permissions.has(p)),
      hasAllPermissions: (ps) => ps.every((p) => permissions.has(p)),
    };
  }, [user]);
 
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
 
function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be inside AuthProvider");
  return ctx;
}
 
function usePermission(permission: Permission): boolean {
  const { hasPermission } = useAuth();
  return hasPermission(permission);
}

Route Guards

Route-level access control prevents unauthorized users from navigating to restricted pages.

import { Navigate, Outlet } from "react-router-dom";
 
function ProtectedRoute({ required }: { required: Permission | Permission[] }) {
  const { user, hasPermission, hasAnyPermission } = useAuth();
 
  if (!user) return <Navigate to="/login" replace />;
 
  const permissions = Array.isArray(required) ? required : [required];
  const hasAccess = Array.isArray(required)
    ? hasAnyPermission(permissions)
    : hasPermission(required);
 
  if (!hasAccess) return <AccessDenied />;
 
  return <Outlet />;
}
 
// Router configuration
function AppRouter() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
 
      {/* Dashboard — available to all authenticated users */}
      <Route element={<ProtectedRoute required="dashboard:view" />}>
        <Route path="/" element={<DashboardPage />} />
      </Route>
 
      {/* Services — requires service permissions */}
      <Route element={<ProtectedRoute required="services:view" />}>
        <Route path="/services" element={<ServicesPage />} />
      </Route>
 
      {/* Customer data — requires customer permissions */}
      <Route element={<ProtectedRoute required="customers:view" />}>
        <Route path="/customers" element={<CustomersPage />} />
      </Route>
 
      {/* Config — admin only */}
      <Route element={<ProtectedRoute required="config:modify" />}>
        <Route path="/config" element={<ConfigPage />} />
      </Route>
 
      {/* User management — admin only */}
      <Route element={<ProtectedRoute required="users:manage" />}>
        <Route path="/admin/users" element={<UserManagementPage />} />
      </Route>
    </Routes>
  );
}

Component-Level Access Control

Within a page, individual components may need different permission levels. A wrapper component conditionally renders content based on permissions.

function RequirePermission({
  permission,
  children,
  fallback = null,
}: {
  permission: Permission;
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  const hasAccess = usePermission(permission);
  return hasAccess ? <>{children}</> : <>{fallback}</>;
}
 
// Usage: hide the restart button for users without restart permission
function ServiceActions({ serviceId }: { serviceId: string }) {
  return (
    <div className="flex gap-2">
      <RequirePermission permission="services:view">
        <button className="btn-secondary">View Logs</button>
      </RequirePermission>
 
      <RequirePermission permission="services:restart">
        <button className="btn-warning">Restart Service</button>
      </RequirePermission>
 
      <RequirePermission permission="services:deploy">
        <button className="btn-primary">Deploy</button>
      </RequirePermission>
    </div>
  );
}
 
// Usage: mask PII for users without PII permission
function CustomerDetails({ customer }: { customer: Customer }) {
  const canViewPII = usePermission("customers:view_pii");
 
  return (
    <div>
      <div>Name: {customer.name}</div>
      <div>Email: {canViewPII ? customer.email : maskEmail(customer.email)}</div>
      <div>Phone: {canViewPII ? customer.phone : "****" + customer.phone.slice(-4)}</div>
      <div>Account: {customer.accountId}</div>
    </div>
  );
}
 
function maskEmail(email: string): string {
  const [local, domain] = email.split("@");
  return `${local[0]}***@${domain}`;
}

Audit Logging

Every action taken through the console is logged for security auditing, including who performed the action, what permission was required, and whether access was granted or denied.

interface AuditEntry {
  userId: string;
  action: string;
  resource: string;
  permission: Permission;
  granted: boolean;
  timestamp: Date;
  metadata?: Record<string, unknown>;
}
 
async function auditLog(entry: AuditEntry): Promise<void> {
  await fetch("/api/audit", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(entry),
  });
}
 
// Wrapper for audited actions
async function performAuditedAction(
  permission: Permission,
  action: string,
  resource: string,
  fn: () => Promise<unknown>
): Promise<unknown> {
  const { user, hasPermission } = getAuthContext();
  const granted = hasPermission(permission);
 
  await auditLog({
    userId: user!.id,
    action,
    resource,
    permission,
    granted,
    timestamp: new Date(),
  });
 
  if (!granted) throw new Error("Access denied");
  return fn();
}

Conclusion

RBAC for internal tools is not just about security — it is about trust. Operations teams trust the console because they know that accidental or unauthorized actions are prevented by the permission model. Auditors trust the system because every action is logged. And administrators trust the role model because it provides clear, maintainable boundaries between different access levels. The Web Ops Console's RBAC implementation ensures that the right people have access to the right capabilities, no more and no less.

Related Articles

business

Build vs Buy for Internal Operations Tools

A framework for deciding whether to build or buy internal operations tools, covering total cost of ownership, customization needs, and the strategic value of purpose-built tooling.

6 min read
technical

Data Visualization Patterns for Ops Dashboards

How to choose and implement effective data visualizations for operations dashboards, covering chart selection, color systems, responsive layouts, and accessibility.

5 min read