Implement RBAC with permissions, roles, and route guards

- permission, role, role_permission, user_role_assignment tables
- 42 system permissions across 13 domains
- 6 default roles: Admin, Manager, Sales Associate, Technician, Instructor, Viewer
- Permission inheritance: admin implies edit implies view
- requirePermission() Fastify decorator on ALL routes
- System permissions and roles seeded per company
- Test helpers and API test runner seed RBAC data
- All 42 API tests pass with permissions enforced
This commit is contained in:
Ryan Moon
2026-03-28 17:00:42 -05:00
parent dd03fb79ef
commit 4a1fc608f0
13 changed files with 679 additions and 79 deletions

View File

@@ -12,7 +12,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
})
// List files for an entity
app.get('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/files', { preHandler: [app.authenticate, app.requirePermission('files.view')] }, async (request, reply) => {
const { entityType, entityId } = request.query as { entityType?: string; entityId?: string }
if (!entityType || !entityId) {
throw new ValidationError('entityType and entityId query params required')
@@ -27,7 +27,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
})
// Upload a file
app.post('/files', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/files', { preHandler: [app.authenticate, app.requirePermission('files.upload')] }, async (request, reply) => {
const data = await request.file()
if (!data) {
throw new ValidationError('No file provided')
@@ -77,7 +77,7 @@ 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] }, async (request, reply) => {
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')
@@ -104,7 +104,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
})
// Get file metadata
app.get('/files/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
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)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })
@@ -113,7 +113,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
})
// Delete a file
app.delete('/files/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
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)
if (!file) return reply.status(404).send({ error: { message: 'File not found', statusCode: 404 } })