Drop company_id column from all 22 domain tables via migration. Remove companyId from JWT payload, auth plugins, all service method signatures (~215 occurrences), all route handlers (~105 occurrences), test runner, test suites, and frontend auth store/types. The company table stays as store settings (name, timezone). Tenant isolation in a SaaS deployment would be at the database level (one DB per customer) not the application level. All 107 API tests pass. Zero TSC errors across all packages.
133 lines
4.0 KiB
TypeScript
133 lines
4.0 KiB
TypeScript
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<string>
|
|
}
|
|
}
|
|
|
|
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<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) {
|
|
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<void>
|
|
requirePermission: (...permissions: string[]) => (request: any, reply: any) => Promise<void>
|
|
requireRole: (...roles: string[]) => (request: any, reply: any) => Promise<void>
|
|
}
|
|
}
|