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.
This commit is contained in:
Ryan Moon
2026-03-29 14:58:33 -05:00
parent 55f8591cf1
commit d36c6f7135
35 changed files with 353 additions and 511 deletions

View File

@@ -18,8 +18,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
throw new ValidationError('entityType and entityId query params required')
}
// Files are company-scoped in the service — companyId from JWT ensures access control
const fileRecords = await FileService.listByEntity(app.db, request.companyId, entityType, entityId)
const fileRecords = await FileService.listByEntity(app.db, entityType, entityId)
const data = await Promise.all(
fileRecords.map(async (f) => ({ ...f, url: await app.storage.getUrl(f.path) })),
)
@@ -59,7 +58,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
const buffer = await data.toBuffer()
const file = await FileService.upload(app.db, app.storage, request.companyId, {
const file = await FileService.upload(app.db, app.storage, {
data: buffer,
filename: data.filename,
contentType: data.mimetype,
@@ -76,15 +75,14 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
})
// Serve file content (for local provider)
// Path traversal protection: validate the path starts with the requesting company's ID
app.get('/files/serve/*', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const filePath = (request.params as { '*': string })['*']
if (!filePath) {
throw new ValidationError('Path required')
}
// Path traversal protection: must start with company ID, no '..' allowed
if (filePath.includes('..') || !filePath.startsWith(request.companyId)) {
// Path traversal protection: no '..' allowed
if (filePath.includes('..')) {
return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
}
@@ -106,7 +104,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Get file metadata
app.get('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const file = await FileService.getById(app.db, request.companyId, id)
const file = await FileService.getById(app.db, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
const url = await app.storage.getUrl(file.path)
return reply.send({ ...file, url })
@@ -115,12 +113,12 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Generate signed URL for a file (short-lived token in query string)
app.get('/files/signed-url/:id', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const file = await FileService.getById(app.db, request.companyId, id)
const file = await FileService.getById(app.db, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
// Sign a short-lived token with the file path
const token = app.jwt.sign(
{ path: file.path, companyId: request.companyId, purpose: 'file-access' } as any,
{ path: file.path, purpose: 'file-access' } as any,
{ expiresIn: '15m' },
)
@@ -139,14 +137,10 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Verify the signed token
try {
const payload = app.jwt.verify(token) as { path: string; companyId: string; purpose: string }
const payload = app.jwt.verify(token) as { path: string; purpose: string }
if (payload.purpose !== 'file-access' || payload.path !== filePath) {
return reply.status(403).send({ error: { message: 'Invalid token', statusCode: 403 } })
}
// Validate company isolation — file path must start with the token's companyId
if (payload.companyId && !filePath.startsWith(payload.companyId)) {
return reply.status(403).send({ error: { message: 'Access denied', statusCode: 403 } })
}
} catch {
return reply.status(403).send({ error: { message: 'Token expired or invalid', statusCode: 403 } })
}
@@ -174,7 +168,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
// Delete a file
app.delete('/files/:id', { preHandler: [app.authenticate, app.requirePermission('files.delete')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const file = await FileService.delete(app.db, app.storage, request.companyId, id)
const file = await FileService.delete(app.db, app.storage, id)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
request.log.info({ fileId: id, path: file.path }, 'File deleted')
return reply.send(file)