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:
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user