Drop company_id column from all 22 domain tables via migration. Remove companyId from JWT payload, auth plugins, all service method signatures (~215 occurrences), all route handlers (~105 occurrences), test runner, test suites, and frontend auth store/types. The company table stays as store settings (name, timezone). Tenant isolation in a SaaS deployment would be at the database level (one DB per customer) not the application level. All 107 API tests pass. Zero TSC errors across all packages.
231 lines
8.7 KiB
TypeScript
231 lines
8.7 KiB
TypeScript
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<string, Column> = {
|
|
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<string, { id: string; name: string; slug: string; isSystem: boolean }[]>()
|
|
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,
|
|
})
|
|
})
|
|
}
|