diff --git a/__tests__/rbac.test.ts b/__tests__/rbac.test.ts new file mode 100644 index 0000000..52bbb29 --- /dev/null +++ b/__tests__/rbac.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { hasPermission, hasAnyPermission, hasAllPermissions, ROLE_PERMISSIONS, UserRole, Permission } from '@/lib/auth/constants' + +describe('RBAC Constants and Helpers', () => { + describe('ROLE_PERMISSIONS', () => { + it('should have freelancer permissions defined', () => { + expect(ROLE_PERMISSIONS.freelancer).toBeDefined() + expect(ROLE_PERMISSIONS.freelancer.length).toBeGreaterThan(0) + }) + + it('should have client permissions defined', () => { + expect(ROLE_PERMISSIONS.client).toBeDefined() + expect(ROLE_PERMISSIONS.client.length).toBeGreaterThan(0) + }) + + it('should have admin permissions defined', () => { + expect(ROLE_PERMISSIONS.admin).toBeDefined() + expect(ROLE_PERMISSIONS.admin.length).toBeGreaterThan(0) + }) + + it('admin should have all permissions', () => { + const allPermissions: Permission[] = [ + 'project:create', + 'project:view', + 'project:update', + 'project:delete', + 'milestone:view', + 'milestone:submit', + 'milestone:approve', + 'milestone:reject', + 'escrow:view', + 'escrow:fund', + 'escrow:release', + 'escrow:refund', + 'contract:view', + 'contract:create', + 'contract:update', + 'dispute:create', + 'dispute:view', + 'dispute:resolve', + 'admin:users_manage', + 'admin:contracts_freeze', + 'admin:system_oversight', + 'reputation:view', + 'reviews:create', + 'reviews:view', + ] + + allPermissions.forEach(permission => { + expect(ROLE_PERMISSIONS.admin).toContain(permission) + }) + }) + }) + + describe('Freelancer Permissions', () => { + it('freelancer should have milestone:submit permission', () => { + expect(hasPermission('freelancer', 'milestone:submit')).toBe(true) + }) + + it('freelancer should NOT have project:create permission', () => { + expect(hasPermission('freelancer', 'project:create')).toBe(false) + }) + + it('freelancer should NOT have milestone:approve permission', () => { + expect(hasPermission('freelancer', 'milestone:approve')).toBe(false) + }) + + it('freelancer should have dispute:create permission', () => { + expect(hasPermission('freelancer', 'dispute:create')).toBe(true) + }) + + it('freelancer should NOT have dispute:resolve permission', () => { + expect(hasPermission('freelancer', 'dispute:resolve')).toBe(false) + }) + }) + + describe('Client Permissions', () => { + it('client should have project:create permission', () => { + expect(hasPermission('client', 'project:create')).toBe(true) + }) + + it('client should have milestone:approve permission', () => { + expect(hasPermission('client', 'milestone:approve')).toBe(true) + }) + + it('client should have milestone:reject permission', () => { + expect(hasPermission('client', 'milestone:reject')).toBe(true) + }) + + it('client should NOT have milestone:submit permission', () => { + expect(hasPermission('client', 'milestone:submit')).toBe(false) + }) + + it('client should have escrow:fund permission', () => { + expect(hasPermission('client', 'escrow:fund')).toBe(true) + }) + + it('client should have escrow:release permission', () => { + expect(hasPermission('client', 'escrow:release')).toBe(true) + }) + + it('client should NOT have dispute:resolve permission', () => { + expect(hasPermission('client', 'dispute:resolve')).toBe(false) + }) + }) + + describe('Admin Permissions', () => { + it('admin should have dispute:resolve permission', () => { + expect(hasPermission('admin', 'dispute:resolve')).toBe(true) + }) + + it('admin should have admin:users_manage permission', () => { + expect(hasPermission('admin', 'admin:users_manage')).toBe(true) + }) + + it('admin should have admin:contracts_freeze permission', () => { + expect(hasPermission('admin', 'admin:contracts_freeze')).toBe(true) + }) + + it('admin should have all project permissions', () => { + expect(hasPermission('admin', 'project:create')).toBe(true) + expect(hasPermission('admin', 'project:view')).toBe(true) + expect(hasPermission('admin', 'project:update')).toBe(true) + expect(hasPermission('admin', 'project:delete')).toBe(true) + }) + }) + + describe('hasPermission Helper', () => { + it('should return true for valid permission', () => { + expect(hasPermission('freelancer', 'milestone:submit')).toBe(true) + }) + + it('should return false for invalid permission', () => { + expect(hasPermission('freelancer', 'project:create')).toBe(false) + }) + }) + + describe('hasAnyPermission Helper', () => { + it('should return true if user has any of the permissions', () => { + expect(hasAnyPermission('freelancer', ['milestone:submit', 'project:create'])).toBe(true) + }) + + it('should return false if user has none of the permissions', () => { + expect(hasAnyPermission('freelancer', ['project:create', 'project:delete'])).toBe(false) + }) + + it('should return true if user has all permissions', () => { + expect(hasAnyPermission('admin', ['dispute:resolve', 'admin:users_manage'])).toBe(true) + }) + }) + + describe('hasAllPermissions Helper', () => { + it('should return true if user has all permissions', () => { + expect(hasAllPermissions('client', ['project:create', 'milestone:approve'])).toBe(true) + }) + + it('should return false if user is missing any permission', () => { + expect(hasAllPermissions('freelancer', ['milestone:submit', 'project:create'])).toBe(false) + }) + + it('should return true for admin with any permissions', () => { + expect(hasAllPermissions('admin', ['dispute:resolve', 'admin:users_manage'])).toBe(true) + }) + }) + + describe('Permission Scope by Role', () => { + it('freelancer should only have view permissions for projects', () => { + expect(hasPermission('freelancer', 'project:view')).toBe(true) + expect(hasPermission('freelancer', 'project:create')).toBe(false) + expect(hasPermission('freelancer', 'project:update')).toBe(false) + expect(hasPermission('freelancer', 'project:delete')).toBe(false) + }) + + it('client should have full project management permissions', () => { + expect(hasPermission('client', 'project:view')).toBe(true) + expect(hasPermission('client', 'project:create')).toBe(true) + expect(hasPermission('client', 'project:update')).toBe(true) + expect(hasPermission('client', 'project:delete')).toBe(true) + }) + + it('freelancer should have submit but not approve milestones', () => { + expect(hasPermission('freelancer', 'milestone:submit')).toBe(true) + expect(hasPermission('freelancer', 'milestone:approve')).toBe(false) + expect(hasPermission('freelancer', 'milestone:reject')).toBe(false) + }) + + it('client should have approve and reject but not submit milestones', () => { + expect(hasPermission('client', 'milestone:submit')).toBe(false) + expect(hasPermission('client', 'milestone:approve')).toBe(true) + expect(hasPermission('client', 'milestone:reject')).toBe(true) + }) + + it('only admin should have admin-specific permissions', () => { + expect(hasPermission('admin', 'admin:users_manage')).toBe(true) + expect(hasPermission('admin', 'admin:contracts_freeze')).toBe(true) + expect(hasPermission('admin', 'admin:system_oversight')).toBe(true) + + expect(hasPermission('freelancer', 'admin:users_manage')).toBe(false) + expect(hasPermission('client', 'admin:users_manage')).toBe(false) + + expect(hasPermission('freelancer', 'admin:contracts_freeze')).toBe(false) + expect(hasPermission('client', 'admin:contracts_freeze')).toBe(false) + }) + }) +}) diff --git a/app/api/disputes/[id]/resolve/route.ts b/app/api/disputes/[id]/resolve/route.ts index b94c324..62fcbd9 100644 --- a/app/api/disputes/[id]/resolve/route.ts +++ b/app/api/disputes/[id]/resolve/route.ts @@ -2,17 +2,15 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { sql } from '@/lib/db' -import { withAuth } from '@/lib/auth/middleware' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' type RouteContext = { params: Promise<{ id: string }> } -export const POST = withAuth(async (request: NextRequest, auth, context: RouteContext) => { +export const POST = withRbac('dispute:resolve', async (request: NextRequest, auth: RbacContext, context: RouteContext) => { const { id } = await context.params try { const { resolution, newState } = await request.json() if (!resolution) return NextResponse.json({ error: 'Missing required field: resolution', code: 'MISSING_FIELDS' }, { status: 400 }) - const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress}` - if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) const [dispute] = await sql`SELECT d.*, j.escrow_contract_id FROM disputes d JOIN jobs j ON d.job_id = j.id WHERE d.id = ${id}` if (!dispute) return NextResponse.json({ error: 'Dispute not found', code: 'DISPUTE_NOT_FOUND' }, { status: 404 }) const nextState = newState || 'resolved' diff --git a/app/api/disputes/route.ts b/app/api/disputes/route.ts index 847d3fd..f60de9c 100644 --- a/app/api/disputes/route.ts +++ b/app/api/disputes/route.ts @@ -2,9 +2,9 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { sql } from '@/lib/db' -import { withAuth } from '@/lib/auth/middleware' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' -export const POST = withAuth(async (request: NextRequest, auth) => { +export const POST = withRbac('dispute:create', async (request: NextRequest, auth: RbacContext) => { try { const body = await request.json() const { jobId, reason } = body @@ -13,9 +13,7 @@ export const POST = withAuth(async (request: NextRequest, auth) => { } const [job] = await sql`SELECT id, client_id, freelancer_id FROM jobs WHERE id = ${jobId}` if (!job) return NextResponse.json({ error: 'Job not found', code: 'JOB_NOT_FOUND' }, { status: 404 }) - const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress}` - if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) - const [dispute] = await sql`INSERT INTO disputes (job_id, raised_by, reason) VALUES (${job.id}, ${user.id}, ${reason}) RETURNING *` + const [dispute] = await sql`INSERT INTO disputes (job_id, raised_by, reason) VALUES (${job.id}, ${auth.userId}, ${reason}) RETURNING *` await sql`UPDATE jobs SET status = 'disputed', updated_at = CURRENT_TIMESTAMP WHERE id = ${jobId}` return NextResponse.json(dispute, { status: 201 }) } catch { @@ -23,14 +21,12 @@ export const POST = withAuth(async (request: NextRequest, auth) => { } }) -export const GET = withAuth(async (_request: NextRequest, auth) => { +export const GET = withRbac('dispute:view', async (_request: NextRequest, auth: RbacContext) => { try { - const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress}` - if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) const disputes = await sql` SELECT d.*, j.title as job_title, u.username as raised_by_username FROM disputes d JOIN jobs j ON d.job_id = j.id JOIN users u ON d.raised_by = u.id - WHERE j.client_id = ${user.id} OR j.freelancer_id = ${user.id} + WHERE j.client_id = ${auth.userId} OR j.freelancer_id = ${auth.userId} ORDER BY d.created_at DESC ` return NextResponse.json(disputes, { status: 200 }) diff --git a/app/api/escrow/fund/route.ts b/app/api/escrow/fund/route.ts index 1221943..08e9138 100644 --- a/app/api/escrow/fund/route.ts +++ b/app/api/escrow/fund/route.ts @@ -11,10 +11,10 @@ */ import { NextRequest, NextResponse } from 'next/server' -import { withAuth } from '@/lib/auth/middleware' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow' -export const POST = withAuth(async (request: NextRequest, auth) => { +export const POST = withRbac('escrow:fund', async (request: NextRequest, auth: RbacContext) => { let body: Record try { body = await request.json() diff --git a/app/api/escrow/refund/route.ts b/app/api/escrow/refund/route.ts index 7fcf5fd..a7d7cbd 100644 --- a/app/api/escrow/refund/route.ts +++ b/app/api/escrow/refund/route.ts @@ -10,10 +10,10 @@ */ import { NextRequest, NextResponse } from 'next/server' -import { withAuth } from '@/lib/auth/middleware' +import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware' import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow' -export const POST = withAuth(async (request: NextRequest, auth) => { +export const POST = withAnyRbac(['escrow:refund', 'admin:contracts_freeze'], async (request: NextRequest, auth: RbacContext) => { let body: Record try { body = await request.json() diff --git a/app/api/escrow/release/route.ts b/app/api/escrow/release/route.ts index 2fd70b6..ecb6847 100644 --- a/app/api/escrow/release/route.ts +++ b/app/api/escrow/release/route.ts @@ -10,10 +10,10 @@ */ import { NextRequest, NextResponse } from 'next/server' -import { withAuth } from '@/lib/auth/middleware' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow' -export const POST = withAuth(async (request: NextRequest, auth) => { +export const POST = withRbac('escrow:release', async (request: NextRequest, auth: RbacContext) => { let body: Record try { body = await request.json() diff --git a/app/api/milestones/[id]/approve/route.ts b/app/api/milestones/[id]/approve/route.ts index a95cfb7..6ea8719 100644 --- a/app/api/milestones/[id]/approve/route.ts +++ b/app/api/milestones/[id]/approve/route.ts @@ -1,11 +1,11 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { withAuth } from '@/lib/auth/middleware' +import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware' import { sql } from '@/lib/db' // Only the contract client can approve (or reject) a submitted milestone -export const POST = withAuth(async (request: NextRequest, auth) => { +export const POST = withAnyRbac(['milestone:approve', 'milestone:reject'], async (request: NextRequest, auth: RbacContext) => { const id = request.nextUrl.pathname.split('/').at(-2) try { @@ -26,9 +26,6 @@ export const POST = withAuth(async (request: NextRequest, auth) => { ) } - const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1` - if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) - // Fetch milestone with contract info to verify client role const [milestone] = await sql` SELECT m.*, c.client_id @@ -39,7 +36,7 @@ export const POST = withAuth(async (request: NextRequest, auth) => { ` if (!milestone) return NextResponse.json({ error: 'Milestone not found', code: 'MILESTONE_NOT_FOUND' }, { status: 404 }) - if (!milestone.contract_id || milestone.client_id !== user.id) { + if (!milestone.contract_id || milestone.client_id !== auth.userId) { return NextResponse.json({ error: 'Access denied', code: 'FORBIDDEN' }, { status: 403 }) } diff --git a/app/api/milestones/[id]/submit/route.ts b/app/api/milestones/[id]/submit/route.ts index fdba1b2..3649195 100644 --- a/app/api/milestones/[id]/submit/route.ts +++ b/app/api/milestones/[id]/submit/route.ts @@ -1,20 +1,17 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' -import { withAuth } from '@/lib/auth/middleware' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' import { sql } from '@/lib/db' // Only the contract freelancer can submit a milestone (status must be pending or in_progress) -export const POST = withAuth(async (request: NextRequest, auth) => { +export const POST = withRbac('milestone:submit', async (request: NextRequest, auth: RbacContext) => { const id = request.nextUrl.pathname.split('/').at(-2) try { const body = await request.json().catch(() => ({})) const { deliverables } = body - const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1` - if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) - // Fetch milestone with contract info to verify freelancer role const [milestone] = await sql` SELECT m.*, c.freelancer_id @@ -26,7 +23,7 @@ export const POST = withAuth(async (request: NextRequest, auth) => { if (!milestone) return NextResponse.json({ error: 'Milestone not found', code: 'MILESTONE_NOT_FOUND' }, { status: 404 }) // Must have a contract and caller must be the freelancer - if (!milestone.contract_id || milestone.freelancer_id !== user.id) { + if (!milestone.contract_id || milestone.freelancer_id !== auth.userId) { return NextResponse.json({ error: 'Access denied', code: 'FORBIDDEN' }, { status: 403 }) } diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index 4317cb5..aee0b80 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -3,7 +3,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { sql } from '@/lib/db' import { z } from 'zod' -import { verifyAccessToken } from '@/lib/auth/session' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' const MOCK_WALLET = 'GMOCKUSER1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ123456' @@ -25,33 +25,13 @@ const bodySchema = z.object({ milestones: z.array(milestoneSchema).min(1), }) -async function saveProject(walletAddress: string, body: z.infer) { - let rows = await sql<{ id: string }[]>` - SELECT id FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 - ` - if (!rows.length) { - rows = await sql<{ id: string }[]>` - INSERT INTO users (wallet_address, username, email, role) - VALUES ( - ${walletAddress}, - ${'dev_' + walletAddress.slice(-8).toLowerCase()}, - ${'dev_' + walletAddress.slice(-8).toLowerCase() + '@mock.local'}, - 'client' - ) - ON CONFLICT (wallet_address) DO UPDATE SET wallet_address = EXCLUDED.wallet_address - RETURNING id - ` - } - - const clientId = rows[0]?.id - if (!clientId) throw new Error('Could not resolve user') - +async function saveProject(userId: string, body: z.infer) { const projectRows = await sql<{ id: string }[]>` INSERT INTO projects ( client_id, title, description, category, budget_min, budget_max, currency, deadline, status ) VALUES ( - ${clientId}, ${body.title}, ${body.description}, + ${userId}, ${body.title}, ${body.description}, ${body.category ?? null}, ${body.totalAmount}, ${body.totalAmount}, ${body.currency}, ${body.deadline}, 'draft' ) @@ -76,25 +56,7 @@ async function saveProject(walletAddress: string, body: z.infer { - let walletAddress: string | null = null - - const authHeader = request.headers.get('authorization') - const cookieToken = request.cookies.get('tc_access_token')?.value - const rawToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : cookieToken - - if (rawToken) { - const payload = verifyAccessToken(rawToken) - if (payload) walletAddress = payload.walletAddress - } - - if (!walletAddress) { - if (process.env.NODE_ENV === 'production') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - walletAddress = MOCK_WALLET - } - +export const POST = withRbac('project:create', async (request: NextRequest, auth: RbacContext): Promise => { let raw: unknown try { raw = await request.json() } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) @@ -106,9 +68,9 @@ export async function POST(request: NextRequest): Promise { } try { - const result = await saveProject(walletAddress, parsed.data) + const result = await saveProject(auth.userId, parsed.data) return NextResponse.json({ ...result, status: 'draft' }, { status: 201 }) } catch (err) { return NextResponse.json({ error: err instanceof Error ? err.message : 'Database error' }, { status: 500 }) } -} +}) diff --git a/app/api/reviews/route.ts b/app/api/reviews/route.ts index 683284a..fae5541 100644 --- a/app/api/reviews/route.ts +++ b/app/api/reviews/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { sql } from "@/lib/db"; +import { withRbac, RbacContext } from "@/lib/auth/rbacMiddleware"; const reviewSchema = z.object({ contractId: z.number().int().positive(), @@ -10,7 +11,7 @@ const reviewSchema = z.object({ comment: z.string().optional(), }); -export async function POST(req: Request) { +export const POST = withRbac('reviews:create', async (req: Request, auth: RbacContext) => { try { const body = await req.json(); @@ -23,7 +24,7 @@ export async function POST(req: Request) { ); } - const { contractId, reviewerId, freelancerId, rating, comment } = result.data; + const { contractId, freelancerId, rating, comment } = result.data; // Check if contract exists and get its status const contractResult = (await sql` @@ -45,7 +46,7 @@ export async function POST(req: Request) { // Insert the review const insertResult = (await sql` INSERT INTO reviews (contract_id, reviewer_id, freelancer_id, rating, comment, verified) - VALUES (${contractId}, ${reviewerId}, ${freelancerId}, ${rating}, ${comment || null}, ${verified}) + VALUES (${contractId}, ${auth.userId}, ${freelancerId}, ${rating}, ${comment || null}, ${verified}) RETURNING * `) as any[]; @@ -66,4 +67,4 @@ export async function POST(req: Request) { { status: 500 } ); } -} +}); diff --git a/docs/RBAC_IMPLEMENTATION.md b/docs/RBAC_IMPLEMENTATION.md new file mode 100644 index 0000000..477e4df --- /dev/null +++ b/docs/RBAC_IMPLEMENTATION.md @@ -0,0 +1,311 @@ +# Role-Based Access Control (RBAC) Implementation + +## Overview + +This document describes the Role-Based Access Control (RBAC) system implemented for TaskChain. The RBAC system enforces role-based permissions across protected API routes, ensuring only authorized users can perform sensitive operations. + +## Architecture + +### Components + +1. **Role Definitions** (`lib/auth/constants.ts`) + - Defines user roles: `freelancer`, `client`, `admin` + - Defines granular permissions for each resource type + - Maps roles to their allowed permissions + +2. **RBAC Middleware** (`lib/auth/rbacMiddleware.ts`) + - `withRbac()` - Requires a specific permission + - `withAnyRbac()` - Requires any of the specified permissions + - `withAllRbac()` - Requires all of the specified permissions + - `withRole()` - Restricts access to specific roles + - Helper functions for runtime permission checks + +3. **Updated Admin Middleware** (`lib/auth/adminMiddleware.ts`) + - Refactored to use the new RBAC system + - Maintains backward compatibility + +## User Roles + +### Freelancer +Freelancers can: +- View projects and milestones +- Submit milestones for approval +- View escrow status +- Create and view disputes +- View and create reviews +- View reputation data + +### Client +Clients can: +- Create, update, and delete projects +- View projects and milestones +- Approve or reject milestones +- Fund, release, and refund escrow +- Create and manage contracts +- Create and view disputes +- View and create reviews +- View reputation data + +### Admin +Admins have full system access: +- All client and freelancer permissions +- Resolve disputes +- Manage users +- Freeze contracts +- Full system oversight + +## Permissions + +### Project Permissions +- `project:create` - Create new projects +- `project:view` - View projects +- `project:update` - Update project details +- `project:delete` - Delete projects + +### Milestone Permissions +- `milestone:view` - View milestones +- `milestone:submit` - Submit completed milestones +- `milestone:approve` - Approve submitted milestones +- `milestone:reject` - Reject submitted milestones + +### Escrow Permissions +- `escrow:view` - View escrow status +- `escrow:fund` - Fund escrow contracts +- `escrow:release` - Release funds to freelancer +- `escrow:refund` - Refund funds to client + +### Contract Permissions +- `contract:view` - View contracts +- `contract:create` - Create contracts +- `contract:update` - Update contract details + +### Dispute Permissions +- `dispute:create` - Create disputes +- `dispute:view` - View disputes +- `dispute:resolve` - Resolve disputes (admin only) + +### Admin Permissions +- `admin:users_manage` - Manage users +- `admin:contracts_freeze` - Freeze contracts +- `admin:system_oversight` - Full system oversight + +### General Permissions +- `reputation:view` - View reputation data +- `reviews:create` - Create reviews +- `reviews:view` - View reviews + +## Usage Examples + +### Basic RBAC Middleware + +```typescript +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' + +export const POST = withRbac('project:create', async (request: NextRequest, auth: RbacContext) => { + // Only users with 'project:create' permission can reach here + // auth contains: { userId, role, walletAddress, tokenJti } + return NextResponse.json({ success: true }) +}) +``` + +### Multiple Permissions (Any) + +```typescript +import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware' + +export const POST = withAnyRbac(['milestone:approve', 'milestone:reject'], async (request: NextRequest, auth: RbacContext) => { + // Users with either 'milestone:approve' OR 'milestone:reject' permission can access + return NextResponse.json({ success: true }) +}) +``` + +### Multiple Permissions (All) + +```typescript +import { withAllRbac, RbacContext } from '@/lib/auth/rbacMiddleware' + +export const POST = withAllRbac(['contract:create', 'escrow:fund'], async (request: NextRequest, auth: RbacContext) => { + // Users must have BOTH permissions to access + return NextResponse.json({ success: true }) +}) +``` + +### Role-Based Access + +```typescript +import { withRole, RbacContext } from '@/lib/auth/rbacMiddleware' + +export const POST = withRole(['admin'], async (request: NextRequest, auth: RbacContext) => { + // Only admins can access this route + return NextResponse.json({ success: true }) +}) +``` + +### Runtime Permission Checks + +```typescript +import { checkUserPermission } from '@/lib/auth/rbacMiddleware' + +export const POST = withRbac('project:view', async (request: NextRequest, auth: RbacContext) => { + // Additional runtime check + if (!checkUserPermission(auth, 'project:update')) { + return NextResponse.json({ error: 'Cannot update' }, { status: 403 }) + } + + // Proceed with update + return NextResponse.json({ success: true }) +}) +``` + +## Error Handling + +The RBAC middleware provides clear error responses: + +### 401 Unauthorized +- Missing or invalid authentication token +```json +{ + "error": "Unauthorized", + "code": "AUTH_REQUIRED" +} +``` + +### 403 Forbidden +- User lacks required permission +```json +{ + "error": "Forbidden", + "code": "INSUFFICIENT_PERMISSIONS", + "required": "project:create", + "role": "freelancer" +} +``` + +### 404 Not Found +- User not found in database +```json +{ + "error": "User not found", + "code": "USER_NOT_FOUND" +} +``` + +### 500 Internal Server Error +- Database or system error +```json +{ + "error": "Internal server error", + "code": "INTERNAL_ERROR" +} +``` + +## Protected Routes + +The following routes have been updated with RBAC middleware: + +### Project Routes +- `POST /api/projects/create` - Requires `project:create` + +### Milestone Routes +- `POST /api/milestones/[id]/submit` - Requires `milestone:submit` +- `POST /api/milestones/[id]/approve` - Requires `milestone:approve` OR `milestone:reject` + +### Escrow Routes +- `POST /api/escrow/fund` - Requires `escrow:fund` +- `POST /api/escrow/release` - Requires `escrow:release` +- `POST /api/escrow/refund` - Requires `escrow:refund` OR `admin:contracts_freeze` + +### Dispute Routes +- `POST /api/disputes` - Requires `dispute:create` +- `GET /api/disputes` - Requires `dispute:view` +- `POST /api/disputes/[id]/resolve` - Requires `dispute:resolve` + +### Review Routes +- `POST /api/reviews` - Requires `reviews:create` + +### Admin Routes +- `GET /api/admin/disputes` - Requires admin role (uses `withAdmin`) +- `POST /api/admin/contracts/[id]/freeze` - Requires admin role (uses `withAdmin`) + +## Database Schema + +The RBAC system relies on the existing `users` table schema: + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY, + role user_role NOT NULL DEFAULT 'freelancer', + wallet_address TEXT UNIQUE, + -- ... other fields +) +``` + +The `user_role` enum is defined as: +```sql +CREATE TYPE user_role AS ENUM ('freelancer', 'client', 'admin'); +``` + +## Security Considerations + +1. **Token Validation**: JWT tokens are validated before role checks +2. **Database Lookup**: User roles are fetched from the database on each request +3. **Permission Granularity**: Permissions are granular to follow the principle of least privilege +4. **Error Messages**: Error messages are generic to prevent information leakage +5. **Audit Logging**: Admin actions are logged in `admin_audit_logs` table + +## Scalability + +The RBAC system is designed for future expansion: + +### Adding New Roles +1. Add the role to the `user_role` enum in the database +2. Add the role to the `UserRole` type in `lib/auth/constants.ts` +3. Define permissions for the new role in `ROLE_PERMISSIONS` + +### Adding New Permissions +1. Add the permission to the `Permission` type in `lib/auth/constants.ts` +2. Add the permission to the appropriate roles in `ROLE_PERMISSIONS` +3. Apply the middleware to relevant routes + +### Adding New Middleware +The middleware functions are composable and can be extended for custom authorization logic. + +## Testing + +To test the RBAC implementation: + +1. Create test users with different roles +2. Obtain JWT tokens for each user +3. Attempt to access protected routes with each role +4. Verify that: + - Users with correct permissions can access routes + - Users without permissions receive 403 errors + - Unauthenticated requests receive 401 errors + +## Migration Guide + +For existing routes using `withAuth`: + +### Before +```typescript +import { withAuth, AuthContext } from '@/lib/auth/middleware' + +export const POST = withAuth(async (request: NextRequest, auth: AuthContext) => { + const [user] = await sql`SELECT id, role FROM users WHERE wallet_address = ${auth.walletAddress}` + // Manual role checks... +}) +``` + +### After +```typescript +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' + +export const POST = withRbac('permission:name', async (request: NextRequest, auth: RbacContext) => { + // auth.userId and auth.role are already available + // No manual role checks needed +}) +``` + +## Backward Compatibility + +The existing `withAuth` middleware remains unchanged and can still be used for routes that don't require role-based permissions. The `withAdmin` middleware has been refactored to use the new RBAC system but maintains its original API for backward compatibility. diff --git a/lib/auth/adminMiddleware.ts b/lib/auth/adminMiddleware.ts index c3ebf98..e58b04d 100644 --- a/lib/auth/adminMiddleware.ts +++ b/lib/auth/adminMiddleware.ts @@ -1,9 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import { withAuth, AuthContext } from './middleware' -import { sql } from '@/lib/db' +import { withRole, RbacContext } from './rbacMiddleware' -export interface AdminContext extends AuthContext { - userId: string +export interface AdminContext extends RbacContext { role: 'admin' } @@ -12,37 +10,19 @@ type AdminHandler = ( auth: AdminContext ) => Promise | NextResponse +/** + * Middleware that restricts access to admin users only. + * This is a convenience wrapper around withRole(['admin']). + * + * @deprecated Use withRole(['admin']) or withRbac with specific admin permissions instead. + * This is maintained for backward compatibility. + */ export function withAdmin(handler: AdminHandler) { - return withAuth(async (request: NextRequest, auth: AuthContext): Promise => { - // Fetch user from database by walletAddress to get role and id - const result = await sql` - SELECT id, role - FROM users - WHERE wallet_address = ${auth.walletAddress} - ` - - if (result.length === 0) { - return NextResponse.json( - { error: 'User not found', code: 'USER_NOT_FOUND' }, - { status: 404 } - ) - } - - const { id, role } = result[0] - - // Check if the user has admin role - if (role !== 'admin') { - return NextResponse.json( - { error: 'Forbidden', code: 'INSUFFICIENT_PERMISSIONS' }, - { status: 403 } - ) - } - - // Extend auth context with userId and role + return withRole(['admin'], async (request: NextRequest, auth: RbacContext): Promise => { + // Extend auth context with admin-specific type const adminAuth: AdminContext = { ...auth, - userId: id, - role: 'admin' + role: 'admin' as const } return handler(request, adminAuth) diff --git a/lib/auth/constants.ts b/lib/auth/constants.ts index 55be11e..87dc34c 100644 --- a/lib/auth/constants.ts +++ b/lib/auth/constants.ts @@ -4,3 +4,119 @@ export const REFRESH_TOKEN_COOKIE = 'tc_refresh_token' export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60 export const REFRESH_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60 export const NONCE_TTL_SECONDS = 5 * 60 + +// RBAC: User Roles +export type UserRole = 'freelancer' | 'client' | 'admin' + +// RBAC: Permissions +export type Permission = + // Project permissions + | 'project:create' + | 'project:view' + | 'project:update' + | 'project:delete' + // Milestone permissions + | 'milestone:view' + | 'milestone:submit' + | 'milestone:approve' + | 'milestone:reject' + // Escrow permissions + | 'escrow:view' + | 'escrow:fund' + | 'escrow:release' + | 'escrow:refund' + // Contract permissions + | 'contract:view' + | 'contract:create' + | 'contract:update' + // Dispute permissions + | 'dispute:create' + | 'dispute:view' + | 'dispute:resolve' + // Admin permissions + | 'admin:users_manage' + | 'admin:contracts_freeze' + | 'admin:system_oversight' + // General permissions + | 'reputation:view' + | 'reviews:create' + | 'reviews:view' + +// Role-based permission mapping +export const ROLE_PERMISSIONS: Record = { + freelancer: [ + 'project:view', + 'milestone:view', + 'milestone:submit', + 'escrow:view', + 'contract:view', + 'dispute:create', + 'dispute:view', + 'reputation:view', + 'reviews:create', + 'reviews:view', + ], + client: [ + 'project:create', + 'project:view', + 'project:update', + 'project:delete', + 'milestone:view', + 'milestone:approve', + 'milestone:reject', + 'escrow:view', + 'escrow:fund', + 'escrow:release', + 'escrow:refund', + 'contract:view', + 'contract:create', + 'contract:update', + 'dispute:create', + 'dispute:view', + 'reputation:view', + 'reviews:create', + 'reviews:view', + ], + admin: [ + // Admin has all permissions + 'project:create', + 'project:view', + 'project:update', + 'project:delete', + 'milestone:view', + 'milestone:submit', + 'milestone:approve', + 'milestone:reject', + 'escrow:view', + 'escrow:fund', + 'escrow:release', + 'escrow:refund', + 'contract:view', + 'contract:create', + 'contract:update', + 'dispute:create', + 'dispute:view', + 'dispute:resolve', + 'admin:users_manage', + 'admin:contracts_freeze', + 'admin:system_oversight', + 'reputation:view', + 'reviews:create', + 'reviews:view', + ], +} + +// Helper function to check if a role has a specific permission +export function hasPermission(role: UserRole, permission: Permission): boolean { + return ROLE_PERMISSIONS[role].includes(permission) +} + +// Helper function to check if a role has any of the specified permissions +export function hasAnyPermission(role: UserRole, permissions: Permission[]): boolean { + return permissions.some(permission => hasPermission(role, permission)) +} + +// Helper function to check if a role has all of the specified permissions +export function hasAllPermissions(role: UserRole, permissions: Permission[]): boolean { + return permissions.every(permission => hasPermission(role, permission)) +} diff --git a/lib/auth/rbacMiddleware.ts b/lib/auth/rbacMiddleware.ts new file mode 100644 index 0000000..3eab0f8 --- /dev/null +++ b/lib/auth/rbacMiddleware.ts @@ -0,0 +1,336 @@ +import { NextRequest, NextResponse } from 'next/server' +import { withAuth, AuthContext } from './middleware' +import { sql } from '@/lib/db' +import { UserRole, Permission, hasPermission } from './constants' + +export interface RbacContext extends AuthContext { + userId: string + role: UserRole +} + +type RbacHandler = ( + request: NextRequest, + auth: RbacContext +) => Promise | NextResponse + +/** + * Middleware that enforces role-based access control. + * Fetches the user's role from the database and checks if they have the required permission. + * + * @param requiredPermission - The permission required to access the route + * @param handler - The route handler to execute if authorization succeeds + * + * @example + * export const POST = withRbac('project:create', async (request, auth) => { + * // Only users with 'project:create' permission can reach here + * return NextResponse.json({ success: true }) + * }) + */ +export function withRbac(requiredPermission: Permission, handler: RbacHandler) { + return withAuth(async (request: NextRequest, auth: AuthContext): Promise => { + try { + // Fetch user from database by walletAddress to get role and id + const result = await sql` + SELECT id, role + FROM users + WHERE wallet_address = ${auth.walletAddress} + ` + + if (result.length === 0) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 } + ) + } + + const { id, role } = result[0] + + // Validate that the role is a valid UserRole + if (!isValidRole(role)) { + return NextResponse.json( + { error: 'Invalid user role', code: 'INVALID_ROLE' }, + { status: 500 } + ) + } + + // Check if the user has the required permission + if (!hasPermission(role as UserRole, requiredPermission)) { + return NextResponse.json( + { + error: 'Forbidden', + code: 'INSUFFICIENT_PERMISSIONS', + required: requiredPermission, + role: role + }, + { status: 403 } + ) + } + + // Extend auth context with userId and role + const rbacAuth: RbacContext = { + ...auth, + userId: id, + role: role as UserRole + } + + return handler(request, rbacAuth) + } catch (error) { + console.error('RBAC middleware error:', error) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } + }) +} + +/** + * Middleware that requires any of the specified permissions. + * Access is granted if the user has at least one of the required permissions. + * + * @param requiredPermissions - Array of permissions (any one will grant access) + * @param handler - The route handler to execute if authorization succeeds + */ +export function withAnyRbac(requiredPermissions: Permission[], handler: RbacHandler) { + return withAuth(async (request: NextRequest, auth: AuthContext): Promise => { + try { + // Fetch user from database by walletAddress to get role and id + const result = await sql` + SELECT id, role + FROM users + WHERE wallet_address = ${auth.walletAddress} + ` + + if (result.length === 0) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 } + ) + } + + const { id, role } = result[0] + + // Validate that the role is a valid UserRole + if (!isValidRole(role)) { + return NextResponse.json( + { error: 'Invalid user role', code: 'INVALID_ROLE' }, + { status: 500 } + ) + } + + // Check if the user has any of the required permissions + const hasAny = requiredPermissions.some(permission => + hasPermission(role as UserRole, permission) + ) + + if (!hasAny) { + return NextResponse.json( + { + error: 'Forbidden', + code: 'INSUFFICIENT_PERMISSIONS', + required: requiredPermissions, + role: role + }, + { status: 403 } + ) + } + + // Extend auth context with userId and role + const rbacAuth: RbacContext = { + ...auth, + userId: id, + role: role as UserRole + } + + return handler(request, rbacAuth) + } catch (error) { + console.error('RBAC middleware error:', error) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } + }) +} + +/** + * Middleware that requires all of the specified permissions. + * Access is granted only if the user has all of the required permissions. + * + * @param requiredPermissions - Array of permissions (all must be present) + * @param handler - The route handler to execute if authorization succeeds + */ +export function withAllRbac(requiredPermissions: Permission[], handler: RbacHandler) { + return withAuth(async (request: NextRequest, auth: AuthContext): Promise => { + try { + // Fetch user from database by walletAddress to get role and id + const result = await sql` + SELECT id, role + FROM users + WHERE wallet_address = ${auth.walletAddress} + ` + + if (result.length === 0) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 } + ) + } + + const { id, role } = result[0] + + // Validate that the role is a valid UserRole + if (!isValidRole(role)) { + return NextResponse.json( + { error: 'Invalid user role', code: 'INVALID_ROLE' }, + { status: 500 } + ) + } + + // Check if the user has all of the required permissions + const hasAll = requiredPermissions.every(permission => + hasPermission(role as UserRole, permission) + ) + + if (!hasAll) { + return NextResponse.json( + { + error: 'Forbidden', + code: 'INSUFFICIENT_PERMISSIONS', + required: requiredPermissions, + role: role + }, + { status: 403 } + ) + } + + // Extend auth context with userId and role + const rbacAuth: RbacContext = { + ...auth, + userId: id, + role: role as UserRole + } + + return handler(request, rbacAuth) + } catch (error) { + console.error('RBAC middleware error:', error) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } + }) +} + +/** + * Middleware that restricts access to specific roles only. + * + * @param allowedRoles - Array of roles that are allowed to access the route + * @param handler - The route handler to execute if authorization succeeds + * + * @example + * export const POST = withRole(['admin'], async (request, auth) => { + * // Only admins can reach here + * return NextResponse.json({ success: true }) + * }) + */ +export function withRole(allowedRoles: UserRole[], handler: RbacHandler) { + return withAuth(async (request: NextRequest, auth: AuthContext): Promise => { + try { + // Fetch user from database by walletAddress to get role and id + const result = await sql` + SELECT id, role + FROM users + WHERE wallet_address = ${auth.walletAddress} + ` + + if (result.length === 0) { + return NextResponse.json( + { error: 'User not found', code: 'USER_NOT_FOUND' }, + { status: 404 } + ) + } + + const { id, role } = result[0] + + // Validate that the role is a valid UserRole + if (!isValidRole(role)) { + return NextResponse.json( + { error: 'Invalid user role', code: 'INVALID_ROLE' }, + { status: 500 } + ) + } + + // Check if the user's role is in the allowed roles + if (!allowedRoles.includes(role as UserRole)) { + return NextResponse.json( + { + error: 'Forbidden', + code: 'INSUFFICIENT_PERMISSIONS', + allowedRoles: allowedRoles, + role: role + }, + { status: 403 } + ) + } + + // Extend auth context with userId and role + const rbacAuth: RbacContext = { + ...auth, + userId: id, + role: role as UserRole + } + + return handler(request, rbacAuth) + } catch (error) { + console.error('RBAC middleware error:', error) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } + }) +} + +/** + * Helper function to validate that a role string is a valid UserRole + */ +function isValidRole(role: string): role is UserRole { + return ['freelancer', 'client', 'admin'].includes(role) +} + +/** + * Helper function to check if a user has a specific permission. + * This can be used within route handlers for additional checks. + * + * @param auth - The RbacContext from the middleware + * @param permission - The permission to check + * @returns true if the user has the permission, false otherwise + */ +export function checkUserPermission(auth: RbacContext, permission: Permission): boolean { + return hasPermission(auth.role, permission) +} + +/** + * Helper function to check if a user has any of the specified permissions. + * This can be used within route handlers for additional checks. + * + * @param auth - The RbacContext from the middleware + * @param permissions - Array of permissions to check + * @returns true if the user has any of the permissions, false otherwise + */ +export function checkUserAnyPermission(auth: RbacContext, permissions: Permission[]): boolean { + return permissions.some(permission => hasPermission(auth.role, permission)) +} + +/** + * Helper function to check if a user has all of the specified permissions. + * This can be used within route handlers for additional checks. + * + * @param auth - The RbacContext from the middleware + * @param permissions - Array of permissions to check + * @returns true if the user has all of the permissions, false otherwise + */ +export function checkUserAllPermissions(auth: RbacContext, permissions: Permission[]): boolean { + return permissions.every(permission => hasPermission(auth.role, permission)) +}