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