diff --git a/apps/backend/lambdas/projects/auth.ts b/apps/backend/lambdas/projects/auth.ts new file mode 100644 index 0000000..72500aa --- /dev/null +++ b/apps/backend/lambdas/projects/auth.ts @@ -0,0 +1,139 @@ +import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import db from './db'; + +const COGNITO_USER_POOL_ID = process.env.COGNITO_USER_POOL_ID || ''; +const COGNITO_CLIENT_ID = process.env.COGNITO_CLIENT_ID || ''; + +let verifier: any = null; +function getVerifier() { + if (!verifier) { + if (!COGNITO_USER_POOL_ID) { + throw new Error('COGNITO_USER_POOL_ID environment variable is not set'); + } + verifier = CognitoJwtVerifier.create({ + userPoolId: COGNITO_USER_POOL_ID, + tokenUse: 'access', + clientId: COGNITO_CLIENT_ID, + }); + } + return verifier; +} + +export interface AuthenticatedUser { + cognitoSub: string; + userId?: number; + email?: string; + isAdmin: boolean; + cognitoGroups?: string[]; +} + +export interface AuthContext { + user?: AuthenticatedUser; + isAuthenticated: boolean; +} + +export interface AuthorizationResult { + allowed: boolean; + reason?: string; +} + +function extractToken(event: any): string | null { + const authHeader = event.headers?.Authorization || event.headers?.authorization; + if (!authHeader) return null; + const parts = authHeader.split(' '); + if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') { + return parts[1]; + } + return authHeader; +} + +export async function authenticateRequest(event: any): Promise { + const token = extractToken(event); + if (!token) return { isAuthenticated: false }; + + try { + const payload = await getVerifier().verify(token); + const dbUser = await db + .selectFrom('branch.users') + .where('cognito_sub', '=', payload.sub) + .selectAll() + .executeTakeFirst(); + + if (!dbUser) { + console.warn('User authenticated with Cognito but not found in database:', payload.sub); + return { isAuthenticated: false }; + } + + const user: AuthenticatedUser = { + cognitoSub: payload.sub, + userId: dbUser.user_id, + email: payload.email as string | undefined, + isAdmin: dbUser.is_admin === true, + cognitoGroups: payload['cognito:groups'] as string[] | undefined, + }; + + if (user.cognitoGroups?.includes('Admins')) { + user.isAdmin = true; + } + + return { user, isAuthenticated: true }; + } catch (error) { + console.error('Token verification failed:', error); + return { isAuthenticated: false }; + } +} + +/** + * Authorization levels: + * AUTHENTICATED — any logged-in user + * PROJECT_MEMBER — admin, or any role on the project + * ADMIN_OR_PI — admin, or PI role on the project + * ADMIN — global admin only + */ +export async function checkAuthorization( + authContext: AuthContext, + level: 'AUTHENTICATED' | 'PROJECT_MEMBER' | 'ADMIN_OR_PI' | 'ADMIN', + projectId?: string +): Promise { + if (!authContext.isAuthenticated || !authContext.user) { + return { allowed: false, reason: 'Authentication required' }; + } + + const { user } = authContext; + + if (level === 'AUTHENTICATED') { + return { allowed: true }; + } + + if (level === 'ADMIN') { + return user.isAdmin + ? { allowed: true } + : { allowed: false, reason: 'Admin access required' }; + } + + // PROJECT_MEMBER and ADMIN_OR_PI both need a projectId + if (!projectId) { + return { allowed: false, reason: 'Project ID required for authorization' }; + } + + if (user.isAdmin) return { allowed: true }; + + const membership = await db + .selectFrom('branch.project_memberships') + .where('project_id', '=', Number(projectId)) + .where('user_id', '=', user.userId!) + .select('role') + .executeTakeFirst(); + + if (level === 'PROJECT_MEMBER') { + return membership + ? { allowed: true } + : { allowed: false, reason: 'Project membership required' }; + } + + // ADMIN_OR_PI + if (membership && ['PI', 'Admin'].includes(membership.role)) { + return { allowed: true }; + } + return { allowed: false, reason: 'PI or Admin role required' }; +} \ No newline at end of file diff --git a/apps/backend/lambdas/projects/db-types.d.ts b/apps/backend/lambdas/projects/db-types.d.ts index b2bc948..3d190d8 100644 --- a/apps/backend/lambdas/projects/db-types.d.ts +++ b/apps/backend/lambdas/projects/db-types.d.ts @@ -60,6 +60,7 @@ export interface BranchProjects { } export interface BranchUsers { + cognito_sub: string | null; created_at: Generated; email: string; is_admin: Generated; diff --git a/apps/backend/lambdas/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index 49ccc05..7f39352 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -1,6 +1,7 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import db from './db'; import { ProjectValidationUtils } from './validation-utils'; +import { authenticateRequest } from './auth'; export const handler = async (event: any): Promise => { @@ -17,6 +18,11 @@ export const handler = async (event: any): Promise => { return json(200, { ok: true, timestamp: new Date().toISOString() }); } + const authContext = await authenticateRequest(event); + if (!authContext.isAuthenticated || !authContext.user) { + return json(401, { message: 'Authentication required' }); + } + // >>> ROUTES-START (do not remove this marker) // CLI-generated routes will be inserted here @@ -106,4 +112,4 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult { }, body: JSON.stringify(body) }; -} +} \ No newline at end of file diff --git a/apps/backend/lambdas/projects/test/projects.e2e.test.ts b/apps/backend/lambdas/projects/test/projects.e2e.test.ts index cacc763..5b9e9b7 100644 --- a/apps/backend/lambdas/projects/test/projects.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/projects.e2e.test.ts @@ -1,45 +1,52 @@ -// E2E tests require the dev server running at http://localhost:3000/projects +jest.mock('../auth'); +import { handler } from '../handler'; +import { authenticateRequest } from '../auth'; +const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; -const base = 'http://localhost:3000/projects'; +const adminUser = { + isAuthenticated: true as const, + user: { cognitoSub: 'admin-sub', userId: 1, email: 'ashley@branch.org', isAdmin: true }, +}; + +function postEvent(body: Record) { + return { + rawPath: '/projects', + requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, + body: JSON.stringify(body), + }; +} + +beforeEach(() => { + mockAuthenticateRequest.mockResolvedValue(adminUser); +}); describe('POST /projects (e2e)', () => { test('201 creates project with number budget', async () => { - const res = await fetch(`${base}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Proj A', total_budget: 1000 }), - }); - expect(res.status).toBe(201); - const json = await res.json(); + const res = await handler(postEvent({ name: 'Proj A', total_budget: 1000 })); + expect(res.statusCode).toBe(201); + const json = JSON.parse(res.body); expect(json.name).toBe('Proj A'); expect(json.project_id).toBeDefined(); }); test('201 creates project with numeric string budget', async () => { - const res = await fetch(`${base}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Proj B', total_budget: '2500.50' }), - }); - expect(res.status).toBe(201); - const json = await res.json(); + const res = await handler(postEvent({ name: 'Proj B', total_budget: '2500.50' })); + expect(res.statusCode).toBe(201); + const json = JSON.parse(res.body); expect(json.name).toBe('Proj B'); }); test('201: creates project with all fields (e2e)', async () => { - const res = await fetch('http://localhost:3000/projects', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: 'AllFieldsE2E', - total_budget: '2500.50', - start_date: '2025-03-01', - end_date: '2025-09-30', - currency: 'EUR', - }), - }); - expect(res.status).toBe(201); - const json = await res.json(); + const res = await handler(postEvent({ + name: 'AllFieldsE2E', + total_budget: '2500.50', + start_date: '2025-03-01', + end_date: '2025-09-30', + currency: 'EUR', + })); + expect(res.statusCode).toBe(201); + const json = JSON.parse(res.body); expect(json.name).toBe('AllFieldsE2E'); expect(json.total_budget).toBeDefined(); expect(json.start_date).toContain('2025-03-01'); @@ -48,29 +55,17 @@ describe('POST /projects (e2e)', () => { }); test('400 when name missing', async () => { - const res = await fetch(`${base}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ total_budget: 10 }), - }); - expect(res.status).toBe(400); + const res = await handler(postEvent({ total_budget: 10 })); + expect(res.statusCode).toBe(400); }); test('400 when total_budget invalid', async () => { - const res = await fetch(`${base}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'X', total_budget: 'abc' }), - }); - expect(res.status).toBe(400); + const res = await handler(postEvent({ name: 'X', total_budget: 'abc' })); + expect(res.statusCode).toBe(400); }); test('201 with only required name (optional omitted)', async () => { - const res = await fetch(`${base}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Minimal' }), - }); - expect(res.status).toBe(201); + const res = await handler(postEvent({ name: 'Minimal' })); + expect(res.statusCode).toBe(201); }); }); \ No newline at end of file diff --git a/apps/backend/lambdas/projects/test/projects.unit.test.ts b/apps/backend/lambdas/projects/test/projects.unit.test.ts index 04ac428..35d0d3b 100644 --- a/apps/backend/lambdas/projects/test/projects.unit.test.ts +++ b/apps/backend/lambdas/projects/test/projects.unit.test.ts @@ -1,9 +1,18 @@ +jest.mock('../auth'); import { handler } from '../handler'; +import { authenticateRequest } from '../auth'; +const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction; + +const adminUser = { + isAuthenticated: true as const, + user: { cognitoSub: 'admin-sub', userId: 1, email: 'ashley@branch.org', isAdmin: true }, +}; function event(body: unknown) { return { rawPath: '/projects', requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, body: JSON.stringify(body), } as any; } @@ -16,6 +25,10 @@ beforeAll(() => { process.env.DB_NAME = process.env.DB_NAME ?? 'branch_db'; }); +beforeEach(() => { + mockAuthenticateRequest.mockResolvedValue(adminUser); +}); + test('201: creates project with number budget', async () => { const res = await handler(event({ name: 'Proj Number', total_budget: 1000 })); expect(res.statusCode).toBe(201); @@ -41,6 +54,7 @@ test('201: creates project with all fields', async () => { const res = await handler({ rawPath: '/', requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, body: JSON.stringify({ name: 'AllFieldsUnit', total_budget: 12345.67, @@ -85,4 +99,4 @@ test('400: currency empty or too long', async () => { const tooLong = await handler(event({ name: 'X', currency: 'ABCDEFGHIJK' })); // 11 chars expect(tooLong.statusCode).toBe(400); -}); +}); \ No newline at end of file