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

@@ -26,7 +26,7 @@ import {
export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Accounts ---
app.post('/accounts', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const parsed = AccountCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
@@ -35,20 +35,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(account)
})
app.get('/accounts', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await AccountService.list(app.db, request.companyId, params)
return reply.send(result)
})
app.get('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const account = await AccountService.getById(app.db, request.companyId, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
return reply.send(account)
})
app.patch('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = AccountUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -59,7 +59,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(account)
})
app.delete('/accounts/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/accounts/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const account = await AccountService.softDelete(app.db, request.companyId, id)
if (!account) return reply.status(404).send({ error: { message: 'Account not found', statusCode: 404 } })
@@ -69,7 +69,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Members (top-level) ---
app.get('/members', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/members', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await MemberService.list(app.db, request.companyId, params)
return reply.send(result)
@@ -77,7 +77,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Members (scoped to account) ---
app.post('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts/:accountId/members', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = MemberCreateSchema.safeParse({ ...(request.body as object), accountId })
if (!parsed.success) {
@@ -87,21 +87,21 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(member)
})
app.get('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:accountId/members', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const params = PaginationSchema.parse(request.query)
const result = await MemberService.listByAccount(app.db, request.companyId, accountId, params)
return reply.send(result)
})
app.get('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const member = await MemberService.getById(app.db, request.companyId, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
return reply.send(member)
})
app.patch('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = MemberUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -112,7 +112,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(member)
})
app.post('/members/:id/move', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/members/:id/move', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const { accountId } = (request.body as { accountId?: string }) ?? {}
@@ -136,7 +136,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Member Identifiers ---
app.post('/members/:memberId/identifiers', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/members/:memberId/identifiers', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { memberId } = request.params as { memberId: string }
const parsed = MemberIdentifierCreateSchema.safeParse({ ...(request.body as object), memberId })
if (!parsed.success) {
@@ -146,13 +146,13 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(identifier)
})
app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/members/:memberId/identifiers', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { memberId } = request.params as { memberId: string }
const identifiers = await MemberIdentifierService.listByMember(app.db, request.companyId, memberId)
return reply.send({ data: identifiers })
})
app.patch('/identifiers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/identifiers/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = MemberIdentifierUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -163,14 +163,14 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(identifier)
})
app.delete('/identifiers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/identifiers/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const identifier = await MemberIdentifierService.delete(app.db, request.companyId, id)
if (!identifier) return reply.status(404).send({ error: { message: 'Identifier not found', statusCode: 404 } })
return reply.send(identifier)
})
app.delete('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/members/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const member = await MemberService.delete(app.db, request.companyId, id)
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
@@ -179,7 +179,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Processor Links ---
app.post('/accounts/:accountId/processor-links', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts/:accountId/processor-links', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = ProcessorLinkCreateSchema.safeParse({ ...(request.body as object), accountId })
if (!parsed.success) {
@@ -189,13 +189,13 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(link)
})
app.get('/accounts/:accountId/processor-links', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:accountId/processor-links', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const links = await ProcessorLinkService.listByAccount(app.db, request.companyId, accountId)
return reply.send({ data: links })
})
app.patch('/processor-links/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/processor-links/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = ProcessorLinkUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -206,7 +206,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(link)
})
app.delete('/processor-links/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/processor-links/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const link = await ProcessorLinkService.delete(app.db, request.companyId, id)
if (!link) return reply.status(404).send({ error: { message: 'Processor link not found', statusCode: 404 } })
@@ -215,7 +215,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Payment Methods ---
app.post('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = PaymentMethodCreateSchema.safeParse({ ...(request.body as object), accountId })
if (!parsed.success) {
@@ -225,20 +225,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(method)
})
app.get('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:accountId/payment-methods', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const methods = await PaymentMethodService.listByAccount(app.db, request.companyId, accountId)
return reply.send({ data: methods })
})
app.get('/payment-methods/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const method = await PaymentMethodService.getById(app.db, request.companyId, id)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
return reply.send(method)
})
app.patch('/payment-methods/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = PaymentMethodUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -249,7 +249,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(method)
})
app.delete('/payment-methods/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/payment-methods/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const method = await PaymentMethodService.delete(app.db, request.companyId, id)
if (!method) return reply.status(404).send({ error: { message: 'Payment method not found', statusCode: 404 } })
@@ -258,7 +258,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
// --- Tax Exemptions ---
app.post('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const parsed = TaxExemptionCreateSchema.safeParse({ ...(request.body as object), accountId })
if (!parsed.success) {
@@ -268,20 +268,20 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(exemption)
})
app.get('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/accounts/:accountId/tax-exemptions', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { accountId } = request.params as { accountId: string }
const exemptions = await TaxExemptionService.listByAccount(app.db, request.companyId, accountId)
return reply.send({ data: exemptions })
})
app.get('/tax-exemptions/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/tax-exemptions/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const exemption = await TaxExemptionService.getById(app.db, request.companyId, id)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
return reply.send(exemption)
})
app.patch('/tax-exemptions/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/tax-exemptions/:id', { preHandler: [app.authenticate, app.requirePermission('accounts.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = TaxExemptionUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -292,7 +292,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(exemption)
})
app.post('/tax-exemptions/:id/approve', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/tax-exemptions/:id/approve', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const exemption = await TaxExemptionService.approve(app.db, request.companyId, id, request.user.id)
if (!exemption) return reply.status(404).send({ error: { message: 'Tax exemption not found', statusCode: 404 } })
@@ -300,7 +300,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.send(exemption)
})
app.post('/tax-exemptions/:id/revoke', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/tax-exemptions/:id/revoke', { preHandler: [app.authenticate, app.requirePermission('accounts.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const { reason } = (request.body as { reason?: string }) ?? {}
if (!reason) {