diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index b2a374e0..e6a13cc0 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -32,13 +32,13 @@ jobs: - name: Poetry audit id: poetry-audit run: |- - pip install poetry-audit-plugin - poetry audit > audit.txt + pip install "safety==3.7.0" poetry-audit-plugin + poetry audit > audit.txt 2>&1 || true cat audit.txt continue-on-error: true - name: Comment with audit result uses: marocchino/sticky-pull-request-comment@v2 - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && hashFiles('audit.txt') != '' with: recreate: true path: audit.txt diff --git a/scripts/invite-google-apps-scripts.js b/scripts/invite-google-apps-scripts.js new file mode 100644 index 00000000..bf7556a5 --- /dev/null +++ b/scripts/invite-google-apps-scripts.js @@ -0,0 +1,84 @@ +const BACKEND_URL = 'VIRTUAL_LAB_API_URL'; + +function onWebinarFormSubmit(e) { + try { + if (!e || !e.response) { + console.error('No form response in event'); + return; + } + const props = PropertiesService.getScriptProperties(); + const secret = props.getProperty('x_webhook_signature'); + const virtualLabId = props.getProperty('x_virtual_lab_id'); + const projectId = props.getProperty('x_project_id'); + const userId = props.getProperty('x_user_id'); + + if(!secret){ + console.error('Missing Script Properties: WEBHOOK_SECRET'); + return; + } + if(!virtualLabId){ + console.error('Missing Script Properties: VIRTUAL_LAB_ID'); + return; + } + if(!projectId){ + console.error('Missing Script Properties: PROJECT_ID'); + return; + } + if(!userId){ + console.error('Missing Script Properties: USER_ID'); + return; + } + + const itemResponses = e.response.getItemResponses(); + const answers = {}; + itemResponses.forEach(ir => { + answers[ir.getItem().getTitle()] = ir.getResponse(); + }); + + const fullName = (answers['Name'] || '').toString().trim(); + const email = (answers['Email'] || '').toString().trim(); + + if (!email) { + console.error('No email in submission'); + return; + } + + + const payload = { + name: fullName, + email: email, + source: 'webinar_form', + submittedAt: new Date().toISOString(), + responseId: e.response ? e.response.getId() : Utilities.getUuid(), + }; + + const body = JSON.stringify(payload); + + // HMAC-SHA256 over the raw body — IDs are inside, so they're signed too. + const signature = Utilities.computeHmacSha256Signature(body, secret) + .map(b => ('0' + (b & 0xff).toString(16)).slice(-2)) + .join(''); + + const response = UrlFetchApp.fetch(`${BACKEND_URL}/invites/webhook`, { + method: 'post', + contentType: 'application/json', + headers: { + 'x-webhook-signature': signature, + 'x-virtual-lab-id': virtualLabId, + 'x-project-id': projectId, + 'x-user-id': userId, + }, + payload: body, + muteHttpExceptions: true, + }); + + const code = response.getResponseCode(); + if (code < 200 || code >= 300) { + console.error('Backend error', code, response.getContentText()); + } else { + console.log('Invite sent for', email); + } + } catch (err) { + console.error('Submission handler failed:', err); + } +} diff --git a/virtual_labs/domain/invite.py b/virtual_labs/domain/invite.py index e6f40e1d..19f5294f 100644 --- a/virtual_labs/domain/invite.py +++ b/virtual_labs/domain/invite.py @@ -27,3 +27,8 @@ class InviteOut(BaseModel): virtual_lab_id: UUID4 origin: InviteOrigin accepted: Literal["accepted", "already_accepted"] | None + + +class WebhookPayload(BaseModel): + name: str + email: EmailStr diff --git a/virtual_labs/infrastructure/settings.py b/virtual_labs/infrastructure/settings.py index 0fb0c347..8f345955 100644 --- a/virtual_labs/infrastructure/settings.py +++ b/virtual_labs/infrastructure/settings.py @@ -75,6 +75,7 @@ class Settings(BaseSettings): VALIDATE_CERTS: bool = False INVITE_JWT_SECRET: str = "TEST_JWT_SECRET" + INVITE_WEBHOOK_SECRET: str = "" INVITE_EXPIRES_IN_DAYS: int = 7 INVITE_LINK_BASE: str = "http://localhost:3000" diff --git a/virtual_labs/routes/invites.py b/virtual_labs/routes/invites.py index fd957b05..f3fa3ac4 100644 --- a/virtual_labs/routes/invites.py +++ b/virtual_labs/routes/invites.py @@ -1,12 +1,17 @@ -from typing import Tuple +from typing import Annotated, Tuple -from fastapi import APIRouter, Depends, Query -from fastapi.responses import Response +from fastapi import APIRouter, Depends, Header, Query, Request +from fastapi.responses import JSONResponse, Response +from pydantic import UUID4 from sqlalchemy.ext.asyncio import AsyncSession from virtual_labs.core.exceptions.api_error import VliError from virtual_labs.core.types import VliAppResponse -from virtual_labs.domain.invite import InvitationResponse, InviteOut +from virtual_labs.domain.invite import ( + InvitationResponse, + InviteOut, + WebhookPayload, +) from virtual_labs.infrastructure.db.config import default_session_factory from virtual_labs.infrastructure.kc.auth import verify_jwt from virtual_labs.infrastructure.kc.models import AuthUser @@ -52,3 +57,33 @@ async def handle_invite( invite_token=token, auth=auth, ) + + +@router.post( + "/webhook", + operation_id="invites_webhook_handler", + summary="Handle incoming webhook to invite a user to a project", + description="This is tied to the google form invite setup", +) +async def webhook_handler( + request: Request, + payload: WebhookPayload, + x_webhook_signature: Annotated[str, Header()], + x_virtual_lab_id: Annotated[UUID4, Header()], + x_project_id: Annotated[UUID4, Header()], + x_user_id: Annotated[UUID4, Header()], + session: AsyncSession = Depends(default_session_factory), +) -> JSONResponse: + body = await request.body() + + result = await invite_cases.handle_invite_webhook( + session, + signature=x_webhook_signature, + body=body, + virtual_lab_id=str(x_virtual_lab_id), + project_id=str(x_project_id), + inviter_id=str(x_user_id), + invitee_email=payload.email, + invitee_name=payload.name, + ) + return JSONResponse(status_code=200, content=result) diff --git a/virtual_labs/tests/test_invite_webhook.py b/virtual_labs/tests/test_invite_webhook.py new file mode 100644 index 00000000..502923f5 --- /dev/null +++ b/virtual_labs/tests/test_invite_webhook.py @@ -0,0 +1,247 @@ +import json +from typing import AsyncGenerator + +import pytest +import pytest_asyncio +from httpx import AsyncClient + +from virtual_labs.infrastructure.settings import settings +from virtual_labs.tests.utils import ( + cleanup_resources, + create_mock_lab_with_project, + get_headers, + get_user_id_from_test_auth, +) +from virtual_labs.usecases.invites.webhook_handler import compute_webhook_signature + +WEBHOOK_SECRET = "test_webhook_secret_hex_value" + + +def sign(body: bytes) -> str: + """Compute a valid signature for the test webhook secret.""" + return compute_webhook_signature(body, WEBHOOK_SECRET) + + +@pytest_asyncio.fixture(autouse=True) +async def set_webhook_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "INVITE_WEBHOOK_SECRET", WEBHOOK_SECRET) + + +@pytest_asyncio.fixture +async def lab_and_project( + async_test_client: AsyncClient, +) -> AsyncGenerator[tuple[str, str, str], None]: + """Create a virtual lab with a project for testing. Returns (lab_id, project_id, user_id).""" + headers = get_headers("test") + user_id = await get_user_id_from_test_auth( + auth_header=headers.get("Authorization", "") + ) + + lab, project_id = await create_mock_lab_with_project(async_test_client) + lab_id = lab["id"] + + yield lab_id, project_id, str(user_id) + + await cleanup_resources(client=async_test_client, lab_id=lab_id) + + +@pytest.mark.asyncio +async def test_webhook_successful_invite( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "Invite sent successfully" + assert data["invitee_email"] == "invitee@example.com" + assert data["invitee_name"] == "Test Invitee" + + +@pytest.mark.asyncio +async def test_webhook_invalid_signature( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": "invalid_signature", + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_webhook_missing_signature_header( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_webhook_missing_virtual_lab_id_header( + async_test_client: AsyncClient, +) -> None: + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Project-Id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "X-User-Id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e", + }, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_webhook_invalid_uuid_in_header( + async_test_client: AsyncClient, +) -> None: + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": "not-a-uuid", + "X-Project-Id": "also-not-a-uuid", + "X-User-Id": "not-a-uuid-either", + }, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_webhook_invalid_email_in_body( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "not-an-email"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_webhook_project_not_found( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, _, user_id = lab_and_project + + payload = {"name": "Test Invitee", "email": "invitee@example.com"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_webhook_missing_body_fields( + async_test_client: AsyncClient, + lab_and_project: tuple[str, str, str], +) -> None: + lab_id, project_id, user_id = lab_and_project + + payload = {"name": "Test Invitee"} + body = json.dumps(payload).encode("utf-8") + signature = sign(body) + + response = await async_test_client.post( + "/invites/webhook", + content=body, + headers={ + "Content-Type": "application/json", + "X-Webhook-Signature": signature, + "X-Virtual-Lab-Id": lab_id, + "X-Project-Id": project_id, + "X-User-Id": user_id, + }, + ) + + assert response.status_code == 422 diff --git a/virtual_labs/usecases/invites/__init__.py b/virtual_labs/usecases/invites/__init__.py index 26f733c6..7d621fbd 100644 --- a/virtual_labs/usecases/invites/__init__.py +++ b/virtual_labs/usecases/invites/__init__.py @@ -1,7 +1,9 @@ from .get_invite_details import get_invite_details from .invitation_handler import invitation_handler +from .webhook_handler import handle_invite_webhook __all__ = [ "invitation_handler", "get_invite_details", + "handle_invite_webhook", ] diff --git a/virtual_labs/usecases/invites/webhook_handler.py b/virtual_labs/usecases/invites/webhook_handler.py new file mode 100644 index 00000000..48e7a763 --- /dev/null +++ b/virtual_labs/usecases/invites/webhook_handler.py @@ -0,0 +1,108 @@ +import hashlib +import hmac +from http import HTTPStatus +from uuid import UUID + +from pydantic import EmailStr +from sqlalchemy.ext.asyncio import AsyncSession + +from virtual_labs.core.exceptions.api_error import VliError, VliErrorCode +from virtual_labs.core.types import UserRoleEnum +from virtual_labs.domain.invite import InvitePayload +from virtual_labs.infrastructure.settings import settings +from virtual_labs.repositories.project_repo import ProjectQueryRepository +from virtual_labs.usecases.project.invite_user_to_project import invite_user_to_project + + +def compute_webhook_signature(body: bytes, secret: str) -> str: + """Compute HMAC-SHA256 signature for a webhook payload.""" + return hmac.HMAC( + secret.encode("utf-8"), + body, + hashlib.sha256, + ).hexdigest() + + +def verify_webhook_signature(signature: str, body: bytes) -> None: + """ + Verify the webhook signature. + + The sender computes: HMAC-SHA256(body, secret) and hex-encodes the digest. + We compute the same and compare using a timing-safe comparison. + """ + if not settings.INVITE_WEBHOOK_SECRET: + raise VliError( + error_code=VliErrorCode.SERVER_ERROR, + http_status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + message="Webhook secret is not configured", + ) + + expected = compute_webhook_signature(body, settings.INVITE_WEBHOOK_SECRET) + + if not hmac.compare_digest(signature, expected): + raise VliError( + error_code=VliErrorCode.INVALID_PARAMETER, + http_status_code=HTTPStatus.UNAUTHORIZED, + message="Invalid webhook signature", + ) + + +async def handle_invite_webhook( + session: AsyncSession, + *, + signature: str, + body: bytes, + virtual_lab_id: str, + project_id: str, + inviter_id: str, + invitee_email: EmailStr, + invitee_name: str, +) -> dict: + """ + Handle an incoming webhook to invite a user to a project. + + Steps: + 1. Verify the webhook signature + 2. Verify the virtual lab and project exist + 3. Send an invite to the project + """ + # 1. Verify signature + verify_webhook_signature(signature, body) + + vlab_uuid = UUID(virtual_lab_id) + project_uuid = UUID(project_id) + inviter_uuid = UUID(inviter_id) + + # 2. Verify virtual lab and project exist + pqr = ProjectQueryRepository(session) + try: + await pqr.retrieve_one_project_strict( + virtual_lab_id=vlab_uuid, + project_id=project_uuid, + ) + except Exception: + raise VliError( + error_code=VliErrorCode.INVALID_REQUEST, + http_status_code=HTTPStatus.NOT_FOUND, + message=f"Project {project_id} not found in virtual lab {virtual_lab_id}", + ) + + # 3. Send invite to the project (x_user_id is the inviter) + await invite_user_to_project( + session=session, + virtual_lab_id=vlab_uuid, + project_id=project_uuid, + inviter_id=inviter_uuid, + invite_details=InvitePayload( + email=invitee_email, + role=UserRoleEnum.member, + ), + ) + + return { + "message": "Invite sent successfully", + "virtual_lab_id": virtual_lab_id, + "project_id": project_id, + "invitee_email": invitee_email, + "invitee_name": invitee_name, + }