import type { FastifyPluginAsync } from 'fastify' import { eq, count, sql, type Column } from 'drizzle-orm' import { PaginationSchema } from '@forte/shared/schemas' import { RbacService } from '../../services/rbac.service.js' import { ValidationError } from '../../lib/errors.js' import { users } from '../../db/schema/users.js' import { roles, userRoles } from '../../db/schema/rbac.js' import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../../utils/pagination.js' export const rbacRoutes: FastifyPluginAsync = async (app) => { // --- Users list --- app.get('/users', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { const params = PaginationSchema.parse(request.query) const searchCondition = params.q ? buildSearchCondition(params.q, [users.firstName, users.lastName, users.email]) : undefined const where = searchCondition ?? undefined const sortableColumns: Record = { name: users.lastName, email: users.email, created_at: users.createdAt, } let query = app.db .select({ id: users.id, email: users.email, firstName: users.firstName, lastName: users.lastName, role: users.role, isActive: users.isActive, createdAt: users.createdAt, }) .from(users) .where(where) .$dynamic() query = withSort(query, params.sort, params.order, sortableColumns, users.lastName) query = withPagination(query, params.page, params.limit) const [data, [{ total }]] = await Promise.all([ query, app.db.select({ total: count() }).from(users).where(where), ]) // Attach roles to each user const userIds = data.map((u) => u.id) const roleAssignments = userIds.length > 0 ? await app.db .select({ userId: userRoles.userId, roleId: roles.id, roleName: roles.name, roleSlug: roles.slug, isSystem: roles.isSystem, }) .from(userRoles) .innerJoin(roles, eq(userRoles.roleId, roles.id)) .where(sql`${userRoles.userId} IN ${userIds}`) : [] const rolesByUser = new Map() for (const ra of roleAssignments) { const list = rolesByUser.get(ra.userId) ?? [] list.push({ id: ra.roleId, name: ra.roleName, slug: ra.roleSlug, isSystem: ra.isSystem }) rolesByUser.set(ra.userId, list) } const usersWithRoles = data.map((u) => ({ ...u, roles: rolesByUser.get(u.id) ?? [], })) return reply.send(paginatedResponse(usersWithRoles, total, params.page, params.limit)) }) // --- User status --- app.patch('/users/:userId/status', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => { const { userId } = request.params as { userId: string } const { isActive } = request.body as { isActive?: boolean } if (typeof isActive !== 'boolean') { throw new ValidationError('isActive (boolean) is required') } // Prevent disabling yourself if (userId === request.user.id) { throw new ValidationError('Cannot change your own account status') } const [updated] = await app.db .update(users) .set({ isActive, updatedAt: new Date() }) .where(eq(users.id, userId)) .returning({ id: users.id, isActive: users.isActive }) if (!updated) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) request.log.info({ userId, isActive, changedBy: request.user.id }, 'User status changed') return reply.send(updated) }) // --- Permissions --- app.get('/permissions', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { const data = await RbacService.listPermissions(app.db) return reply.send({ data }) }) // --- Roles --- app.get('/roles', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { const params = PaginationSchema.parse(request.query) const result = await RbacService.listRoles(app.db, params) return reply.send(result) }) // Unpaginated list for dropdowns/selectors app.get('/roles/all', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { const data = await app.db .select() .from(roles) .where(eq(roles.isActive, true)) .orderBy(roles.name) return reply.send({ data }) }) app.get('/roles/:id', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { const { id } = request.params as { id: string } const role = await RbacService.getRoleWithPermissions(app.db, id) if (!role) return reply.status(404).send({ error: { message: 'Role not found', statusCode: 404 } }) return reply.send(role) }) app.post('/roles', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => { const { name, slug, description, permissionSlugs } = request.body as { name?: string slug?: string description?: string permissionSlugs?: string[] } if (!name || !slug || !permissionSlugs) { throw new ValidationError('name, slug, and permissionSlugs are required') } if (!/^[a-z0-9_]+$/.test(slug)) { throw new ValidationError('slug must be lowercase alphanumeric with underscores') } const role = await RbacService.createRole(app.db, { name, slug, description, permissionSlugs, }) request.log.info({ roleId: role?.id, roleName: name, userId: request.user.id }, 'Role created') return reply.status(201).send(role) }) app.patch('/roles/:id', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => { const { id } = request.params as { id: string } const { name, description, permissionSlugs } = request.body as { name?: string description?: string permissionSlugs?: string[] } const role = await RbacService.updateRole(app.db, id, { name, description, permissionSlugs, }) if (!role) return reply.status(404).send({ error: { message: 'Role not found', statusCode: 404 } }) request.log.info({ roleId: id, userId: request.user.id }, 'Role updated') return reply.send(role) }) app.delete('/roles/:id', { preHandler: [app.authenticate, app.requirePermission('users.admin')] }, async (request, reply) => { const { id } = request.params as { id: string } const role = await RbacService.deleteRole(app.db, id) if (!role) return reply.status(404).send({ error: { message: 'Role not found', statusCode: 404 } }) request.log.info({ roleId: id, roleName: role.name, userId: request.user.id }, 'Role deleted') return reply.send(role) }) // --- User Role Assignments --- app.get('/users/:userId/roles', { preHandler: [app.authenticate, app.requirePermission('users.view')] }, async (request, reply) => { const { userId } = request.params as { userId: string } const data = await RbacService.getUserRoles(app.db, userId) return reply.send({ data }) }) app.post('/users/:userId/roles', { preHandler: [app.authenticate, app.requirePermission('users.edit')] }, async (request, reply) => { const { userId } = request.params as { userId: string } const { roleId } = request.body as { roleId?: string } if (!roleId) throw new ValidationError('roleId is required') const assignment = await RbacService.assignRole(app.db, userId, roleId, request.user.id) request.log.info({ userId, roleId, assignedBy: request.user.id }, 'Role assigned to user') return reply.status(201).send(assignment) }) app.delete('/users/:userId/roles/:roleId', { preHandler: [app.authenticate, app.requirePermission('users.edit')] }, async (request, reply) => { const { userId, roleId } = request.params as { userId: string; roleId: string } const removed = await RbacService.removeRole(app.db, userId, roleId) if (!removed) return reply.status(404).send({ error: { message: 'Role assignment not found', statusCode: 404 } }) request.log.info({ userId, roleId, removedBy: request.user.id }, 'Role removed from user') return reply.send(removed) }) // --- Current user permissions --- app.get('/me/permissions', { preHandler: [app.authenticate] }, async (request, reply) => { const permSlugs = await RbacService.getUserPermissions(app.db, request.user.id) const userRoles = await RbacService.getUserRoles(app.db, request.user.id) return reply.send({ permissions: permSlugs, roles: userRoles, }) }) }