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

@@ -1,11 +1,13 @@
import fp from 'fastify-plugin'
import fjwt from '@fastify/jwt'
import { RbacService } from '../services/rbac.service.js'
declare module 'fastify' {
interface FastifyRequest {
companyId: string
locationId: string
user: { id: string; companyId: string; role: string }
permissions: Set<string>
}
}
@@ -16,6 +18,36 @@ declare module '@fastify/jwt' {
}
}
/**
* Permission inheritance: admin implies edit implies view for the same domain.
* e.g. having "accounts.admin" means you also have "accounts.edit" and "accounts.view"
*/
const ACTION_HIERARCHY: Record<string, string[]> = {
admin: ['admin', 'edit', 'view'],
edit: ['edit', 'view'],
view: ['view'],
// Non-hierarchical actions (files, reports) don't cascade
upload: ['upload'],
delete: ['delete'],
send: ['send'],
export: ['export'],
}
function expandPermissions(slugs: string[]): Set<string> {
const expanded = new Set<string>()
for (const slug of slugs) {
expanded.add(slug)
const [domain, action] = slug.split('.')
const implied = ACTION_HIERARCHY[action]
if (implied && domain) {
for (const a of implied) {
expanded.add(`${domain}.${a}`)
}
}
}
return expanded
}
export const authPlugin = fp(async (app) => {
const secret = process.env.JWT_SECRET
if (!secret) {
@@ -32,17 +64,45 @@ export const authPlugin = fp(async (app) => {
app.addHook('onRequest', async (request) => {
request.companyId = (request.headers['x-company-id'] as string) ?? ''
request.locationId = (request.headers['x-location-id'] as string) ?? ''
request.permissions = new Set()
})
app.decorate('authenticate', async function (request: any, reply: any) {
try {
await request.jwtVerify()
request.companyId = request.user.companyId
// Load permissions from DB and expand with inheritance
const permSlugs = await RbacService.getUserPermissions(app.db, request.user.id)
request.permissions = expandPermissions(permSlugs)
} catch (_err) {
reply.status(401).send({ error: { message: 'Unauthorized', statusCode: 401 } })
}
})
app.decorate('requirePermission', function (...requiredPermissions: string[]) {
return async function (request: any, reply: any) {
// If user has no permissions loaded (shouldn't happen after authenticate), deny
if (!request.permissions || request.permissions.size === 0) {
reply.status(403).send({ error: { message: 'No permissions assigned', statusCode: 403 } })
return
}
// Check if user has ANY of the required permissions
const hasPermission = requiredPermissions.some((p) => request.permissions.has(p))
if (!hasPermission) {
reply.status(403).send({
error: {
message: 'Insufficient permissions',
statusCode: 403,
required: requiredPermissions,
},
})
}
}
})
// Keep legacy requireRole for backward compatibility during migration
app.decorate('requireRole', function (...roles: string[]) {
return async function (request: any, reply: any) {
if (!roles.includes(request.user.role)) {
@@ -57,6 +117,7 @@ export const authPlugin = fp(async (app) => {
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: any, reply: any) => Promise<void>
requirePermission: (...permissions: string[]) => (request: any, reply: any) => Promise<void>
requireRole: (...roles: string[]) => (request: any, reply: any) => Promise<void>
}
}