Files
lunarfront-app/packages/backend/src/plugins/auth.ts
Ryan Moon d36c6f7135 Remove multi-tenant company_id scoping from entire codebase
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.
2026-03-29 14:58:33 -05:00

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>
}
}