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 @@ -60,6 +60,7 @@ export interface BranchProjects {
}

export interface BranchUsers {
cognito_sub: string | null;
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) {
return json(401, { message: 'Authentication required' });
}

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

Expand Down Expand Up @@ -106,4 +112,4 @@ function json(statusCode: number, body: unknown): APIGatewayProxyResult {
},
body: JSON.stringify(body)
};
}
}
89 changes: 42 additions & 47 deletions apps/backend/lambdas/projects/test/projects.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<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',
}),
});
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 @@ -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);
});
});
16 changes: 15 additions & 1 deletion 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 All @@ -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,
Expand Down Expand Up @@ -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);
});
});
Loading