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:
Ryan Moon
2026-03-28 17:00:42 -05:00
parent dd03fb79ef
commit 4a1fc608f0
13 changed files with 679 additions and 79 deletions

View 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))
},
}