From 4a1fc608f070aff48f485439fe63c5836b2925b4 Mon Sep 17 00:00:00 2001 From: Ryan Moon Date: Sat, 28 Mar 2026 17:00:42 -0500 Subject: [PATCH] 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 --- packages/backend/api-tests/run.ts | 40 ++- .../backend/src/db/migrations/0013_rbac.sql | 43 +++ .../src/db/migrations/meta/_journal.json | 7 + packages/backend/src/db/schema/rbac.ts | 54 ++++ packages/backend/src/db/seeds/rbac.ts | 133 ++++++++++ packages/backend/src/plugins/auth.ts | 61 +++++ packages/backend/src/routes/v1/accounts.ts | 62 ++--- packages/backend/src/routes/v1/files.ts | 10 +- packages/backend/src/routes/v1/inventory.ts | 20 +- packages/backend/src/routes/v1/lookups.ts | 8 +- packages/backend/src/routes/v1/products.ts | 18 +- packages/backend/src/services/rbac.service.ts | 250 ++++++++++++++++++ packages/backend/src/test/helpers.ts | 52 ++-- 13 files changed, 679 insertions(+), 79 deletions(-) create mode 100644 packages/backend/src/db/migrations/0013_rbac.sql create mode 100644 packages/backend/src/db/schema/rbac.ts create mode 100644 packages/backend/src/db/seeds/rbac.ts create mode 100644 packages/backend/src/services/rbac.service.ts diff --git a/packages/backend/api-tests/run.ts b/packages/backend/api-tests/run.ts index 5bf3862..ea27fe4 100644 --- a/packages/backend/api-tests/run.ts +++ b/packages/backend/api-tests/run.ts @@ -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 { // --- Register test user --- async function registerTestUser(): Promise { + 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 { 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)}`) diff --git a/packages/backend/src/db/migrations/0013_rbac.sql b/packages/backend/src/db/migrations/0013_rbac.sql new file mode 100644 index 0000000..c2497fe --- /dev/null +++ b/packages/backend/src/db/migrations/0013_rbac.sql @@ -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"); diff --git a/packages/backend/src/db/migrations/meta/_journal.json b/packages/backend/src/db/migrations/meta/_journal.json index d08a3b6..57371b8 100644 --- a/packages/backend/src/db/migrations/meta/_journal.json +++ b/packages/backend/src/db/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1774720000000, "tag": "0012_file_storage", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1774730000000, + "tag": "0013_rbac", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/db/schema/rbac.ts b/packages/backend/src/db/schema/rbac.ts new file mode 100644 index 0000000..206e18e --- /dev/null +++ b/packages/backend/src/db/schema/rbac.ts @@ -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 diff --git a/packages/backend/src/db/seeds/rbac.ts b/packages/backend/src/db/seeds/rbac.ts new file mode 100644 index 0000000..70f6adf --- /dev/null +++ b/packages/backend/src/db/seeds/rbac.ts @@ -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 diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 953d789..74dee5f 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -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 } } @@ -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 = { + 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 { + const expanded = new Set() + 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 + requirePermission: (...permissions: string[]) => (request: any, reply: any) => Promise requireRole: (...roles: string[]) => (request: any, reply: any) => Promise } } diff --git a/packages/backend/src/routes/v1/accounts.ts b/packages/backend/src/routes/v1/accounts.ts index 42aca82..e49a848 100644 --- a/packages/backend/src/routes/v1/accounts.ts +++ b/packages/backend/src/routes/v1/accounts.ts @@ -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) { diff --git a/packages/backend/src/routes/v1/files.ts b/packages/backend/src/routes/v1/files.ts index f0ae5c8..389163c 100644 --- a/packages/backend/src/routes/v1/files.ts +++ b/packages/backend/src/routes/v1/files.ts @@ -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 } }) diff --git a/packages/backend/src/routes/v1/inventory.ts b/packages/backend/src/routes/v1/inventory.ts index d3ae4b9..0a3f2ad 100644 --- a/packages/backend/src/routes/v1/inventory.ts +++ b/packages/backend/src/routes/v1/inventory.ts @@ -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 } }) diff --git a/packages/backend/src/routes/v1/lookups.ts b/packages/backend/src/routes/v1/lookups.ts index e77533a..c65de4d 100644 --- a/packages/backend/src/routes/v1/lookups.ts +++ b/packages/backend/src/routes/v1/lookups.ts @@ -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 } }) diff --git a/packages/backend/src/routes/v1/products.ts b/packages/backend/src/routes/v1/products.ts index 4757fc9..1f8c492 100644 --- a/packages/backend/src/routes/v1/products.ts +++ b/packages/backend/src/routes/v1/products.ts @@ -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) { diff --git a/packages/backend/src/services/rbac.service.ts b/packages/backend/src/services/rbac.service.ts new file mode 100644 index 0000000..b64857b --- /dev/null +++ b/packages/backend/src/services/rbac.service.ts @@ -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 { + 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)) + }, +} diff --git a/packages/backend/src/test/helpers.ts b/packages/backend/src/test/helpers.ts index beca6c2..ee166c4 100644 --- a/packages/backend/src/test/helpers.ts +++ b/packages/backend/src/test/helpers.ts @@ -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 { const app = await buildApp() await app.ready() return app } -/** - * Truncate all tables in the test database. - */ export async function cleanDb(app: FastifyInstance): Promise { 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 { await app.db.insert(companies).values({ id: TEST_COMPANY_ID, @@ -46,14 +42,14 @@ export async function seedTestCompany(app: FastifyInstance): Promise { 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 } }