Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set up DocuSign webhook handler for waivers #245

Merged
merged 2 commits into from
Jan 17, 2024
Merged
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
21 changes: 13 additions & 8 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@ which will start a Uvicorn server with auto-reload.

For deployment, the following environment variables need to be set:

- `PYTHONPATH=src/api` to properly import Python modules
- `SP_KEY`, the private key for SAML authentication
- `JWT_KEY`, the secret key used to sign JWTs
- `AUTH_KEY_SALT`, the salt used when encrypting guest authentication tokens
- `SENDGRID_API_KEY`, the API key needed to use the SendGrid API
- `RESUMES_FOLDER_ID`, the ID of the Google Drive folder to upload to
- Either `SERVICE_ACCOUNT_FILE` or `GOOGLE_SERVICE_ACCOUNT_CREDENTIALS`: We use a Google service acccount in tandem with aiogoogle to automatically upload resumes when submitting a form. The keys are JSON that can either be stored in a file, in which case the path of the file should be stored in `SERVICE_ACCOUNT_FILE`, or be stored directly in `GOOGLE_SERVICE_ACCOUNT_CREDENTIALS`. For local development, it is recommended to take the `SERVICE_ACCOUNT_FILE` approach.
- `PYTHONPATH=src/api` to properly import Python modules
- `SP_KEY`, the private key for SAML authentication
- `JWT_KEY`, the secret key used to sign JWTs
- `AUTH_KEY_SALT`, the salt used when encrypting guest authentication tokens
- `SENDGRID_API_KEY`, the API key needed to use the SendGrid API
- `RESUMES_FOLDER_ID`, the ID of the Google Drive folder to upload to
- Either `SERVICE_ACCOUNT_FILE` or `GOOGLE_SERVICE_ACCOUNT_CREDENTIALS`: We use a Google service acccount in tandem with aiogoogle to automatically upload resumes when submitting a form. The keys are JSON that can either be stored in a file, in which case the path of the file should be stored in `SERVICE_ACCOUNT_FILE`, or be stored directly in `GOOGLE_SERVICE_ACCOUNT_CREDENTIALS`. For local development, it is recommended to take the `SERVICE_ACCOUNT_FILE` approach.
- `DOCUSIGN_HMAC_KEY`, the HMAC key for validating DocuSign Connect webhook event payloads.

For staging, the following environment variables should also bet set:

- `DEPLOYMENT=staging`
- `DEPLOYMENT=staging`

For local, the following environment variables should also be set:

- `DEPLOYMENT=local`
3 changes: 3 additions & 0 deletions apps/api/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
from app import app as api
from auth.guest_auth import AUTH_KEY_SALT
from auth.user_identity import JWT_SECRET
from services.docusign_handler import DOCUSIGN_HMAC_KEY

if not JWT_SECRET:
raise RuntimeError("JWT_SECRET is not defined")
if not AUTH_KEY_SALT:
raise RuntimeError("AUTH_KEY_SALT is not defined")
if not DOCUSIGN_HMAC_KEY:
raise RuntimeError("DOCUSIGN_HMAC_KEY is not defined")

# Override AWS Lambda logging configuration
logging.basicConfig(level=logging.INFO, force=True)
Expand Down
41 changes: 40 additions & 1 deletion apps/api/src/routers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
from logging import getLogger
from typing import Annotated, Optional, Union

from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile, status
from fastapi import (
APIRouter,
Depends,
Form,
Header,
HTTPException,
Request,
UploadFile,
status,
)
from fastapi.responses import RedirectResponse
from pydantic import BaseModel, EmailStr

Expand All @@ -11,6 +20,7 @@
from auth.user_identity import User, require_user_identity, use_user_identity
from models.ApplicationData import ProcessedApplicationData, RawApplicationData
from services import docusign_handler, mongodb_handler
from services.docusign_handler import WebhookPayload
from services.mongodb_handler import Collection
from utils import email_handler, resume_handler
from utils.user_record import Applicant, Role, Status
Expand Down Expand Up @@ -160,6 +170,7 @@ async def request_waiver(
user: Annotated[tuple[User, Applicant], Depends(require_accepted_applicant)]
) -> RedirectResponse:
"""Request to sign the participant waiver through DocuSign."""
# TODO: non-applicants might also want to request a waiver
user_data, applicant = user
application_data = applicant.application_data

Expand All @@ -168,10 +179,38 @@ async def request_waiver(

user_name = f"{application_data.first_name} {application_data.last_name}"

# TODO: email may not match UCInetID format from `docusign_handler._acquire_uid`
form_url = docusign_handler.waiver_form_url(user_data.email, user_name)
return RedirectResponse(form_url, status.HTTP_303_SEE_OTHER)


@router.post("/waiver")
async def waiver_webhook(
request: Request,
x_docusign_signature_1: Annotated[str, Header()],
payload: WebhookPayload,
) -> None:
"""Process webhook from DocuSign Connect."""
# Note: in practice there can be multiple keys to generate multiple signatures
# We assume there is only one key and thus pick the first signature
is_valid_signature = docusign_handler.verify_webhook_signature(
await request.body(), x_docusign_signature_1
)

if payload.event != "envelope-completed":
raise HTTPException(status.HTTP_404_NOT_FOUND, "Unable to process event type.")

if not is_valid_signature:
log.error("Waiver Webhook received invalid signature.")
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid signature")

try:
await docusign_handler.process_webhook_event(payload)
except ValueError as err:
log.exception("During waiver webhook processing: %s", err)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid payload content.")


@router.post("/rsvp")
async def rsvp(
user: Annotated[User, Depends(require_user_identity)]
Expand Down
99 changes: 96 additions & 3 deletions apps/api/src/services/docusign_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,56 @@
import base64
import hashlib
import hmac
import os
import urllib.parse
from datetime import datetime
from logging import getLogger
from typing import Sequence
from uuid import UUID

from pydantic import EmailStr
from pydantic import UUID4, BaseModel, EmailStr

from auth import user_identity
from utils import waiver_handler

log = getLogger(__name__)


class PowerForm(BaseModel):
powerFormId: UUID4


class Signer(BaseModel):
name: str
email: EmailStr


class Recipients(BaseModel):
signers: list[Signer]


class EnvelopeSummary(BaseModel):
status: str
recipients: Recipients
powerForm: PowerForm
completedDateTime: datetime


class EnvelopeCompletedData(BaseModel):
accountId: UUID4
userId: UUID4
envelopeId: UUID4
envelopeSummary: EnvelopeSummary


class WebhookPayload(BaseModel):
event: str
data: EnvelopeCompletedData


DOCUSIGN_HMAC_KEY = os.getenv("DOCUSIGN_HMAC_KEY", "")
POWERFORM_ID = UUID("d5120219-dec1-41c5-b579-5e6b45c886e8") # temporary
ACCOUNT_ID = UUID("cc0e3157-358d-4e10-acb0-ef39db7e3071") # temporary


def waiver_form_url(email: EmailStr, user_name: str) -> str:
Expand All @@ -9,8 +59,8 @@ def waiver_form_url(email: EmailStr, user_name: str) -> str:
query = urllib.parse.urlencode(
{
"env": "demo", # temporary
"PowerFormId": "d5120219-dec1-41c5-b579-5e6b45c886e8", # temporary
"acct": "cc0e3157-358d-4e10-acb0-ef39db7e3071", # temporary
"PowerFormId": str(POWERFORM_ID),
"acct": str(ACCOUNT_ID),
f"{role_name}_Email": email,
f"{role_name}_UserName": user_name,
"v": "2",
Expand All @@ -20,3 +70,46 @@ def waiver_form_url(email: EmailStr, user_name: str) -> str:
return (
f"https://demo.docusign.net/Member/PowerFormSigning.aspx?{query}" # temporary
)


def verify_webhook_signature(payload: bytes, signature: str) -> bool:
"""Verify POST request content is signed according to the secret key."""
hmac_hash = hmac.new(DOCUSIGN_HMAC_KEY.encode(), payload, hashlib.sha256)
result = base64.b64encode(hmac_hash.digest())
return hmac.compare_digest(result, signature.encode())


async def process_webhook_event(payload: WebhookPayload) -> None:
"""Process webhook event from DocuSign for waiver signing."""
envelope_summary = payload.data.envelopeSummary
signers = envelope_summary.recipients.signers
_verify_envelope_content(envelope_summary.powerForm, signers)

email = signers[0].email
uid = _acquire_uid(email)

await waiver_handler.process_waiver_completion(uid, email)


def _verify_envelope_content(power_form: PowerForm, signers: Sequence[Signer]) -> None:
"""Raises ValueError if envelope data is invalid."""
# checked template id
if power_form.powerFormId != POWERFORM_ID:
log.error("Received DocuSign event for an unexpected PowerForm ID.")
raise ValueError("PowerForm IDs do not match")

# parse applicant from recipient
if len(signers) != 1:
log.error("DocuSign envelope had multiple signers.")
raise ValueError("PowerForm should only contain one signer")


def _acquire_uid(email: EmailStr) -> str:
"""Get UID from the user's email address."""
if user_identity.uci_email(email):
# Note: this is technically not correct since could have custom email,
# but most users have email addresses based on their UCInetID
ucinetid, _ = email.split("@")
return f"edu.uci.{ucinetid}"
else:
return user_identity.scoped_uid(email)
2 changes: 1 addition & 1 deletion apps/api/src/services/mongodb_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from bson import CodecOptions
from motor.core import AgnosticClient
from motor.motor_asyncio import AsyncIOMotorClient
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field

log = getLogger(__name__)

Expand Down
48 changes: 48 additions & 0 deletions apps/api/src/utils/waiver_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from logging import getLogger

from pydantic import EmailStr

from models.ApplicationData import Decision
from services import mongodb_handler
from services.mongodb_handler import Collection
from utils.user_record import Applicant, Role, Status, UserRecord

log = getLogger(__name__)


async def process_waiver_completion(uid: str, email: EmailStr) -> None:
"""
Update user record with WAIVER_SIGNED status if the user is filling out the
waiver for the first time and if the user has a status of ACCEPTED.

If no user record exists, insert a new record. In all other cases, ignore
the submission.
"""
record = await mongodb_handler.retrieve_one(Collection.USERS, {"_id": uid})

if not record:
# external participant, create database record
log.info(f"external participant {email} signed waiver.")
await mongodb_handler.insert(
Collection.USERS, {"_id": uid, "status": Status.WAIVER_SIGNED}
)
return

user_record = UserRecord.model_validate(record)
if user_record.role == Role.APPLICANT:
applicant_record = Applicant.model_validate(record)
if applicant_record.status in (Status.WAIVER_SIGNED, Status.CONFIRMED):
log.warning(
f"User {uid} attempted to sign waiver but already signed it previously."
)
return
elif applicant_record.status != Decision.ACCEPTED:
log.warning(f"User {uid} attempted to sign waiver but was not accepted.")
return

log.info(f"User {uid} signed waiver.")
# Note: this should be able to account for other participant types
# including mentors, volunteers, etc.
await mongodb_handler.update_one(
Collection.USERS, {"_id": uid}, {"status": Status.WAIVER_SIGNED}
)
Loading