import fp from 'fastify-plugin' import fjwt from '@fastify/jwt' import { eq } from 'drizzle-orm' import { users } from '../db/schema/users.js' import { RbacService } from '../services/rbac.service.js' declare module 'fastify' { interface FastifyRequest { locationId: string user: { id: string; role: string } permissions: Set } } declare module '@fastify/jwt' { interface FastifyJWT { payload: { id: string; role: string } user: { id: string; role: string } } } /** * 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 = { 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 { const expanded = new Set() 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) { throw new Error('JWT_SECRET environment variable is required') } await app.register(fjwt, { secret, sign: { expiresIn: '24h' }, }) app.addHook('onRequest', async (request) => { 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() // Check if user account is active const [dbUser] = await app.db .select({ isActive: users.isActive }) .from(users) .where(eq(users.id, request.user.id)) .limit(1) if (!dbUser || !dbUser.isActive) { reply.status(401).send({ error: { message: 'Account disabled', statusCode: 401 } }) return } // 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)) { reply .status(403) .send({ error: { message: 'Insufficient permissions', statusCode: 403 } }) } } }) }) declare module 'fastify' { interface FastifyInstance { authenticate: (request: any, reply: any) => Promise requirePermission: (...permissions: string[]) => (request: any, reply: any) => Promise requireRole: (...roles: string[]) => (request: any, reply: any) => Promise } }