← Back to Blog
Role-Based Access Control in Modern Web Apps

When we built Catalyst PSA, we needed granular access control: project managers can edit their projects, team members can log time, admins can do everything, and clients can view reports. Simple role checks wouldn't cut it.

After building RBAC systems for multiple enterprise applications, here's a flexible approach that scales from simple to complex authorization requirements.

RBAC Fundamentals

Role-Based Access Control has three core concepts:

  • Users: People using the system
  • Roles: Named collections of permissions (Admin, Manager, Employee)
  • Permissions: Specific actions (create_project, edit_timesheet, view_reports)

Database Schema

-- Users
CREATE TABLE users (
  id UUID PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  name VARCHAR(255) NOT NULL
);
 
-- Roles
CREATE TABLE roles (
  id UUID PRIMARY KEY,
  name VARCHAR(100) UNIQUE NOT NULL,
  description TEXT
);
 
-- Permissions
CREATE TABLE permissions (
  id UUID PRIMARY KEY,
  name VARCHAR(100) UNIQUE NOT NULL,
  resource VARCHAR(100),  -- e.g., 'project', 'timesheet'
  action VARCHAR(100)      -- e.g., 'create', 'read', 'update', 'delete'
);
 
-- Role has many permissions
CREATE TABLE role_permissions (
  role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
  permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
  PRIMARY KEY (role_id, permission_id)
);
 
-- User has many roles (per tenant for multi-tenancy)
CREATE TABLE user_roles (
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
  tenant_id UUID NOT NULL,
  PRIMARY KEY (user_id, role_id, tenant_id)
);

Seed Data: Standard Roles and Permissions

// scripts/seed-rbac.ts
async function seedRBAC() {
  // Create permissions
  const permissions = await db.insert(permissionsTable).values([
    // Project permissions
    { name: 'projects:create', resource: 'project', action: 'create' },
    { name: 'projects:read', resource: 'project', action: 'read' },
    { name: 'projects:update', resource: 'project', action: 'update' },
    { name: 'projects:delete', resource: 'project', action: 'delete' },
 
    // Timesheet permissions
    { name: 'timesheets:create', resource: 'timesheet', action: 'create' },
    { name: 'timesheets:read', resource: 'timesheet', action: 'read' },
    { name: 'timesheets:update', resource: 'timesheet', action: 'update' },
    { name: 'timesheets:approve', resource: 'timesheet', action: 'approve' },
 
    // Report permissions
    { name: 'reports:view', resource: 'report', action: 'view' },
    { name: 'reports:export', resource: 'report', action: 'export' },
 
    // User management
    { name: 'users:create', resource: 'user', action: 'create' },
    { name: 'users:update', resource: 'user', action: 'update' },
    { name: 'users:delete', resource: 'user', action: 'delete' },
  ]).returning();
 
  // Create roles
  const adminRole = await db.insert(rolesTable).values({
    name: 'admin',
    description: 'Full system access',
  }).returning();
 
  const managerRole = await db.insert(rolesTable).values({
    name: 'manager',
    description: 'Manage projects and approve timesheets',
  }).returning();
 
  const employeeRole = await db.insert(rolesTable).values({
    name: 'employee',
    description: 'Log time and view assigned projects',
  }).returning();
 
  // Assign permissions to roles
  await db.insert(rolePermissionsTable).values([
    // Admin gets all permissions
    ...permissions.map(p => ({
      roleId: adminRole[0].id,
      permissionId: p.id,
    })),
 
    // Manager gets project and timesheet permissions
    { roleId: managerRole[0].id, permissionId: findPermission('projects:create').id },
    { roleId: managerRole[0].id, permissionId: findPermission('projects:read').id },
    { roleId: managerRole[0].id, permissionId: findPermission('projects:update').id },
    { roleId: managerRole[0].id, permissionId: findPermission('timesheets:read').id },
    { roleId: managerRole[0].id, permissionId: findPermission('timesheets:approve').id },
    { roleId: managerRole[0].id, permissionId: findPermission('reports:view').id },
 
    // Employee gets basic permissions
    { roleId: employeeRole[0].id, permissionId: findPermission('projects:read').id },
    { roleId: employeeRole[0].id, permissionId: findPermission('timesheets:create').id },
    { roleId: employeeRole[0].id, permissionId: findPermission('timesheets:read').id },
  ]);
}

Authorization Service

// infrastructure/auth/authorization.service.ts
@Injectable()
export class AuthorizationService {
  constructor(private db: DrizzleService) {}
 
  async getUserPermissions(userId: string, tenantId: string): Promise<string[]> {
    const result = await this.db
      .select({
        permissionName: permissions.name,
      })
      .from(userRoles)
      .innerJoin(roles, eq(userRoles.roleId, roles.id))
      .innerJoin(rolePermissions, eq(roles.id, rolePermissions.roleId))
      .innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
      .where(
        and(
          eq(userRoles.userId, userId),
          eq(userRoles.tenantId, tenantId)
        )
      );
 
    return result.map(r => r.permissionName);
  }
 
  async hasPermission(
    userId: string,
    tenantId: string,
    permission: string
  ): Promise<boolean> {
    const permissions = await this.getUserPermissions(userId, tenantId);
    return permissions.includes(permission);
  }
 
  async hasAnyPermission(
    userId: string,
    tenantId: string,
    requiredPermissions: string[]
  ): Promise<boolean> {
    const permissions = await this.getUserPermissions(userId, tenantId);
    return requiredPermissions.some(p => permissions.includes(p));
  }
 
  async hasAllPermissions(
    userId: string,
    tenantId: string,
    requiredPermissions: string[]
  ): Promise<boolean> {
    const permissions = await this.getUserPermissions(userId, tenantId);
    return requiredPermissions.every(p => permissions.includes(p));
  }
}

Permission Guards (NestJS)

// guards/permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private authz: AuthorizationService
  ) {}
 
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const requiredPermissions = this.reflector.get<string[]>(
      'permissions',
      context.getHandler()
    );
 
    if (!requiredPermissions) {
      return true;
    }
 
    const request = context.switchToHttp().getRequest();
    const { userId, tenantId } = request.user;
 
    return await this.authz.hasAllPermissions(
      userId,
      tenantId,
      requiredPermissions
    );
  }
}
 
// Decorator
export const RequirePermissions = (...permissions: string[]) =>
  SetMetadata('permissions', permissions);
 
// Usage in controller
@Controller('projects')
export class ProjectsController {
  @Post()
  @UseGuards(JwtAuthGuard, PermissionsGuard)
  @RequirePermissions('projects:create')
  async create(@Body() dto: CreateProjectDTO) {
    return await this.projectsService.create(dto);
  }
 
  @Put(':id')
  @UseGuards(JwtAuthGuard, PermissionsGuard)
  @RequirePermissions('projects:update')
  async update(@Param('id') id: string, @Body() dto: UpdateProjectDTO) {
    return await this.projectsService.update(id, dto);
  }
}

Resource-Level Authorization

Role permissions aren't enough. Users should only access resources they own or are assigned to.

// domain/policies/project.policy.ts
export class ProjectPolicy {
  canView(user: User, project: Project): boolean {
    return (
      // Admin can view all
      user.hasPermission('projects:read') &&
      (user.isAdmin ||
        // Project manager can view
        project.managerId === user.id ||
        // Team member can view
        project.teamMemberIds.includes(user.id))
    );
  }
 
  canUpdate(user: User, project: Project): boolean {
    return (
      user.hasPermission('projects:update') &&
      (user.isAdmin || project.managerId === user.id)
    );
  }
 
  canDelete(user: User, project: Project): boolean {
    return user.hasPermission('projects:delete') && user.isAdmin;
  }
}
 
// Use in service
@Injectable()
export class ProjectsService {
  constructor(
    private repository: ProjectRepository,
    private policy: ProjectPolicy
  ) {}
 
  async findById(userId: string, projectId: string): Promise<Project> {
    const project = await this.repository.findById(projectId);
 
    if (!project) {
      throw new NotFoundException();
    }
 
    const user = await this.userRepository.findById(userId);
 
    if (!this.policy.canView(user, project)) {
      throw new ForbiddenException('Cannot view this project');
    }
 
    return project;
  }
 
  async update(
    userId: string,
    projectId: string,
    dto: UpdateProjectDTO
  ): Promise<Project> {
    const project = await this.repository.findById(projectId);
    const user = await this.userRepository.findById(userId);
 
    if (!this.policy.canUpdate(user, project)) {
      throw new ForbiddenException('Cannot update this project');
    }
 
    return await this.repository.update(projectId, dto);
  }
}

Frontend Permission Checks

// hooks/usePermissions.ts
import { useAuth } from './useAuth';
 
export function usePermissions() {
  const { user } = useAuth();
 
  const hasPermission = (permission: string): boolean => {
    return user?.permissions?.includes(permission) ?? false;
  };
 
  const hasAnyPermission = (permissions: string[]): boolean => {
    return permissions.some(p => hasPermission(p));
  };
 
  const hasAllPermissions = (permissions: string[]): boolean => {
    return permissions.every(p => hasPermission(p));
  };
 
  return { hasPermission, hasAnyPermission, hasAllPermissions };
}
 
// Component
const ProjectActions = ({ project }) => {
  const { hasPermission } = usePermissions();
  const { user } = useAuth();
 
  const canEdit = hasPermission('projects:update') &&
    (user.isAdmin || project.managerId === user.id);
 
  const canDelete = hasPermission('projects:delete') && user.isAdmin;
 
  return (
    <div>
      {canEdit && (
        <Button onClick={() => navigate(`/projects/${project.id}/edit`)}>
          Edit
        </Button>
      )}
 
      {canDelete && (
        <Button variant="danger" onClick={() => deleteProject(project.id)}>
          Delete
        </Button>
      )}
    </div>
  );
};

Route-Level Protection

// components/ProtectedRoute.tsx
export const ProtectedRoute = ({
  children,
  permissions,
}: {
  children: React.ReactNode;
  permissions: string[];
}) => {
  const { hasAllPermissions } = usePermissions();
 
  if (!hasAllPermissions(permissions)) {
    return <Navigate to="/403" />;
  }
 
  return <>{children}</>;
};
 
// App routing
<Routes>
  <Route path="/projects" element={<ProjectsList />} />
 
  <Route
    path="/projects/new"
    element={
      <ProtectedRoute permissions={['projects:create']}>
        <CreateProject />
      </ProtectedRoute>
    }
  />
 
  <Route
    path="/admin"
    element={
      <ProtectedRoute permissions={['users:create', 'users:update']}>
        <AdminPanel />
      </ProtectedRoute>
    }
  />
</Routes>

Hierarchical Roles

Some roles inherit from others:

CREATE TABLE role_hierarchy (
  parent_role_id UUID REFERENCES roles(id),
  child_role_id UUID REFERENCES roles(id),
  PRIMARY KEY (parent_role_id, child_role_id)
);
 
-- Admin inherits from Manager
INSERT INTO role_hierarchy (parent_role_id, child_role_id)
VALUES (admin_role_id, manager_role_id);
 
-- Manager inherits from Employee
INSERT INTO role_hierarchy (parent_role_id, child_role_id)
VALUES (manager_role_id, employee_role_id);
async getUserPermissions(userId: string, tenantId: string): Promise<string[]> {
  const result = await this.db.execute(sql`
    WITH RECURSIVE role_tree AS (
      -- Direct roles
      SELECT role_id FROM user_roles
      WHERE user_id = ${userId} AND tenant_id = ${tenantId}
 
      UNION
 
      -- Inherited roles
      SELECT rh.child_role_id
      FROM role_hierarchy rh
      INNER JOIN role_tree rt ON rt.role_id = rh.parent_role_id
    )
    SELECT DISTINCT p.name
    FROM role_tree rt
    INNER JOIN role_permissions rp ON rt.role_id = rp.role_id
    INNER JOIN permissions p ON rp.permission_id = p.id
  `);
 
  return result.map(r => r.name);
}

Attribute-Based Access Control (ABAC)

Sometimes role isn't enough. Consider attributes:

export class TimeEntryPolicy {
  canApprove(user: User, timeEntry: TimeEntry, project: Project): boolean {
    return (
      user.hasPermission('timesheets:approve') &&
      (user.isAdmin ||
        (project.managerId === user.id && // Is project manager
         timeEntry.userId !== user.id)) // Can't approve own entries
    );
  }
 
  canEdit(user: User, timeEntry: TimeEntry): boolean {
    const isOwner = timeEntry.userId === user.id;
    const isNotApproved = timeEntry.status !== 'approved';
    const isRecent = isWithinDays(timeEntry.date, 7);
 
    return (
      user.hasPermission('timesheets:update') &&
      isOwner &&
      isNotApproved &&
      isRecent
    );
  }
}

Dynamic Permissions

Allow creating custom permissions at runtime:

@Post('permissions')
@RequirePermissions('permissions:create')
async createPermission(@Body() dto: CreatePermissionDTO) {
  const permission = await this.db.insert(permissions).values({
    name: dto.name,
    resource: dto.resource,
    action: dto.action,
    description: dto.description,
  });
 
  return permission;
}
 
// Assign to role
@Post('roles/:roleId/permissions')
@RequirePermissions('roles:update')
async assignPermission(
  @Param('roleId') roleId: string,
  @Body() dto: { permissionId: string }
) {
  await this.db.insert(rolePermissions).values({
    roleId,
    permissionId: dto.permissionId,
  });
}

Caching Permissions

Don't query permissions on every request. Cache them in JWT or Redis:

// Store permissions in JWT
const token = jwt.sign(
  {
    userId: user.id,
    tenantId: user.tenantId,
    permissions: await authz.getUserPermissions(user.id, user.tenantId),
  },
  process.env.JWT_SECRET
);
 
// Or cache in Redis
await redis.setex(
  `permissions:${userId}:${tenantId}`,
  3600, // 1 hour
  JSON.stringify(permissions)
);

Invalidate cache when roles/permissions change:

@Put('users/:userId/roles')
async updateUserRoles(@Param('userId') userId: string, @Body() dto: UpdateRolesDTO) {
  await this.userRolesService.update(userId, dto.roleIds);
 
  // Invalidate permission cache
  await redis.del(`permissions:${userId}:*`);
}

Audit Logging

Track permission changes:

CREATE TABLE audit_log (
  id UUID PRIMARY KEY,
  user_id UUID,
  action VARCHAR(100),
  resource VARCHAR(100),
  resource_id UUID,
  timestamp TIMESTAMP DEFAULT NOW(),
  details JSONB
);
async function logAudit(
  userId: string,
  action: string,
  resource: string,
  resourceId: string,
  details: any
) {
  await db.insert(auditLog).values({
    userId,
    action,
    resource,
    resourceId,
    details,
  });
}
 
// Usage
await this.projectsService.update(projectId, dto);
 
await logAudit(user.id, 'update', 'project', projectId, {
  changes: dto,
});

Testing RBAC

describe('ProjectsController', () => {
  it('should allow admin to delete any project', async () => {
    const admin = await createUser({ roles: ['admin'] });
 
    const response = await request(app)
      .delete(`/projects/${projectId}`)
      .set('Authorization', `Bearer ${admin.token}`);
 
    expect(response.status).toBe(200);
  });
 
  it('should allow manager to delete own project', async () => {
    const manager = await createUser({ roles: ['manager'] });
    const project = await createProject({ managerId: manager.id });
 
    const response = await request(app)
      .delete(`/projects/${project.id}`)
      .set('Authorization', `Bearer ${manager.token}`);
 
    expect(response.status).toBe(200);
  });
 
  it('should prevent employee from deleting project', async () => {
    const employee = await createUser({ roles: ['employee'] });
 
    const response = await request(app)
      .delete(`/projects/${projectId}`)
      .set('Authorization', `Bearer ${employee.token}`);
 
    expect(response.status).toBe(403);
  });
});

Lessons Learned

  1. Permissions over roles: Check permissions, not role names
  2. Resource-level auth: Role permissions + ownership checks
  3. Cache permissions: Don't query database on every request
  4. Frontend checks are UX: Backend checks are security
  5. Audit everything: Track who did what
  6. Test thoroughly: Authorization bugs are security bugs

Conclusion

Building flexible RBAC systems requires balancing simplicity with granularity. Start with roles and permissions, add resource-level policies as needed, and always enforce authorization on the backend.

After 27 years of building authorization systems, I've learned that good RBAC is invisible—users can do what they should, can't do what they shouldn't, and never think about it.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.