From d7f6d3604300a5959633108e8240c32e33541bad Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 05:28:57 +0000 Subject: [PATCH] feat: add Azure Function for authenticated dataset downloads Adds a Python Azure Function that authenticates BCIT users via Entra ID Easy Auth and generates short-lived SAS URLs for dataset downloads from Azure Blob Storage. - api/function_app.py: HTTP trigger that validates file names, creates user delegation SAS tokens, and returns 302 redirect - api/requirements.txt: azure-functions, azure-identity, azure-storage-blob - deploy-function.yaml: GitHub Actions workflow for zip deployment - .gitignore/.dockerignore: exclude function runtime artifacts Co-Authored-By: kyle_hunter@bcit.ca --- .dockerignore | 3 + .github/workflows/deploy-function.yaml | 41 +++++++++++++ .gitignore | 5 ++ api/.funcignore | 8 +++ api/function_app.py | 80 ++++++++++++++++++++++++++ api/host.json | 15 +++++ api/requirements.txt | 3 + 7 files changed, 155 insertions(+) create mode 100644 .github/workflows/deploy-function.yaml create mode 100644 api/.funcignore create mode 100644 api/function_app.py create mode 100644 api/host.json create mode 100644 api/requirements.txt diff --git a/.dockerignore b/.dockerignore index 43e8bde..91c2941 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,6 +27,9 @@ CHANGELOG.md release-please-config.json .release-please-manifest.json +# Azure Function (separate deployment) +api + # Docker docker-compose.yml Dockerfile diff --git a/.github/workflows/deploy-function.yaml b/.github/workflows/deploy-function.yaml new file mode 100644 index 0000000..c5a10da --- /dev/null +++ b/.github/workflows/deploy-function.yaml @@ -0,0 +1,41 @@ +name: Deploy Download Function + +on: + push: + branches: [main] + paths: [api/**] + workflow_dispatch: + +permissions: + id-token: write + contents: read + +env: + AZURE_FUNCTIONAPP_NAME: bcit-tlu-opendata-dl + PYTHON_VERSION: "3.12" + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: pip install -r api/requirements.txt --target api/.python_packages/lib/site-packages + + - uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - uses: azure/functions-action@v1 + with: + app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }} + package: api + scm-do-build-during-deployment: false diff --git a/.gitignore b/.gitignore index b2d6de3..73050ad 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Azure Functions local settings (contains secrets for local dev) +api/local.settings.json +api/.python_packages +api/__pycache__ diff --git a/api/.funcignore b/api/.funcignore new file mode 100644 index 0000000..212bab9 --- /dev/null +++ b/api/.funcignore @@ -0,0 +1,8 @@ +.git* +.vscode +__azurite_db* +__pycache__ +.python_packages +local.settings.json +test +.venv diff --git a/api/function_app.py b/api/function_app.py new file mode 100644 index 0000000..da2a42b --- /dev/null +++ b/api/function_app.py @@ -0,0 +1,80 @@ +# Azure Function — authenticated dataset download via SAS redirect. +# +# Sits behind Entra ID Easy Auth. Unauthenticated requests are +# redirected to Microsoft SSO automatically by the platform. +# Authenticated requests receive a short-lived SAS URL (302 redirect) +# that grants read-only access to the requested blob. + +import datetime +import logging +import os + +import azure.functions as func +from azure.identity import DefaultAzureCredential +from azure.storage.blob import BlobSasPermissions, BlobServiceClient, generate_blob_sas + +app = func.FunctionApp() + +STORAGE_ACCOUNT = os.environ["STORAGE_ACCOUNT_NAME"] +CONTAINER = os.environ.get("CONTAINER_NAME", "datasets-latest") +SAS_TTL_MINUTES = int(os.environ.get("SAS_TTL_MINUTES", "5")) + +ALLOWED_FILES: set[str] = { + "audiovideoprocessed.zip", + "contentobjects.zip", + "discussionsforum.zip", + "gradeobjects.zip", + "organizationalunits.zip", + "quizobjects.zip", + "releaseconditionsobjects.zip", + "roledetails.zip", + "all-datasets.zip", +} + + +@app.function_name("download") +@app.route(route="download", methods=["GET"]) +def download(req: func.HttpRequest) -> func.HttpResponse: + file_name = req.params.get("file") + if not file_name or file_name not in ALLOWED_FILES: + return func.HttpResponse( + "Invalid or missing 'file' parameter. " + f"Allowed values: {', '.join(sorted(ALLOWED_FILES))}", + status_code=400, + ) + + try: + credential = DefaultAzureCredential() + blob_service = BlobServiceClient( + f"https://{STORAGE_ACCOUNT}.blob.core.windows.net", + credential=credential, + ) + + now = datetime.datetime.now(datetime.timezone.utc) + delegation_key = blob_service.get_user_delegation_key( + key_start_time=now, + key_expiry_time=now + datetime.timedelta(hours=1), + ) + + sas_token = generate_blob_sas( + account_name=STORAGE_ACCOUNT, + container_name=CONTAINER, + blob_name=file_name, + user_delegation_key=delegation_key, + permission=BlobSasPermissions(read=True), + expiry=now + datetime.timedelta(minutes=SAS_TTL_MINUTES), + content_disposition=f"attachment; filename={file_name}", + ) + + sas_url = ( + f"https://{STORAGE_ACCOUNT}.blob.core.windows.net" + f"/{CONTAINER}/{file_name}?{sas_token}" + ) + return func.HttpResponse(status_code=302, headers={"Location": sas_url}) + + except Exception: + logging.exception("SAS URL generation failed") + return func.HttpResponse( + "Failed to generate download link. Please try again later.", + status_code=500, + ) diff --git a/api/host.json b/api/host.json new file mode 100644 index 0000000..06d01bd --- /dev/null +++ b/api/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..a557374 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,3 @@ +azure-functions>=1.21.0,<2 +azure-identity>=1.17.0,<2 +azure-storage-blob>=12.23.0,<13