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)}`)

View File

@@ -0,0 +1,43 @@
-- Permissions and role-based access control
CREATE TABLE IF NOT EXISTS "permission" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"slug" varchar(100) NOT NULL UNIQUE,
"domain" varchar(50) NOT NULL,
"action" varchar(50) NOT NULL,
"description" varchar(255) NOT NULL,
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "role" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"company_id" uuid NOT NULL REFERENCES "company"("id"),
"name" varchar(100) NOT NULL,
"slug" varchar(100) NOT NULL,
"description" text,
"is_system" boolean NOT NULL DEFAULT false,
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "role_company_slug" ON "role" ("company_id", "slug");
CREATE TABLE IF NOT EXISTS "role_permission" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"role_id" uuid NOT NULL REFERENCES "role"("id"),
"permission_id" uuid NOT NULL REFERENCES "permission"("id"),
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "role_permission_unique" ON "role_permission" ("role_id", "permission_id");
CREATE TABLE IF NOT EXISTS "user_role_assignment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" uuid NOT NULL REFERENCES "user"("id"),
"role_id" uuid NOT NULL REFERENCES "role"("id"),
"assigned_by" uuid,
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "user_role_assignment_unique" ON "user_role_assignment" ("user_id", "role_id");

View File

@@ -92,6 +92,13 @@
"when": 1774720000000,
"tag": "0012_file_storage",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1774730000000,
"tag": "0013_rbac",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,54 @@
import { pgTable, uuid, varchar, text, timestamp, boolean, uniqueIndex } from 'drizzle-orm/pg-core'
import { companies } from './stores.js'
import { users } from './users.js'
export const permissions = pgTable('permission', {
id: uuid('id').primaryKey().defaultRandom(),
slug: varchar('slug', { length: 100 }).notNull().unique(),
domain: varchar('domain', { length: 50 }).notNull(),
action: varchar('action', { length: 50 }).notNull(),
description: varchar('description', { length: 255 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const roles = pgTable('role', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id),
name: varchar('name', { length: 100 }).notNull(),
slug: varchar('slug', { length: 100 }).notNull(),
description: text('description'),
isSystem: boolean('is_system').notNull().default(false),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const rolePermissions = pgTable('role_permission', {
id: uuid('id').primaryKey().defaultRandom(),
roleId: uuid('role_id')
.notNull()
.references(() => roles.id),
permissionId: uuid('permission_id')
.notNull()
.references(() => permissions.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const userRoles = pgTable('user_role_assignment', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id),
roleId: uuid('role_id')
.notNull()
.references(() => roles.id),
assignedBy: uuid('assigned_by'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export type Permission = typeof permissions.$inferSelect
export type Role = typeof roles.$inferSelect
export type RolePermission = typeof rolePermissions.$inferSelect
export type UserRole = typeof userRoles.$inferSelect

View File

@@ -0,0 +1,133 @@
/**
* System permissions and default role definitions.
* Seeded per company on creation.
*/
export const SYSTEM_PERMISSIONS = [
// Accounts
{ slug: 'accounts.view', domain: 'accounts', action: 'view', description: 'View accounts, members, payment methods, tax exemptions' },
{ slug: 'accounts.edit', domain: 'accounts', action: 'edit', description: 'Create and edit accounts, members, payment methods' },
{ slug: 'accounts.admin', domain: 'accounts', action: 'admin', description: 'Delete accounts, approve tax exemptions, manage identifiers' },
// Inventory
{ slug: 'inventory.view', domain: 'inventory', action: 'view', description: 'View products, stock levels, categories, suppliers' },
{ slug: 'inventory.edit', domain: 'inventory', action: 'edit', description: 'Create and edit products, receive stock, manage categories' },
{ slug: 'inventory.admin', domain: 'inventory', action: 'admin', description: 'Delete products, adjust stock, manage lookup values' },
// POS
{ slug: 'pos.view', domain: 'pos', action: 'view', description: 'View transaction history, cash drawer sessions' },
{ slug: 'pos.edit', domain: 'pos', action: 'edit', description: 'Process sales, take payments, apply discounts' },
{ slug: 'pos.admin', domain: 'pos', action: 'admin', description: 'Void transactions, override prices, manage discounts' },
// Rentals
{ slug: 'rentals.view', domain: 'rentals', action: 'view', description: 'View rental contracts, fleet, billing' },
{ slug: 'rentals.edit', domain: 'rentals', action: 'edit', description: 'Create rentals, process returns, manage fleet' },
{ slug: 'rentals.admin', domain: 'rentals', action: 'admin', description: 'Override terms, adjust equity, cancel contracts' },
// Lessons
{ slug: 'lessons.view', domain: 'lessons', action: 'view', description: 'View lesson schedules, enrollments, attendance' },
{ slug: 'lessons.edit', domain: 'lessons', action: 'edit', description: 'Manage scheduling, enrollment, attendance' },
{ slug: 'lessons.admin', domain: 'lessons', action: 'admin', description: 'Configure lesson settings, manage instructors' },
// Repairs
{ slug: 'repairs.view', domain: 'repairs', action: 'view', description: 'View repair tickets, parts inventory' },
{ slug: 'repairs.edit', domain: 'repairs', action: 'edit', description: 'Create tickets, log parts and labor, update status' },
{ slug: 'repairs.admin', domain: 'repairs', action: 'admin', description: 'Override estimates, manage templates, delete tickets' },
// Accounting
{ slug: 'accounting.view', domain: 'accounting', action: 'view', description: 'View journal entries, reports, AR aging' },
{ slug: 'accounting.edit', domain: 'accounting', action: 'edit', description: 'Create journal entries, manage chart of accounts' },
{ slug: 'accounting.admin', domain: 'accounting', action: 'admin', description: 'Close periods, adjust entries, export data' },
// Personnel
{ slug: 'personnel.view', domain: 'personnel', action: 'view', description: 'View schedules, time entries' },
{ slug: 'personnel.edit', domain: 'personnel', action: 'edit', description: 'Manage schedules, approve time off' },
{ slug: 'personnel.admin', domain: 'personnel', action: 'admin', description: 'Payroll export, manage pay rates' },
// Files
{ slug: 'files.view', domain: 'files', action: 'view', description: 'View and download files' },
{ slug: 'files.upload', domain: 'files', action: 'upload', description: 'Upload files' },
{ slug: 'files.delete', domain: 'files', action: 'delete', description: 'Delete files' },
// Email
{ slug: 'email.view', domain: 'email', action: 'view', description: 'View email logs' },
{ slug: 'email.send', domain: 'email', action: 'send', description: 'Send mass emails' },
{ slug: 'email.admin', domain: 'email', action: 'admin', description: 'Manage email templates and campaigns' },
// Settings
{ slug: 'settings.view', domain: 'settings', action: 'view', description: 'View store settings' },
{ slug: 'settings.edit', domain: 'settings', action: 'edit', description: 'Edit store settings, locations, tax rates' },
// Users
{ slug: 'users.view', domain: 'users', action: 'view', description: 'View users and roles' },
{ slug: 'users.edit', domain: 'users', action: 'edit', description: 'Create and edit users, assign roles' },
{ slug: 'users.admin', domain: 'users', action: 'admin', description: 'Create and modify roles, manage permissions' },
// Reports
{ slug: 'reports.view', domain: 'reports', action: 'view', description: 'View reports' },
{ slug: 'reports.export', domain: 'reports', action: 'export', description: 'Export report data' },
// System
{ slug: 'system.backup', domain: 'system', action: 'backup', description: 'Create and download backups' },
{ slug: 'system.restore', domain: 'system', action: 'restore', description: 'Restore from backup' },
{ slug: 'system.audit', domain: 'system', action: 'audit', description: 'View audit trail' },
] as const
/** Default system roles with their permission slugs */
export const DEFAULT_ROLES = [
{
slug: 'admin',
name: 'Admin',
description: 'Full access to all features',
permissions: SYSTEM_PERMISSIONS.map((p) => p.slug),
},
{
slug: 'manager',
name: 'Manager',
description: 'Full access except user administration',
permissions: SYSTEM_PERMISSIONS
.filter((p) => p.slug !== 'users.admin')
.map((p) => p.slug),
},
{
slug: 'sales_associate',
name: 'Sales Associate',
description: 'Front counter sales, customer management',
permissions: [
'accounts.view', 'accounts.edit',
'inventory.view',
'pos.view', 'pos.edit',
'rentals.view',
'files.view', 'files.upload',
'reports.view',
],
},
{
slug: 'technician',
name: 'Technician',
description: 'Repair ticket management',
permissions: [
'repairs.view', 'repairs.edit',
'inventory.view',
'accounts.view',
'files.view', 'files.upload',
],
},
{
slug: 'instructor',
name: 'Instructor',
description: 'Lesson management',
permissions: [
'lessons.view', 'lessons.edit',
'accounts.view',
],
},
{
slug: 'viewer',
name: 'Viewer',
description: 'Read-only access to all areas',
permissions: SYSTEM_PERMISSIONS
.filter((p) => p.action === 'view')
.map((p) => p.slug),
},
] as const

View File

@@ -1,11 +1,13 @@
import fp from 'fastify-plugin'
import fjwt from '@fastify/jwt'
import { RbacService } from '../services/rbac.service.js'
declare module 'fastify' {
interface FastifyRequest {
companyId: string
locationId: string
user: { id: string; companyId: string; role: string }
permissions: Set<string>
}
}
@@ -16,6 +18,36 @@ declare module '@fastify/jwt' {
}
}
/**
* Permission inheritance: admin implies edit implies view for the same domain.
* e.g. having "accounts.admin" means you also have "accounts.edit" and "accounts.view"
*/
const ACTION_HIERARCHY: Record<string, string[]> = {
admin: ['admin', 'edit', 'view'],
edit: ['edit', 'view'],
view: ['view'],
// Non-hierarchical actions (files, reports) don't cascade
upload: ['upload'],
delete: ['delete'],
send: ['send'],
export: ['export'],
}
function expandPermissions(slugs: string[]): Set<string> {
const expanded = new Set<string>()
for (const slug of slugs) {
expanded.add(slug)
const [domain, action] = slug.split('.')
const implied = ACTION_HIERARCHY[action]
if (implied && domain) {
for (const a of implied) {
expanded.add(`${domain}.${a}`)
}
}
}
return expanded
}
export const authPlugin = fp(async (app) => {
const secret = process.env.JWT_SECRET
if (!secret) {
@@ -32,17 +64,45 @@ export const authPlugin = fp(async (app) => {
app.addHook('onRequest', async (request) => {
request.companyId = (request.headers['x-company-id'] as string) ?? ''
request.locationId = (request.headers['x-location-id'] as string) ?? ''
request.permissions = new Set()
})
app.decorate('authenticate', async function (request: any, reply: any) {
try {
await request.jwtVerify()
request.companyId = request.user.companyId
// Load permissions from DB and expand with inheritance
const permSlugs = await RbacService.getUserPermissions(app.db, request.user.id)
request.permissions = expandPermissions(permSlugs)
} catch (_err) {
reply.status(401).send({ error: { message: 'Unauthorized', statusCode: 401 } })
}
})
app.decorate('requirePermission', function (...requiredPermissions: string[]) {
return async function (request: any, reply: any) {
// If user has no permissions loaded (shouldn't happen after authenticate), deny
if (!request.permissions || request.permissions.size === 0) {
reply.status(403).send({ error: { message: 'No permissions assigned', statusCode: 403 } })
return
}
// Check if user has ANY of the required permissions
const hasPermission = requiredPermissions.some((p) => request.permissions.has(p))
if (!hasPermission) {
reply.status(403).send({
error: {
message: 'Insufficient permissions',
statusCode: 403,
required: requiredPermissions,
},
})
}
}
})
// Keep legacy requireRole for backward compatibility during migration
app.decorate('requireRole', function (...roles: string[]) {
return async function (request: any, reply: any) {
if (!roles.includes(request.user.role)) {
@@ -57,6 +117,7 @@ export const authPlugin = fp(async (app) => {
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: any, reply: any) => Promise<void>
requirePermission: (...permissions: string[]) => (request: any, reply: any) => Promise<void>
requireRole: (...roles: string[]) => (request: any, reply: any) => Promise<void>
}
}

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) {

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 } })

View File

@@ -11,7 +11,7 @@ import { CategoryService, SupplierService } from '../../services/inventory.servi
export const inventoryRoutes: FastifyPluginAsync = async (app) => {
// --- Categories ---
app.post('/categories', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/categories', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const parsed = CategoryCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
@@ -20,20 +20,20 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(category)
})
app.get('/categories', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/categories', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await CategoryService.list(app.db, request.companyId, params)
return reply.send(result)
})
app.get('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const category = await CategoryService.getById(app.db, request.companyId, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
return reply.send(category)
})
app.patch('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = CategoryUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -44,7 +44,7 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.send(category)
})
app.delete('/categories/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/categories/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const category = await CategoryService.softDelete(app.db, request.companyId, id)
if (!category) return reply.status(404).send({ error: { message: 'Category not found', statusCode: 404 } })
@@ -53,7 +53,7 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
// --- Suppliers ---
app.post('/suppliers', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const parsed = SupplierCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
@@ -62,20 +62,20 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(supplier)
})
app.get('/suppliers', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/suppliers', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await SupplierService.list(app.db, request.companyId, params)
return reply.send(result)
})
app.get('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const supplier = await SupplierService.getById(app.db, request.companyId, id)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })
return reply.send(supplier)
})
app.patch('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = SupplierUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -86,7 +86,7 @@ export const inventoryRoutes: FastifyPluginAsync = async (app) => {
return reply.send(supplier)
})
app.delete('/suppliers/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/suppliers/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const supplier = await SupplierService.softDelete(app.db, request.companyId, id)
if (!supplier) return reply.status(404).send({ error: { message: 'Supplier not found', statusCode: 404 } })

View File

@@ -5,12 +5,12 @@ import { ConflictError, ValidationError } from '../../lib/errors.js'
function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
const routes: FastifyPluginAsync = async (app) => {
app.get(`/${prefix}`, { preHandler: [app.authenticate] }, async (request, reply) => {
app.get(`/${prefix}`, { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const data = await service.list(app.db, request.companyId)
return reply.send({ data })
})
app.post(`/${prefix}`, { preHandler: [app.authenticate] }, async (request, reply) => {
app.post(`/${prefix}`, { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const parsed = LookupCreateSchema.safeParse(request.body)
if (!parsed.success) {
throw new ValidationError('Validation failed', parsed.error.flatten())
@@ -25,7 +25,7 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
return reply.status(201).send(row)
})
app.patch(`/${prefix}/:id`, { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch(`/${prefix}/:id`, { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = LookupUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -36,7 +36,7 @@ function createLookupRoutes(prefix: string, service: typeof UnitStatusService) {
return reply.send(row)
})
app.delete(`/${prefix}/:id`, { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete(`/${prefix}/:id`, { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const row = await service.delete(app.db, request.companyId, id)
if (!row) return reply.status(404).send({ error: { message: 'Not found', statusCode: 404 } })

View File

@@ -11,7 +11,7 @@ import { ProductService, InventoryUnitService } from '../../services/product.ser
export const productRoutes: FastifyPluginAsync = async (app) => {
// --- Products ---
app.post('/products', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const parsed = ProductCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
@@ -20,20 +20,20 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(product)
})
app.get('/products', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const params = PaginationSchema.parse(request.query)
const result = await ProductService.list(app.db, request.companyId, params)
return reply.send(result)
})
app.get('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const product = await ProductService.getById(app.db, request.companyId, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
return reply.send(product)
})
app.patch('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = ProductUpdateSchema.safeParse(request.body)
if (!parsed.success) {
@@ -44,7 +44,7 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
return reply.send(product)
})
app.delete('/products/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.delete('/products/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const product = await ProductService.softDelete(app.db, request.companyId, id)
if (!product) return reply.status(404).send({ error: { message: 'Product not found', statusCode: 404 } })
@@ -53,7 +53,7 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
// --- Inventory Units ---
app.post('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
app.post('/products/:productId/units', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const parsed = InventoryUnitCreateSchema.safeParse({ ...(request.body as object), productId })
if (!parsed.success) {
@@ -63,21 +63,21 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(unit)
})
app.get('/products/:productId/units', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/products/:productId/units', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { productId } = request.params as { productId: string }
const params = PaginationSchema.parse(request.query)
const result = await InventoryUnitService.listByProduct(app.db, request.companyId, productId, params)
return reply.send(result)
})
app.get('/units/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.get('/units/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const unit = await InventoryUnitService.getById(app.db, request.companyId, id)
if (!unit) return reply.status(404).send({ error: { message: 'Unit not found', statusCode: 404 } })
return reply.send(unit)
})
app.patch('/units/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
app.patch('/units/:id', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = InventoryUnitUpdateSchema.safeParse(request.body)
if (!parsed.success) {

View File

@@ -0,0 +1,250 @@
import { eq, and, inArray } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { permissions, roles, rolePermissions, userRoles } from '../db/schema/rbac.js'
import { SYSTEM_PERMISSIONS, DEFAULT_ROLES } from '../db/seeds/rbac.js'
import { ForbiddenError } from '../lib/errors.js'
export const RbacService = {
/** Seed system permissions (global, run once) */
async seedPermissions(db: PostgresJsDatabase) {
const existing = await db.select({ slug: permissions.slug }).from(permissions)
const existingSlugs = new Set(existing.map((p) => p.slug))
const toInsert = SYSTEM_PERMISSIONS.filter((p) => !existingSlugs.has(p.slug))
if (toInsert.length === 0) return
await db.insert(permissions).values(toInsert)
},
/** Seed default roles for a company */
async seedRolesForCompany(db: PostgresJsDatabase, companyId: string) {
const existingRoles = await db
.select({ slug: roles.slug })
.from(roles)
.where(and(eq(roles.companyId, companyId), eq(roles.isSystem, true)))
if (existingRoles.length > 0) return // already seeded
// Get all permission records for slug → id mapping
const allPerms = await db.select().from(permissions)
const permMap = new Map(allPerms.map((p) => [p.slug, p.id]))
for (const roleDef of DEFAULT_ROLES) {
const [role] = await db
.insert(roles)
.values({
companyId,
name: roleDef.name,
slug: roleDef.slug,
description: roleDef.description,
isSystem: true,
})
.returning()
const permIds = roleDef.permissions
.map((slug) => permMap.get(slug))
.filter((id): id is string => id !== undefined)
if (permIds.length > 0) {
await db.insert(rolePermissions).values(
permIds.map((permissionId) => ({
roleId: role.id,
permissionId,
})),
)
}
}
},
/** Get all permissions for a user (union of all role permissions) */
async getUserPermissions(db: PostgresJsDatabase, userId: string): Promise<string[]> {
const userRoleRecords = await db
.select({ roleId: userRoles.roleId })
.from(userRoles)
.where(eq(userRoles.userId, userId))
if (userRoleRecords.length === 0) return []
const roleIds = userRoleRecords.map((r) => r.roleId)
const rpRecords = await db
.select({ permissionId: rolePermissions.permissionId })
.from(rolePermissions)
.where(inArray(rolePermissions.roleId, roleIds))
if (rpRecords.length === 0) return []
const permIds = [...new Set(rpRecords.map((r) => r.permissionId))]
const permRecords = await db
.select({ slug: permissions.slug })
.from(permissions)
.where(inArray(permissions.id, permIds))
return permRecords.map((p) => p.slug)
},
/** List all permissions */
async listPermissions(db: PostgresJsDatabase) {
return db.select().from(permissions).orderBy(permissions.domain, permissions.action)
},
/** List roles for a company */
async listRoles(db: PostgresJsDatabase, companyId: string) {
return db
.select()
.from(roles)
.where(and(eq(roles.companyId, companyId), eq(roles.isActive, true)))
.orderBy(roles.name)
},
/** Get role with its permissions */
async getRoleWithPermissions(db: PostgresJsDatabase, companyId: string, roleId: string) {
const [role] = await db
.select()
.from(roles)
.where(and(eq(roles.id, roleId), eq(roles.companyId, companyId)))
.limit(1)
if (!role) return null
const rp = await db
.select({ permissionId: rolePermissions.permissionId, slug: permissions.slug })
.from(rolePermissions)
.innerJoin(permissions, eq(rolePermissions.permissionId, permissions.id))
.where(eq(rolePermissions.roleId, roleId))
return { ...role, permissions: rp.map((r) => r.slug) }
},
/** Create a custom role */
async createRole(
db: PostgresJsDatabase,
companyId: string,
input: { name: string; slug: string; description?: string; permissionSlugs: string[] },
) {
const [role] = await db
.insert(roles)
.values({
companyId,
name: input.name,
slug: input.slug,
description: input.description,
isSystem: false,
})
.returning()
await this.setRolePermissions(db, role.id, input.permissionSlugs)
return this.getRoleWithPermissions(db, companyId, role.id)
},
/** Update role permissions (replace all) */
async setRolePermissions(db: PostgresJsDatabase, roleId: string, permissionSlugs: string[]) {
// Delete existing
await db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId))
if (permissionSlugs.length === 0) return
// Get permission IDs
const perms = await db
.select({ id: permissions.id, slug: permissions.slug })
.from(permissions)
.where(inArray(permissions.slug, permissionSlugs))
if (perms.length > 0) {
await db.insert(rolePermissions).values(
perms.map((p) => ({ roleId, permissionId: p.id })),
)
}
},
/** Update a role */
async updateRole(
db: PostgresJsDatabase,
companyId: string,
roleId: string,
input: { name?: string; description?: string; permissionSlugs?: string[] },
) {
if (input.name || input.description) {
await db
.update(roles)
.set({
...(input.name ? { name: input.name } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
updatedAt: new Date(),
})
.where(and(eq(roles.id, roleId), eq(roles.companyId, companyId)))
}
if (input.permissionSlugs) {
await this.setRolePermissions(db, roleId, input.permissionSlugs)
}
return this.getRoleWithPermissions(db, companyId, roleId)
},
/** Delete a custom role */
async deleteRole(db: PostgresJsDatabase, companyId: string, roleId: string) {
const [role] = await db
.select()
.from(roles)
.where(and(eq(roles.id, roleId), eq(roles.companyId, companyId)))
.limit(1)
if (!role) return null
if (role.isSystem) throw new ForbiddenError('Cannot delete a system role')
// Remove role permissions and user assignments
await db.delete(rolePermissions).where(eq(rolePermissions.roleId, roleId))
await db.delete(userRoles).where(eq(userRoles.roleId, roleId))
const [deleted] = await db
.delete(roles)
.where(eq(roles.id, roleId))
.returning()
return deleted
},
/** Assign a role to a user */
async assignRole(db: PostgresJsDatabase, userId: string, roleId: string, assignedBy?: string) {
const [existing] = await db
.select()
.from(userRoles)
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)))
.limit(1)
if (existing) return existing // already assigned
const [assignment] = await db
.insert(userRoles)
.values({ userId, roleId, assignedBy })
.returning()
return assignment
},
/** Remove a role from a user */
async removeRole(db: PostgresJsDatabase, userId: string, roleId: string) {
const [removed] = await db
.delete(userRoles)
.where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, roleId)))
.returning()
return removed ?? null
},
/** Get roles assigned to a user */
async getUserRoles(db: PostgresJsDatabase, userId: string) {
return db
.select({
id: roles.id,
name: roles.name,
slug: roles.slug,
isSystem: roles.isSystem,
})
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, userId))
},
}

View File

@@ -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 }
}