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:
@@ -1,39 +1,35 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { buildApp } from '../main.js'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { sql, eq, and } from 'drizzle-orm'
|
||||
import { companies, locations } from '../db/schema/stores.js'
|
||||
import { UnitStatusService, ItemConditionService } from '../services/lookup.service.js'
|
||||
import { RbacService } from '../services/rbac.service.js'
|
||||
import { roles } from '../db/schema/rbac.js'
|
||||
import { users } from '../db/schema/users.js'
|
||||
|
||||
export const TEST_COMPANY_ID = '00000000-0000-0000-0000-000000000099'
|
||||
export const TEST_LOCATION_ID = '00000000-0000-0000-0000-000000000099'
|
||||
|
||||
/**
|
||||
* Build a fresh Fastify app instance for testing.
|
||||
*/
|
||||
export async function createTestApp(): Promise<FastifyInstance> {
|
||||
const app = await buildApp()
|
||||
await app.ready()
|
||||
return app
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate all tables in the test database.
|
||||
*/
|
||||
export async function cleanDb(app: FastifyInstance): Promise<void> {
|
||||
await app.db.execute(sql`
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
SET client_min_messages TO WARNING;
|
||||
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP
|
||||
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
RESET client_min_messages;
|
||||
END $$
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a test company and location. Call after cleanDb.
|
||||
*/
|
||||
export async function seedTestCompany(app: FastifyInstance): Promise<void> {
|
||||
await app.db.insert(companies).values({
|
||||
id: TEST_COMPANY_ID,
|
||||
@@ -46,14 +42,14 @@ export async function seedTestCompany(app: FastifyInstance): Promise<void> {
|
||||
name: 'Test Location',
|
||||
})
|
||||
|
||||
// Seed lookup tables for the test company
|
||||
await UnitStatusService.seedForCompany(app.db, TEST_COMPANY_ID)
|
||||
await ItemConditionService.seedForCompany(app.db, TEST_COMPANY_ID)
|
||||
|
||||
// Seed RBAC permissions and default roles
|
||||
await RbacService.seedPermissions(app.db)
|
||||
await RbacService.seedRolesForCompany(app.db, TEST_COMPANY_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a user and return the JWT token.
|
||||
*/
|
||||
export async function registerAndLogin(
|
||||
app: FastifyInstance,
|
||||
overrides: {
|
||||
@@ -70,7 +66,7 @@ export async function registerAndLogin(
|
||||
headers: { 'x-company-id': TEST_COMPANY_ID },
|
||||
payload: {
|
||||
email: overrides.email ?? 'test@forte.dev',
|
||||
password: overrides.password ?? 'testpassword123',
|
||||
password: overrides.password ?? 'testpassword1234',
|
||||
firstName: overrides.firstName ?? 'Test',
|
||||
lastName: overrides.lastName ?? 'User',
|
||||
role: overrides.role ?? 'admin',
|
||||
@@ -78,5 +74,31 @@ export async function registerAndLogin(
|
||||
})
|
||||
|
||||
const body = response.json()
|
||||
|
||||
// Assign the admin role to the test user so they have all permissions
|
||||
if (body.user?.id) {
|
||||
const [adminRole] = await app.db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.companyId, TEST_COMPANY_ID), eq(roles.slug, 'admin')))
|
||||
.limit(1)
|
||||
|
||||
if (adminRole) {
|
||||
await RbacService.assignRole(app.db, body.user.id, adminRole.id)
|
||||
}
|
||||
|
||||
// Re-login to get a fresh token (permissions are loaded on authenticate)
|
||||
const loginRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/auth/login',
|
||||
payload: {
|
||||
email: overrides.email ?? 'test@forte.dev',
|
||||
password: overrides.password ?? 'testpassword1234',
|
||||
},
|
||||
})
|
||||
const loginBody = loginRes.json()
|
||||
return { token: loginBody.token, user: loginBody.user }
|
||||
}
|
||||
|
||||
return { token: body.token, user: body.user }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user