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

@@ -72,6 +72,25 @@ async function setupDatabase() {
await testSql`INSERT INTO item_condition (company_id, name, slug, description, is_system, sort_order) VALUES (${COMPANY_ID}, ${c.name}, ${c.slug}, ${c.description}, true, ${c.sortOrder})`
}
// Seed RBAC permissions and default roles
const { SYSTEM_PERMISSIONS, DEFAULT_ROLES } = await import('../src/db/seeds/rbac.js')
for (const p of SYSTEM_PERMISSIONS) {
await testSql`INSERT INTO permission (slug, domain, action, description) VALUES (${p.slug}, ${p.domain}, ${p.action}, ${p.description}) ON CONFLICT (slug) DO NOTHING`
}
const permRows = await testSql`SELECT id, slug FROM permission`
const permMap = new Map(permRows.map((r: any) => [r.slug, r.id]))
for (const roleDef of DEFAULT_ROLES) {
const [role] = await testSql`INSERT INTO role (company_id, name, slug, description, is_system) VALUES (${COMPANY_ID}, ${roleDef.name}, ${roleDef.slug}, ${roleDef.description}, true) RETURNING id`
for (const permSlug of roleDef.permissions) {
const permId = permMap.get(permSlug)
if (permId) {
await testSql`INSERT INTO role_permission (role_id, permission_id) VALUES (${role.id}, ${permId})`
}
}
}
await testSql.end()
console.log(' Database ready')
}
@@ -128,6 +147,8 @@ async function startBackend(): Promise<Subprocess> {
// --- Register test user ---
async function registerTestUser(): Promise<string> {
const testPassword = 'testpassword1234'
// Register needs x-company-id header
const headers = { 'Content-Type': 'application/json', 'x-company-id': COMPANY_ID }
const registerRes = await fetch(`${BASE_URL}/v1/auth/register`, {
@@ -135,20 +156,29 @@ async function registerTestUser(): Promise<string> {
headers,
body: JSON.stringify({
email: 'test@forte.dev',
password: 'testpassword123',
password: testPassword,
firstName: 'Test',
lastName: 'Runner',
role: 'admin',
}),
})
const registerData = await registerRes.json() as { token?: string }
if (registerRes.status === 201 && registerData.token) return registerData.token
const registerData = await registerRes.json() as { token?: string; user?: { id: string } }
// Already exists — login
// Assign admin role to the user via direct SQL
if (registerRes.status === 201 && registerData.user) {
const assignSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`)
const [adminRole] = await assignSql`SELECT id FROM role WHERE company_id = ${COMPANY_ID} AND slug = 'admin' LIMIT 1`
if (adminRole) {
await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`
}
await assignSql.end()
}
// Login to get token with permissions loaded
const loginRes = await fetch(`${BASE_URL}/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'test@forte.dev', password: 'testpassword123' }),
body: JSON.stringify({ email: 'test@forte.dev', password: testPassword }),
})
const loginData = await loginRes.json() as { token?: string }
if (loginRes.status !== 200 || !loginData.token) throw new Error(`Auth failed: ${JSON.stringify(loginData)}`)