Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions apps/backend/lambdas/projects/auth.ts
Original file line number Diff line number Diff line change
@@ -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<AuthContext> {
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<AuthorizationResult> {
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' };
}
1 change: 1 addition & 0 deletions apps/backend/lambdas/projects/db-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface BranchProjects {
}

export interface BranchUsers {
cognito_sub: string | null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait omg never mind idk why i thought it was sql. ignore this comment

created_at: Generated<Timestamp | null>;
email: string;
is_admin: Generated<boolean | null>;
Expand Down
8 changes: 7 additions & 1 deletion apps/backend/lambdas/projects/handler.ts
Original file line number Diff line number Diff line change
@@ -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<APIGatewayProxyResult> => {
Expand All @@ -17,6 +18,11 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(200, { ok: true, timestamp: new Date().toISOString() });
}

const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated || !authContext.user) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the right idea, but we actually want to do this in a more fine grained way.
For PUT project, user should be admin (only admin can create a project)
For the GET, the user should just be anyone authenticated

Also, looks like CI is failing because aws-jwt-verify is not installed, should just be able to npm install this

return json(401, { message: 'Authentication required' });
}

// >>> ROUTES-START (do not remove this marker)
// CLI-generated routes will be inserted here

Expand Down Expand Up @@ -110,4 +116,4 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult {
},
body: JSON.stringify(body)
};
}
}
127 changes: 42 additions & 85 deletions apps/backend/lambdas/projects/test/projects.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,46 +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<typeof authenticateRequest>;

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<string, unknown>) {
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',
description: 'End-to-end project description',
}),
});
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');
Expand All @@ -50,66 +56,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 json = await res.json();
expect(json.description).toBe(''); // description defaults to empty string
});

test('201: creates project with empty string description', async () => {
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'EmptyDesc', description: '' }),
});
expect(res.status).toBe(201);
const json = await res.json();
expect(json.description).toBe('');
});

test('400: description exceeds 1000 characters', async () => {
const longDesc = 'a'.repeat(1001);
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'LongDesc', description: longDesc }),
});
expect(res.status).toBe(400);
const json = await res.json();
expect(json.message).toContain('1000');
});

test('201: creates project with exactly 1000 character description', async () => {
const desc1000 = 'a'.repeat(1000);
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'MaxDesc', description: desc1000 }),
});
expect(res.status).toBe(201);
const json = await res.json();
expect(json.description).toBe(desc1000);
const res = await handler(postEvent({ name: 'Minimal' }));
expect(res.statusCode).toBe(201);
});
});
14 changes: 14 additions & 0 deletions apps/backend/lambdas/projects/test/projects.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
jest.mock('../auth');
import { handler } from '../handler';
import { authenticateRequest } from '../auth';
const mockAuthenticateRequest = authenticateRequest as jest.MockedFunction<typeof authenticateRequest>;

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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -57,6 +70,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,
Expand Down
Loading