diff --git a/scripts/admin/deploy_token_service.sh b/scripts/admin/deploy_token_service.sh new file mode 100755 index 0000000..0c38469 --- /dev/null +++ b/scripts/admin/deploy_token_service.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# +# Deploy Firebase Token Generation Service to Cloud Run +# +# This script builds and deploys the token service that generates fresh +# Firebase custom tokens for workspace service accounts. +# +# Usage: +# ./deploy_token_service.sh [OPTIONS] +# +# Options: +# --project PROJECT_ID GCP project ID (default: coderd) +# --region REGION Cloud Run region (default: us-central1) +# --service-name NAME Service name (default: firebase-token-service) +# --allow-unauthenticated Allow unauthenticated requests (NOT recommended) +# --dry-run Show commands without executing +# + +set -euo pipefail + +# Default configuration +PROJECT_ID="${GCP_PROJECT:-coderd}" +REGION="us-central1" +SERVICE_NAME="firebase-token-service" +ALLOW_UNAUTHENTICATED="false" +DRY_RUN="false" +FIRESTORE_DATABASE="onboarding" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --project) + PROJECT_ID="$2" + shift 2 + ;; + --region) + REGION="$2" + shift 2 + ;; + --service-name) + SERVICE_NAME="$2" + shift 2 + ;; + --allow-unauthenticated) + ALLOW_UNAUTHENTICATED="true" + shift + ;; + --dry-run) + DRY_RUN="true" + shift + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + exit 1 + ;; + esac +done + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +SERVICE_DIR="${PROJECT_ROOT}/services/token-service" + +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${BLUE}Firebase Token Service Deployment${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" +echo -e "${YELLOW}Configuration:${NC}" +echo " Project ID: ${PROJECT_ID}" +echo " Region: ${REGION}" +echo " Service Name: ${SERVICE_NAME}" +echo " Firestore Database: ${FIRESTORE_DATABASE}" +echo " Service Directory: ${SERVICE_DIR}" +echo " Dry Run: ${DRY_RUN}" +echo "" + +# Function to execute or print commands +run_cmd() { + if [[ "${DRY_RUN}" == "true" ]]; then + echo -e "${YELLOW}[DRY RUN]${NC} $*" + else + echo -e "${GREEN}▶${NC} $*" + "$@" + fi +} + +# Verify service directory exists +if [[ ! -d "${SERVICE_DIR}" ]]; then + echo -e "${RED}✗ Service directory not found: ${SERVICE_DIR}${NC}" + exit 1 +fi + +echo -e "${GREEN}✓${NC} Service directory found" + +# Check required files +REQUIRED_FILES=("main.py" "requirements.txt" "Dockerfile") +for file in "${REQUIRED_FILES[@]}"; do + if [[ ! -f "${SERVICE_DIR}/${file}" ]]; then + echo -e "${RED}✗ Required file not found: ${file}${NC}" + exit 1 + fi +done +echo -e "${GREEN}✓${NC} All required files present" +echo "" + +# Step 1: Set GCP project +echo -e "${BLUE}Step 1: Configure GCP Project${NC}" +run_cmd gcloud config set project "${PROJECT_ID}" +echo "" + +# Step 2: Enable required APIs +echo -e "${BLUE}Step 2: Enable Required APIs${NC}" +REQUIRED_APIS=( + "run.googleapis.com" + "cloudbuild.googleapis.com" + "artifactregistry.googleapis.com" +) + +for api in "${REQUIRED_APIS[@]}"; do + echo -e "${GREEN}▶${NC} Enabling ${api}..." + run_cmd gcloud services enable "${api}" --project="${PROJECT_ID}" +done +echo "" + +# Step 3: Build and deploy to Cloud Run +echo -e "${BLUE}Step 3: Build and Deploy to Cloud Run${NC}" +echo -e "${GREEN}▶${NC} Building container image and deploying..." + +DEPLOY_CMD=( + gcloud run deploy "${SERVICE_NAME}" + --source="${SERVICE_DIR}" + --platform=managed + --region="${REGION}" + --project="${PROJECT_ID}" + --set-env-vars="GCP_PROJECT=${PROJECT_ID},FIRESTORE_DATABASE=${FIRESTORE_DATABASE}" + --memory=512Mi + --cpu=1 + --timeout=60s + --max-instances=10 + --min-instances=0 + --concurrency=80 +) + +if [[ "${ALLOW_UNAUTHENTICATED}" == "false" ]]; then + DEPLOY_CMD+=(--no-allow-unauthenticated) +else + DEPLOY_CMD+=(--allow-unauthenticated) + echo -e "${YELLOW}⚠ Warning: Allowing unauthenticated access${NC}" +fi + +run_cmd "${DEPLOY_CMD[@]}" +echo "" + +# Step 4: Get service URL +if [[ "${DRY_RUN}" == "false" ]]; then + echo -e "${BLUE}Step 4: Retrieve Service URL${NC}" + SERVICE_URL=$(gcloud run services describe "${SERVICE_NAME}" \ + --platform=managed \ + --region="${REGION}" \ + --project="${PROJECT_ID}" \ + --format='value(status.url)') + + echo -e "${GREEN}✓${NC} Service deployed successfully!" + echo "" + echo -e "${GREEN}Service URL:${NC} ${SERVICE_URL}" + echo "" + + # Step 5: Set IAM permissions for workspace service accounts + echo -e "${BLUE}Step 5: Configure IAM Permissions${NC}" + echo -e "${YELLOW}Note: You need to grant Cloud Run Invoker role to workspace service accounts${NC}" + echo "" + echo "Run the following command for each workspace service account:" + echo "" + echo -e "${BLUE}gcloud run services add-iam-policy-binding ${SERVICE_NAME} \\${NC}" + echo -e "${BLUE} --region=${REGION} \\${NC}" + echo -e "${BLUE} --project=${PROJECT_ID} \\${NC}" + echo -e "${BLUE} --member='serviceAccount:WORKSPACE_SA_EMAIL' \\${NC}" + echo -e "${BLUE} --role='roles/run.invoker'${NC}" + echo "" + echo "Or grant access to the default compute service account:" + echo "" + + # Get project number + PROJECT_NUMBER=$(gcloud projects describe "${PROJECT_ID}" --format='value(projectNumber)') + DEFAULT_SA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" + + echo -e "${BLUE}gcloud run services add-iam-policy-binding ${SERVICE_NAME} \\${NC}" + echo -e "${BLUE} --region=${REGION} \\${NC}" + echo -e "${BLUE} --project=${PROJECT_ID} \\${NC}" + echo -e "${BLUE} --member='serviceAccount:${DEFAULT_SA}' \\${NC}" + echo -e "${BLUE} --role='roles/run.invoker'${NC}" + echo "" + + # Step 6: Test the service + echo -e "${BLUE}Step 6: Test the Service${NC}" + echo "Test the health endpoint:" + echo "" + echo -e "${BLUE}curl ${SERVICE_URL}/health${NC}" + echo "" + echo "Test token generation (requires authentication):" + echo "" + echo -e "${BLUE}curl -X POST ${SERVICE_URL}/generate-token \\${NC}" + echo -e "${BLUE} -H \"Authorization: Bearer \$(gcloud auth print-identity-token)\" \\${NC}" + echo -e "${BLUE} -H \"Content-Type: application/json\" \\${NC}" + echo -e "${BLUE} -d '{\"github_handle\": \"your-github-username\"}'${NC}" + echo "" + + # Save service URL to config file + CONFIG_FILE="${PROJECT_ROOT}/.token-service-url" + echo "${SERVICE_URL}" > "${CONFIG_FILE}" + echo -e "${GREEN}✓${NC} Service URL saved to ${CONFIG_FILE}" + echo "" +fi + +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN}Deployment Complete!${NC}" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" diff --git a/services/token-service/.dockerignore b/services/token-service/.dockerignore new file mode 100644 index 0000000..e46ef17 --- /dev/null +++ b/services/token-service/.dockerignore @@ -0,0 +1,16 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.git +.gitignore +README.md +.env +.venv +venv/ diff --git a/services/token-service/Dockerfile b/services/token-service/Dockerfile new file mode 100644 index 0000000..d3235c9 --- /dev/null +++ b/services/token-service/Dockerfile @@ -0,0 +1,22 @@ +# Use official Python runtime as base image +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY main.py . + +# Create non-root user for security +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8080 + +# Run the application with gunicorn for production +CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 main:app diff --git a/services/token-service/main.py b/services/token-service/main.py new file mode 100644 index 0000000..878cd59 --- /dev/null +++ b/services/token-service/main.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +Firebase Token Generation Service for Cloud Run. + +This service generates fresh Firebase custom tokens for workspace service accounts, +enabling secure access to Firestore with proper security rules enforcement. +""" + +import logging +import os +from typing import Any + +import firebase_admin +import jwt +from firebase_admin import auth, credentials +from flask import Flask, request +from google.cloud import firestore # type: ignore[attr-defined] + + +# Initialize Flask app +app = Flask(__name__) + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Initialize Firebase Admin SDK +try: + # Use Application Default Credentials in Cloud Run + cred = credentials.ApplicationDefault() + firebase_admin.initialize_app( + cred, {"projectId": os.environ.get("GCP_PROJECT", "coderd")} + ) + logger.info("Firebase Admin SDK initialized successfully") +except Exception as e: + logger.error(f"Failed to initialize Firebase Admin SDK: {e}") + raise + +# Initialize Firestore client +db = firestore.Client( + project=os.environ.get("GCP_PROJECT", "coderd"), + database=os.environ.get("FIRESTORE_DATABASE", "onboarding"), +) + + +def verify_service_account_identity() -> str | None: + """ + Verify the calling service account identity from the request. + + Cloud Run validates authentication and provides identity through + request headers. We extract the authenticated user email from + the X-Goog-Authenticated-User-Email header. + + Returns + ------- + str | None + Service account or user email if verified, None otherwise. + """ + try: + # Cloud Run validates the Authorization header before reaching our app + # If we get here, the token is valid - we can safely decode it + auth_header = request.headers.get("Authorization", "") + + if not auth_header or not auth_header.lower().startswith("bearer "): + logger.warning("No valid Authorization header") + return None + + token = auth_header.split(" ", 1)[1] + + # Decode the token (Cloud Run already validated it, + # so we don't need to verify signature) + decoded = jwt.decode(token, options={"verify_signature": False}) + + # Extract email from token + # For service accounts: email field + # For user accounts: email field + email = decoded.get("email") + + if not email: + logger.warning(f"No email in token. Token claims: {list(decoded.keys())}") + return None + + logger.info(f"Authenticated user: {email}") + return email + + except Exception as e: + logger.error(f"Failed to verify service account: {e}") + return None + + +def get_github_handle_from_workspace_sa(service_account_email: str) -> str | None: + """ + Map workspace service account to GitHub handle. + + Workspace naming convention: coder-{github_handle}-{workspace_name} + Service account: {project_number}-compute@developer.gserviceaccount.com + + Since all workspaces use the same default compute SA, we need to get the + GitHub handle from metadata or allow passing it as a parameter. + + Parameters + ---------- + service_account_email : str + Service account email from the workspace. + + Returns + ------- + str | None + GitHub handle if found, None otherwise. + """ + # For now, require github_handle to be passed in request body + # In production, you could map this via metadata or workspace labels + github_handle = request.json.get("github_handle") if request.json else None + + if not github_handle: + logger.warning("github_handle not provided in request") + return None + + # Verify this participant exists in Firestore + try: + doc_ref = db.collection("participants").document(github_handle) + doc = doc_ref.get() + + if not doc.exists: + logger.warning(f"Participant {github_handle} not found in Firestore") + return None + + logger.info(f"Found participant: {github_handle}") + return github_handle + + except Exception as e: + logger.error(f"Failed to verify participant: {e}") + return None + + +def generate_custom_token(github_handle: str) -> tuple[bool, str | None, str | None]: + """ + Generate a Firebase custom token for a participant. + + Parameters + ---------- + github_handle : str + GitHub handle of the participant. + + Returns + ------- + tuple[bool, str | None, str | None] + Tuple of (success, token_string, error_message). + """ + try: + # Create custom token with github_handle claim + custom_token = auth.create_custom_token( + uid=github_handle, developer_claims={"github_handle": github_handle} + ) + + # Token is returned as bytes, decode to string + token_str = custom_token.decode("utf-8") + logger.info(f"Generated custom token for {github_handle}") + + return True, token_str, None + + except Exception as e: + error_msg = f"Failed to generate token: {str(e)}" + logger.error(error_msg) + return False, None, error_msg + + +@app.route("/health", methods=["GET"]) # type: ignore[misc] +def health() -> tuple[dict[str, str], int]: + """Health check endpoint.""" + return {"status": "healthy"}, 200 + + +@app.route("/generate-token", methods=["POST"]) # type: ignore[misc] +def generate_token() -> tuple[dict[str, Any], int]: + """ + Generate a fresh Firebase custom token for a workspace. + + Expected request body: + { + "github_handle": "username" + } + + Returns + ------- + tuple[dict, int] + Response body and HTTP status code. + """ + try: + # Verify the service account identity + service_account = verify_service_account_identity() + + if not service_account: + return { + "error": "Unauthorized", + "message": "Could not verify service account identity", + }, 401 + + # Get GitHub handle from request or map from service account + github_handle = get_github_handle_from_workspace_sa(service_account) + + if not github_handle: + return { + "error": "Invalid request", + "message": "Could not determine GitHub handle. Ensure github_handle is in request body.", + }, 400 + + # Generate fresh custom token + success, token, error = generate_custom_token(github_handle) + + if not success or not token: + return {"error": "Token generation failed", "message": error}, 500 + + return { + "token": token, + "github_handle": github_handle, + "expires_in": 3600, # 1 hour + }, 200 + + except Exception as e: + logger.error(f"Unexpected error in generate_token: {e}") + return {"error": "Internal server error", "message": str(e)}, 500 + + +if __name__ == "__main__": + # Run the Flask app + port = int(os.environ.get("PORT", "8080")) + app.run(host="0.0.0.0", port=port) diff --git a/services/token-service/requirements.txt b/services/token-service/requirements.txt new file mode 100644 index 0000000..f8e5e97 --- /dev/null +++ b/services/token-service/requirements.txt @@ -0,0 +1,6 @@ +flask==3.0.0 +firebase-admin==6.5.0 +google-cloud-firestore==2.16.0 +google-auth==2.29.0 +pyjwt==2.8.0 +gunicorn==21.2.0