- 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
251 lines
7.6 KiB
TypeScript
251 lines
7.6 KiB
TypeScript
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))
|
|
},
|
|
}
|