Implement RBAC with permissions, roles, and route guards
- permission, role, role_permission, user_role_assignment tables - 42 system permissions across 13 domains - 6 default roles: Admin, Manager, Sales Associate, Technician, Instructor, Viewer - Permission inheritance: admin implies edit implies view - requirePermission() Fastify decorator on ALL routes - System permissions and roles seeded per company - Test helpers and API test runner seed RBAC data - All 42 API tests pass with permissions enforced
This commit is contained in:
250
packages/backend/src/services/rbac.service.ts
Normal file
250
packages/backend/src/services/rbac.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { eq, and, inArray } from 'drizzle-orm'
|
||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||
import { permissions, roles, rolePermissions, userRoles } from '../db/schema/rbac.js'
|
||||
import { SYSTEM_PERMISSIONS, DEFAULT_ROLES } from '../db/seeds/rbac.js'
|
||||
import { ForbiddenError } from '../lib/errors.js'
|
||||
|
||||
export const RbacService = {
|
||||
/** Seed system permissions (global, run once) */
|
||||
async seedPermissions(db: PostgresJsDatabase) {
|
||||
const existing = await db.select({ slug: permissions.slug }).from(permissions)
|
||||
const existingSlugs = new Set(existing.map((p) => p.slug))
|
||||
|
||||
const toInsert = SYSTEM_PERMISSIONS.filter((p) => !existingSlugs.has(p.slug))
|
||||
if (toInsert.length === 0) return
|
||||
|
||||
await db.insert(permissions).values(toInsert)
|
||||
},
|
||||
|
||||
/** Seed default roles for a company */
|
||||
async seedRolesForCompany(db: PostgresJsDatabase, companyId: string) {
|
||||
const existingRoles = await db
|
||||
.select({ slug: roles.slug })
|
||||
.from(roles)
|
||||
.where(and(eq(roles.companyId, companyId), eq(roles.isSystem, true)))
|
||||
|
||||
if (existingRoles.length > 0) return // already seeded
|
||||
|
||||
// Get all permission records for slug → id mapping
|
||||
const allPerms = await db.select().from(permissions)
|
||||
const permMap = new Map(allPerms.map((p) => [p.slug, p.id]))
|
||||
|
||||
for (const roleDef of DEFAULT_ROLES) {
|
||||
const [role] = await db
|
||||
.insert(roles)
|
||||
.values({
|
||||
companyId,
|
||||
name: roleDef.name,
|
||||
slug: roleDef.slug,
|
||||
description: roleDef.description,
|
||||
isSystem: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
const permIds = roleDef.permissions
|
||||
.map((slug) => permMap.get(slug))
|
||||
.filter((id): id is string => id !== undefined)
|
||||
|
||||
if (permIds.length > 0) {
|
||||
await db.insert(rolePermissions).values(
|
||||
permIds.map((permissionId) => ({
|
||||
roleId: role.id,
|
||||
permissionId,
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Get all permissions for a user (union of all role permissions) */
|
||||
async getUserPermissions(db: PostgresJsDatabase, userId: string): Promise<string[]> {
|
||||
const userRoleRecords = await db
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId))
|
||||
|
||||
if (userRoleRecords.length === 0) return []
|
||||
|
||||
const roleIds = userRoleRecords.map((r) => r.roleId)
|
||||
|
||||
const rpRecords = await db
|
||||
.select({ permissionId: rolePermissions.permissionId })
|
||||
.from(rolePermissions)
|
||||
.where(inArray(rolePermissions.roleId, roleIds))
|
||||
|
||||
if (rpRecords.length === 0) return []
|
||||
|
||||
const permIds = [...new Set(rpRecords.map((r) => r.permissionId))]
|
||||
|
||||
const permRecords = await db
|
||||
.select({ slug: permissions.slug })
|
||||
.from(permissions)
|
||||
.where(inArray(permissions.id, permIds))
|
||||
|
||||
return permRecords.map((p) => p.slug)
|
||||
},
|
||||
|
||||
/** List all permissions */
|
||||
async listPermissions(db: PostgresJsDatabase) {
|
||||
return db.select().from(permissions).orderBy(permissions.domain, permissions.action)
|
||||
},
|
||||
|
||||
/** List roles for a company */
|
||||
async listRoles(db: PostgresJsDatabase, companyId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.companyId, companyId), eq(roles.isActive, true)))
|
||||
.orderBy(roles.name)
|
||||
},
|
||||
|
||||
/** Get role with its permissions */
|
||||
async getRoleWithPermissions(db: PostgresJsDatabase, companyId: string, roleId: string) {
|
||||
const [role] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.id, roleId), eq(roles.companyId, companyId)))
|
||||
.limit(1)
|
||||
|
||||
if (!role) return null
|
||||
|
||||
const rp = await db
|
||||
.select({ permissionId: rolePermissions.permissionId, slug: permissions.slug })
|
||||
.from(rolePermissions)
|
||||
.innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
|
||||
.where(eq(rolePermissions.roleId, roleId))
|
||||
|
||||
return { ...role, permissions: rp.map((r) => r.slug) }
|
||||
},
|
||||
|
||||
/** Create a custom role */
|
||||
async createRole(
|
||||
db: PostgresJsDatabase,
|
||||
companyId: string,
|
||||
input: { name: string; slug: string; description?: string; permissionSlugs: string[] },
|
||||
) {
|
||||
const [role] = await db
|
||||
.insert(roles)
|
||||
.values({
|
||||
companyId,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
isSystem: false,
|
||||
})
|
||||
.returning()
|
||||
|
||||
await this.setRolePermissions(db, role.id, input.permissionSlugs)
|
||||
return this.getRoleWithPermissions(db, companyId, role.id)
|
||||
},
|
||||
|
||||
/** Update role permissions (replace all) */
|
||||
async setRolePermissions(db: PostgresJsDatabase, roleId: string, permissionSlugs: string[]) {
|
||||
// Delete existing
|
||||
await db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId))
|
||||
|
||||
if (permissionSlugs.length === 0) return
|
||||
|
||||
// Get permission IDs
|
||||
const perms = await db
|
||||
.select({ id: permissions.id, slug: permissions.slug })
|
||||
.from(permissions)
|
||||
.where(inArray(permissions.slug, permissionSlugs))
|
||||
|
||||
if (perms.length > 0) {
|
||||
await db.insert(rolePermissions).values(
|
||||
perms.map((p) => ({ roleId, permissionId: p.id })),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
/** Update a role */
|
||||
async updateRole(
|
||||
db: PostgresJsDatabase,
|
||||
companyId: string,
|
||||
roleId: string,
|
||||
input: { name?: string; description?: string; permissionSlugs?: string[] },
|
||||
) {
|
||||
if (input.name || input.description) {
|
||||
await db
|
||||
.update(roles)
|
||||
.set({
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...(input.description !== undefined ? { description: input.description } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(roles.id, roleId), eq(roles.companyId, companyId)))
|
||||
}
|
||||
|
||||
if (input.permissionSlugs) {
|
||||
await this.setRolePermissions(db, roleId, input.permissionSlugs)
|
||||
}
|
||||
|
||||
return this.getRoleWithPermissions(db, companyId, roleId)
|
||||
},
|
||||
|
||||
/** Delete a custom role */
|
||||
async deleteRole(db: PostgresJsDatabase, companyId: string, roleId: string) {
|
||||
const [role] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.id, roleId), eq(roles.companyId, companyId)))
|
||||
.limit(1)
|
||||
|
||||
if (!role) return null
|
||||
if (role.isSystem) throw new ForbiddenError('Cannot delete a system role')
|
||||
|
||||
// Remove role permissions and user assignments
|
||||
await db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId))
|
||||
await db.delete(userRoles).where(eq(userRoles.roleId, roleId))
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.returning()
|
||||
|
||||
return deleted
|
||||
},
|
||||
|
||||
/** Assign a role to a user */
|
||||
async assignRole(db: PostgresJsDatabase, userId: string, roleId: string, assignedBy?: string) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(userRoles)
|
||||
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)))
|
||||
.limit(1)
|
||||
|
||||
if (existing) return existing // already assigned
|
||||
|
||||
const [assignment] = await db
|
||||
.insert(userRoles)
|
||||
.values({ userId, roleId, assignedBy })
|
||||
.returning()
|
||||
|
||||
return assignment
|
||||
},
|
||||
|
||||
/** Remove a role from a user */
|
||||
async removeRole(db: PostgresJsDatabase, userId: string, roleId: string) {
|
||||
const [removed] = await db
|
||||
.delete(userRoles)
|
||||
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)))
|
||||
.returning()
|
||||
|
||||
return removed ?? null
|
||||
},
|
||||
|
||||
/** Get roles assigned to a user */
|
||||
async getUserRoles(db: PostgresJsDatabase, userId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
slug: roles.slug,
|
||||
isSystem: roles.isSystem,
|
||||
})
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, userId))
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user