diff --git a/.env.example b/.env.example index 404e283d7..865661081 100644 --- a/.env.example +++ b/.env.example @@ -66,7 +66,16 @@ DATABASE_URL=postgresql://adcp_user:secure_password_change_me@postgres:5432/adcp # ============================================ # Only needed if using real ad server adapters -# Google Ad Manager +# Google Ad Manager OAuth (for GAM Admin UI configuration) +GAM_OAUTH_CLIENT_ID=your-gam-oauth-client-id.apps.googleusercontent.com +GAM_OAUTH_CLIENT_SECRET=your-gam-oauth-client-secret + +# Google Cloud Platform (for auto-provisioning service accounts) +# Required if you want to use the "Create Service Account" feature in Admin UI +# This is the GCP project ID where service accounts will be created +# GCP_PROJECT_ID=your-gcp-project-id + +# Legacy: Manual GAM configuration (not needed if using Admin UI) # GAM_NETWORK_CODE=123456789 # GAM_SERVICE_ACCOUNT_JSON='{"type":"service_account"...}' diff --git a/.gitignore b/.gitignore index a1eed9a6e..e3d4726e1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ gam_ad_units.json client_secret*.json credentials*.json +# GCP Service Account Keys +*-key.json +adcp-manager-key.json + # Build artifacts *.egg-info/ *.egg-info diff --git a/alembic/versions/661c474053fa_add_gcp_project_id_and_service_account_.py b/alembic/versions/661c474053fa_add_gcp_project_id_and_service_account_.py new file mode 100644 index 000000000..7a27ea7cc --- /dev/null +++ b/alembic/versions/661c474053fa_add_gcp_project_id_and_service_account_.py @@ -0,0 +1,30 @@ +"""add_gam_service_account_email_to_adapter_config + +Revision ID: 661c474053fa +Revises: 1a7693edad5d +Create Date: 2025-10-19 04:41:52.439532 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "661c474053fa" +down_revision: str | Sequence[str] | None = "1a7693edad5d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Only add service account email - GCP project ID is environment config, not per-tenant + op.add_column("adapter_config", sa.Column("gam_service_account_email", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("adapter_config", "gam_service_account_email") diff --git a/docs/gam-service-account-setup.md b/docs/gam-service-account-setup.md index e18dfec7d..ead2011d6 100644 --- a/docs/gam-service-account-setup.md +++ b/docs/gam-service-account-setup.md @@ -7,6 +7,16 @@ The AdCP Sales Agent supports two authentication methods for Google Ad Manager: 1. **OAuth (Refresh Token)** - User-based authentication requiring manual token refresh 2. **Service Account** - Automated authentication using Google Cloud service accounts (recommended for production) +## 🚀 New: Automatic Service Account Provisioning (Recommended for Partners) + +**For partner integrations, we now create and manage service accounts for you automatically!** + +Instead of partners sending us their service account JSON credentials, we: +1. Create a service account in our GCP project +2. Provide you with the service account email +3. You add this email as a user in your GAM with Trafficker role +4. We handle all credential management securely + ## Service Account Benefits - ✅ **No manual refresh**: Service accounts don't expire like OAuth tokens @@ -16,6 +26,23 @@ The AdCP Sales Agent supports two authentication methods for Google Ad Manager: - ✅ **Multi-tenant**: Each tenant can have their own service account - ✅ **Cloud-native**: Credentials stored encrypted in database, no file management +## Security Model + +Service account authentication uses **two-factor control**: + +1. **Private Key** (we control): Stored encrypted in our database, used to cryptographically sign API requests +2. **GAM User List** (partner controls): Partner must explicitly add the service account email to their GAM + +**Both are required for access:** +- Just knowing the email is NOT enough - API calls must be signed with the private key +- Just having the private key is NOT enough - partner must grant permissions in their GAM + +**Partner maintains control:** +- Can revoke access anytime by removing email from GAM +- Controls what permissions to grant (Trafficker, Salesperson, etc.) +- Can restrict to specific advertisers via GAM teams +- All activity appears in their GAM audit logs + ## Required Service Account Roles ### Recommended: "Trafficker" Role @@ -78,7 +105,51 @@ Network: - Read (to get timezone and network info) ``` -## Setup Instructions +## Prerequisites for Automatic Service Account Creation + +The automatic service account creation feature requires: + +1. **GCP Project Configuration**: The `GCP_PROJECT_ID` environment variable must be set to your Google Cloud Project ID +2. **Service Account Permissions**: The application's default credentials must have permissions to create service accounts and keys in that project (requires `roles/iam.serviceAccountAdmin` or `roles/iam.serviceAccountKeyAdmin`) + +If you haven't set these up yet, see your system administrator or cloud platform documentation. + +## Setup Instructions (New Automatic Flow - Recommended) + +### Step 1: Request Service Account Creation + +1. Log into the Admin UI (http://localhost:8001 or your production URL) +2. Navigate to **Tenant Settings** → **Ad Server** +3. Select **Google Ad Manager** as your adapter +4. Scroll to the **Service Account Integration** section +5. Click **🔑 Create Service Account** +6. Wait a few seconds while we create the service account in our GCP project +7. Copy the service account email that appears + +### Step 2: Add Service Account to Your GAM + +1. Log into your [Google Ad Manager](https://admanager.google.com/) account +2. Navigate to **Admin** → **Access & authorization** → **Users** +3. Click **New user** +4. Paste the service account email from Step 1 +5. Assign role: **Trafficker** (recommended) +6. Under **Teams**, select specific advertisers (optional but recommended for security) +7. Click **Save** + +### Step 3: Test Connection + +1. Return to the Admin UI settings page +2. Click **Test Connection** button +3. If successful, you're done! If not, verify: + - Service account email was added correctly in GAM + - Trafficker role was assigned + - You clicked Save in GAM + +--- + +## Alternative: Manual Service Account Setup (Legacy) + +**Note**: The automatic flow above is recommended. Use this only if you need to create your own service account. ### Step 1: Create Service Account in Google Cloud Console diff --git a/docs/gcp-service-account-provisioning-setup.md b/docs/gcp-service-account-provisioning-setup.md new file mode 100644 index 000000000..47a1a96f4 --- /dev/null +++ b/docs/gcp-service-account-provisioning-setup.md @@ -0,0 +1,244 @@ +# GCP Service Account Provisioning - Deployment Setup + +## Overview + +This guide explains how to set up the automatic service account provisioning feature for production deployment on Fly.io (or any cloud platform). + +## Architecture + +``` +Your Sales Agent (Fly.io) + ↓ +Uses "Management Service Account" + ↓ +Creates "Partner Service Accounts" in your GCP project + ↓ +Partners add these to their GAM +``` + +## Prerequisites + +1. A Google Cloud Platform (GCP) project +2. Access to create service accounts in that project +3. Fly.io CLI installed and authenticated + +## Step-by-Step Setup + +### Step 1: Create a GCP Project (if needed) + +```bash +# Create a new GCP project (or use existing) +gcloud projects create adcp-sales-agent-prod --name="AdCP Sales Agent Production" + +# Set as default project +gcloud config set project adcp-sales-agent-prod +``` + +### Step 2: Create the "Management" Service Account + +This is the service account that your application will run as to create other service accounts: + +```bash +# Create the management service account +gcloud iam service-accounts create adcp-manager \ + --display-name="AdCP Service Account Manager" \ + --description="Service account used by AdCP Sales Agent to create partner service accounts" + +# Get the email +export SA_EMAIL="adcp-manager@adcp-sales-agent-prod.iam.gserviceaccount.com" +echo "Management Service Account: $SA_EMAIL" +``` + +### Step 3: Grant IAM Permissions + +The management service account needs permission to create other service accounts: + +```bash +# Grant Service Account Admin role (to create service accounts) +gcloud projects add-iam-policy-binding adcp-sales-agent-prod \ + --member="serviceAccount:$SA_EMAIL" \ + --role="roles/iam.serviceAccountAdmin" + +# Grant Service Account Key Admin role (to create service account keys) +gcloud projects add-iam-policy-binding adcp-sales-agent-prod \ + --member="serviceAccount:$SA_EMAIL" \ + --role="roles/iam.serviceAccountKeyAdmin" +``` + +**Why these roles?** +- `roles/iam.serviceAccountAdmin` - Allows creating and managing service accounts +- `roles/iam.serviceAccountKeyAdmin` - Allows creating service account keys + +### Step 4: Generate Service Account Key + +```bash +# Create a JSON key for the management service account +gcloud iam service-accounts keys create ~/adcp-manager-key.json \ + --iam-account=$SA_EMAIL + +# The key is saved to ~/adcp-manager-key.json +``` + +**⚠️ IMPORTANT:** Keep this key secure! It has permission to create service accounts in your project. + +### Step 5: Configure Fly.io + +#### Set the GCP Project ID (in fly.toml) + +Edit `fly.toml` and uncomment/set: + +```toml +[env] + # Other env vars... + GCP_PROJECT_ID = "adcp-sales-agent-prod" # Your actual project ID +``` + +Commit and push this change. + +#### Set the Service Account Key (as Fly secret) + +```bash +# Read the key file and set as Fly secret +fly secrets set GOOGLE_APPLICATION_CREDENTIALS_JSON="$(cat ~/adcp-manager-key.json)" \ + --app adcp-sales-agent + +# Verify it was set (you won't see the value, just the name) +fly secrets list --app adcp-sales-agent +``` + +### Step 6: Deploy + +```bash +# Deploy the application with the new configuration +fly deploy --app adcp-sales-agent +``` + +### Step 7: Verify Setup + +Check the application logs to ensure credentials are loading: + +```bash +fly logs --app adcp-sales-agent +``` + +Look for: +``` +GCP credentials loaded from GOOGLE_APPLICATION_CREDENTIALS_JSON +``` + +### Step 8: Test the Feature + +1. Log into Admin UI: https://sales-agent.scope3.com/ +2. Navigate to **Tenant Settings** → **Ad Server** +3. Select **Google Ad Manager** +4. Scroll to **Service Account Integration** +5. Click **🔑 Create Service Account** +6. You should see a service account email created! + +## Verification Checklist + +- [ ] GCP project created/identified +- [ ] Management service account created +- [ ] IAM roles granted (serviceAccountAdmin + serviceAccountKeyAdmin) +- [ ] Service account key generated +- [ ] `GCP_PROJECT_ID` set in fly.toml +- [ ] `GOOGLE_APPLICATION_CREDENTIALS_JSON` set as Fly secret +- [ ] Application deployed +- [ ] Logs show credentials loaded +- [ ] Test service account creation works + +## Troubleshooting + +### Error: "GCP_PROJECT_ID not configured" +**Cause:** Environment variable not set in fly.toml + +**Fix:** +```toml +[env] + GCP_PROJECT_ID = "your-project-id" +``` + +### Error: "Permission denied" or "IAM API not enabled" +**Cause:** Missing IAM permissions or API not enabled + +**Fix:** +```bash +# Enable IAM API +gcloud services enable iam.googleapis.com --project=adcp-sales-agent-prod + +# Re-grant permissions +gcloud projects add-iam-policy-binding adcp-sales-agent-prod \ + --member="serviceAccount:adcp-manager@adcp-sales-agent-prod.iam.gserviceaccount.com" \ + --role="roles/iam.serviceAccountAdmin" +``` + +### Error: "No explicit GCP credentials provided" +**Cause:** GOOGLE_APPLICATION_CREDENTIALS_JSON secret not set + +**Fix:** +```bash +fly secrets set GOOGLE_APPLICATION_CREDENTIALS_JSON="$(cat ~/adcp-manager-key.json)" \ + --app adcp-sales-agent +``` + +### Verify What Service Account Is Being Used + +```bash +# In the application, log the credentials +# The service account email will appear in logs when IAMClient is initialized + +fly logs --app adcp-sales-agent | grep "service_account" +``` + +## Security Best Practices + +1. **Rotate Keys Regularly**: Create new keys every 90 days + ```bash + # Create new key + gcloud iam service-accounts keys create ~/new-key.json --iam-account=$SA_EMAIL + + # Update Fly secret + fly secrets set GOOGLE_APPLICATION_CREDENTIALS_JSON="$(cat ~/new-key.json)" + + # Delete old key (get key ID from console) + gcloud iam service-accounts keys delete KEY_ID --iam-account=$SA_EMAIL + ``` + +2. **Least Privilege**: Only grant the minimum required roles + +3. **Monitor Usage**: Check GCP IAM audit logs for service account creation activity + +4. **Separate Projects**: Consider using a dedicated GCP project for service account creation + +## Cost Considerations + +- Service account creation is **free** +- Service account keys are **free** +- IAM API calls are **free** (within quota) +- No ongoing costs for this feature + +## Alternative: Using Workload Identity (Advanced) + +If you're running on GCP (not Fly.io), you can use Workload Identity instead of service account keys: + +```bash +# This is more secure but only works on GCP environments +# Not applicable for Fly.io deployments +``` + +## Support + +If you encounter issues: +1. Check Fly.io logs: `fly logs --app adcp-sales-agent` +2. Verify IAM permissions in GCP Console +3. Ensure IAM API is enabled in your project +4. Check that the service account key JSON is valid + +## Summary + +Once configured, the flow is: +1. Your app runs as the "management" service account (credentials in Fly secret) +2. When a partner clicks "Create Service Account" in Admin UI +3. Your app creates a new service account: `adcp-sales-tenant123@project.iam.gserviceaccount.com` +4. Partner adds that email to their GAM +5. Done! No credential sharing needed. diff --git a/fly.toml b/fly.toml index 87015b372..7145f1bfe 100644 --- a/fly.toml +++ b/fly.toml @@ -55,6 +55,8 @@ primary_region = "iad" # A2A Configuration A2A_MOCK_MODE = "false" A2A_SERVER_URL = "https://sales-agent.scope3.com/a2a" + # GCP Configuration (for auto-provisioning service accounts) + GCP_PROJECT_ID = "bok-playground" [mounts] source = "adcp_data" diff --git a/pyproject.toml b/pyproject.toml index 17647bf17..25cb3b09a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ requires-python = ">=3.12" dependencies = [ "fastmcp>=2.11.0", # Required for context.get_http_request() support "google-generativeai>=0.5.4", + "google-cloud-iam>=2.19.1", "rich>=13.7.1", "google-ads>=24.1.0", "requests>=2.32.3", diff --git a/scripts/setup/setup_conductor_workspace.sh b/scripts/setup/setup_conductor_workspace.sh index 30857ecc4..86fd2db08 100755 --- a/scripts/setup/setup_conductor_workspace.sh +++ b/scripts/setup/setup_conductor_workspace.sh @@ -162,6 +162,7 @@ fi # Copy required files from root workspace echo "" echo "Copying files from root workspace..." +cp $CONDUCTOR_ROOT_PATH/adcp-manager-key.json . # Create .env file with secrets from multiple sources echo "Creating .env file with secrets and workspace configuration..." diff --git a/src/admin/blueprints/gam.py b/src/admin/blueprints/gam.py index 7e6495a21..16f1c48d2 100644 --- a/src/admin/blueprints/gam.py +++ b/src/admin/blueprints/gam.py @@ -712,6 +712,89 @@ def get_sync_status(tenant_id, sync_id): return jsonify({"error": str(e)}), 500 +@gam_bp.route("/create-service-account", methods=["POST"]) +@log_admin_action("create_gam_service_account") +@require_tenant_access() +def create_service_account(tenant_id): + """Create a GCP service account for GAM integration. + + This creates a service account in our GCP project, generates credentials, + and stores them encrypted in the database. The partner then configures + this service account email in their GAM. + """ + if session.get("role") == "viewer": + return jsonify({"success": False, "error": "Access denied"}), 403 + + try: + # Get GCP project ID from environment or configuration + gcp_project_id = os.environ.get("GCP_PROJECT_ID") + if not gcp_project_id: + return ( + jsonify( + { + "success": False, + "error": "GCP_PROJECT_ID not configured. Please set this environment variable.", + } + ), + 500, + ) + + from src.services.gcp_service_account_service import GCPServiceAccountService + + service = GCPServiceAccountService(gcp_project_id=gcp_project_id) + + # Create service account for tenant + try: + service_account_email, _ = service.create_service_account_for_tenant(tenant_id=tenant_id) + + return jsonify( + { + "success": True, + "service_account_email": service_account_email, + "message": "Service account created successfully. Please add this email as a user in your Google Ad Manager with Trafficker role.", + } + ) + + except ValueError as e: + # Tenant not found or already has service account + return jsonify({"success": False, "error": str(e)}), 400 + + except Exception as e: + logger.error(f"Error creating service account for tenant {tenant_id}: {e}", exc_info=True) + return jsonify({"success": False, "error": f"Failed to create service account: {str(e)}"}), 500 + + except Exception as e: + logger.error(f"Error in create_service_account endpoint: {e}", exc_info=True) + return jsonify({"success": False, "error": str(e)}), 500 + + +@gam_bp.route("/get-service-account-email", methods=["GET"]) +@require_tenant_access(api_mode=True) +def get_service_account_email(tenant_id): + """Get the service account email for a tenant. + + Returns the service account email if one has been created for this tenant. + """ + try: + gcp_project_id = os.environ.get("GCP_PROJECT_ID") + if not gcp_project_id: + return jsonify({"error": "GCP_PROJECT_ID not configured"}), 500 + + from src.services.gcp_service_account_service import GCPServiceAccountService + + service = GCPServiceAccountService(gcp_project_id=gcp_project_id) + email = service.get_service_account_email(tenant_id) + + if email: + return jsonify({"success": True, "service_account_email": email}) + else: + return jsonify({"success": True, "service_account_email": None, "message": "No service account created"}) + + except Exception as e: + logger.error(f"Error getting service account email: {e}", exc_info=True) + return jsonify({"error": str(e)}), 500 + + @gam_bp.route("/api/line-item/", methods=["GET"]) @require_tenant_access(api_mode=True) def get_gam_line_item_api(tenant_id, line_item_id): diff --git a/src/core/database/models.py b/src/core/database/models.py index df3250c27..42d53c923 100644 --- a/src/core/database/models.py +++ b/src/core/database/models.py @@ -572,7 +572,17 @@ class AdapterConfig(Base): # Google Ad Manager gam_network_code: Mapped[str | None] = mapped_column(String(50), nullable=True) gam_refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True) - _gam_service_account_json: Mapped[str | None] = mapped_column("gam_service_account_json", Text, nullable=True) + _gam_service_account_json: Mapped[str | None] = mapped_column( + "gam_service_account_json", + Text, + nullable=True, + comment="Encrypted service account key. Required to authenticate AS the service account when calling GAM API. Partner must also add the email to their GAM for access.", + ) + gam_service_account_email: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + comment="Email of auto-provisioned service account. Partner adds this to their GAM user list with appropriate permissions.", + ) gam_auth_method: Mapped[str] = mapped_column(String(50), nullable=False, server_default="oauth") gam_trafficker_id: Mapped[str | None] = mapped_column(String(50), nullable=True) gam_manual_approval_required: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/src/services/gcp_service_account_service.py b/src/services/gcp_service_account_service.py new file mode 100644 index 000000000..48d8eef47 --- /dev/null +++ b/src/services/gcp_service_account_service.py @@ -0,0 +1,294 @@ +"""Google Cloud Platform Service Account Management. + +This service handles automatic provisioning of service accounts for GAM integration. +Instead of partners sending us their service account JSON, we create the service account +for them, store it securely, and provide them the email to configure in their GAM. + +Security Model - Two-Factor Control: + ┌────────────────────────────────────────────────────────────────┐ + │ Service Account Authentication Requires BOTH: │ + ├────────────────────────────────────────────────────────────────┤ + │ 1. Private Key (we control - stored encrypted in database) │ + │ 2. GAM User List Entry (partner controls - they add the email) │ + │ │ + │ Just knowing the service account email is NOT enough! │ + │ API calls must be cryptographically signed with the private │ + │ key to prove identity, AND the partner must explicitly grant │ + │ permissions by adding the email to their GAM. │ + └────────────────────────────────────────────────────────────────┘ + +Why We Store the Service Account Key: + We need the private key (stored as gam_service_account_json) to authenticate AS the + service account when making GAM API calls on behalf of the tenant. Without it, we + cannot access the partner's GAM even if they've added the email to their user list. + + Flow: + 1. We create: adcp-sales-tenant123@bok-playground.iam.gserviceaccount.com + private key + 2. We store: Private key encrypted in database (using ENCRYPTION_KEY) + 3. Partner adds: Service account email to their GAM with Trafficker role + 4. When we access GAM: We use stored key to sign API requests as that service account + 5. GAM validates: "Request signed correctly AND email in my user list" → Allow access + + Partner Security: + - Partner can revoke access anytime by removing the email from their GAM + - Partner controls what permissions to grant (Trafficker, Salesperson, etc.) + - Partner can restrict access to specific advertisers via GAM teams + - Service account activity appears in partner's GAM audit logs + +Authentication for This Service: + The IAMClient uses Application Default Credentials (ADC) to authenticate to GCP + for creating service accounts (not for accessing partner GAM). + + In production (Fly.io), set the GOOGLE_APPLICATION_CREDENTIALS_JSON secret: + fly secrets set GOOGLE_APPLICATION_CREDENTIALS_JSON='{"type":"service_account"...}' --app adcp-sales-agent + + The management service account must have these IAM roles in YOUR GCP project: + - roles/iam.serviceAccountAdmin (to create service accounts) + - roles/iam.serviceAccountKeyAdmin (to create service account keys) +""" + +import logging +import os +import tempfile + +from google.cloud import iam_admin_v1 +from google.cloud.iam_admin_v1 import types +from sqlalchemy import select + +from src.core.database.database_session import get_db_session +from src.core.database.models import AdapterConfig + +logger = logging.getLogger(__name__) + + +class GCPServiceAccountService: + """Service for managing GCP service accounts for GAM integration.""" + + def __init__(self, gcp_project_id: str): + """Initialize service with GCP project ID. + + Args: + gcp_project_id: The GCP project ID where service accounts will be created + + Note: + Authentication uses Application Default Credentials (ADC). + Set GOOGLE_APPLICATION_CREDENTIALS_JSON as a Fly secret or + GOOGLE_APPLICATION_CREDENTIALS as a file path. + """ + self.gcp_project_id = gcp_project_id + self._temp_creds_file = None + + # Setup credentials if provided via environment variable (common in cloud deployments) + self._setup_credentials() + + # Create IAM client (uses ADC) + self.iam_client = iam_admin_v1.IAMClient() + + def _setup_credentials(self): + """Setup GCP credentials from environment if provided. + + Handles GOOGLE_APPLICATION_CREDENTIALS_JSON secret for cloud deployments. + """ + creds_json = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS_JSON") + if creds_json: + # Write credentials to temp file for GCP client library + # This is needed because the client library expects a file path + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: + f.write(creds_json) + self._temp_creds_file = f.name + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = f.name + logger.info("GCP credentials loaded from GOOGLE_APPLICATION_CREDENTIALS_JSON") + elif os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"): + logger.info(f"Using GCP credentials from file: {os.environ['GOOGLE_APPLICATION_CREDENTIALS']}") + else: + logger.warning("No explicit GCP credentials provided - relying on Application Default Credentials") + + def cleanup(self): + """Cleanup temporary credentials file. + + Called automatically on object destruction, but can be called manually if needed. + """ + if self._temp_creds_file: + try: + os.unlink(self._temp_creds_file) + logger.debug(f"Cleaned up temporary credentials file: {self._temp_creds_file}") + self._temp_creds_file = None + except FileNotFoundError: + pass + except Exception as e: + logger.warning(f"Failed to cleanup temp credentials file: {e}") + + def __del__(self): + """Destructor: cleanup temporary credentials file.""" + self.cleanup() + + def create_service_account_for_tenant(self, tenant_id: str, display_name: str | None = None) -> tuple[str, str]: + """Create a service account for a tenant and store credentials. + + This creates a service account in GCP, generates a key for it, + and stores the credentials encrypted in the database. + + Args: + tenant_id: Tenant ID to create service account for + display_name: Optional display name for the service account + + Returns: + Tuple of (service_account_email, service_account_json) + + Raises: + ValueError: If tenant not found or already has a service account + Exception: If service account creation fails + """ + with get_db_session() as session: + # Get adapter config + stmt = select(AdapterConfig).filter_by(tenant_id=tenant_id) + adapter_config = session.scalars(stmt).first() + + if not adapter_config: + raise ValueError(f"Tenant {tenant_id} not found or has no adapter config") + + # Check if service account already exists + if adapter_config.gam_service_account_email: + raise ValueError( + f"Tenant {tenant_id} already has a service account: {adapter_config.gam_service_account_email}" + ) + + # Generate account ID from tenant ID (must be 6-30 chars, lowercase, numbers, hyphens) + # Format: adcp-sales-{tenant_id} + account_id = f"adcp-sales-{tenant_id}".lower() + if len(account_id) > 30: + # Truncate if too long + account_id = account_id[:30] + + # Create service account + try: + service_account = self._create_service_account( + account_id=account_id, display_name=display_name or f"AdCP Sales Agent - {tenant_id}" + ) + logger.info(f"Created service account: {service_account.email}") + + # Create key for service account + service_account_json = self._create_service_account_key(service_account.email) + logger.info(f"Created service account key for: {service_account.email}") + + # Store in database + adapter_config.gam_service_account_email = service_account.email + adapter_config.gam_service_account_json = service_account_json + adapter_config.gam_auth_method = "service_account" + session.commit() + + logger.info(f"Stored service account credentials for tenant {tenant_id}") + + return service_account.email, service_account_json + + except Exception as e: + logger.error(f"Failed to create service account for tenant {tenant_id}: {e}", exc_info=True) + raise + + def _create_service_account(self, account_id: str, display_name: str) -> types.ServiceAccount: + """Create a service account in GCP. + + Args: + account_id: Unique ID for the service account (6-30 chars) + display_name: Human-readable display name + + Returns: + Created ServiceAccount object + + Raises: + Exception: If creation fails + """ + request = types.CreateServiceAccountRequest() + request.account_id = account_id + request.name = f"projects/{self.gcp_project_id}" + + service_account = types.ServiceAccount() + service_account.display_name = display_name + request.service_account = service_account + + account = self.iam_client.create_service_account(request=request) + logger.info(f"Created service account: {account.email}") + return account + + def _create_service_account_key(self, service_account_email: str) -> str: + """Create a key for a service account. + + Args: + service_account_email: Email of the service account + + Returns: + Service account JSON credentials as string + + Raises: + Exception: If key creation fails + """ + request = types.CreateServiceAccountKeyRequest() + request.name = f"projects/{self.gcp_project_id}/serviceAccounts/{service_account_email}" + + key = self.iam_client.create_service_account_key(request=request) + + # Extract private key data (this is the JSON credentials) + # The private_key_data is bytes, need to decode to string + service_account_json = key.private_key_data.decode("utf-8") + + return service_account_json + + def get_service_account_email(self, tenant_id: str) -> str | None: + """Get the service account email for a tenant. + + Args: + tenant_id: Tenant ID + + Returns: + Service account email or None if not created + """ + with get_db_session() as session: + stmt = select(AdapterConfig).filter_by(tenant_id=tenant_id) + adapter_config = session.scalars(stmt).first() + + if not adapter_config: + return None + + return adapter_config.gam_service_account_email + + def delete_service_account(self, tenant_id: str) -> bool: + """Delete a service account for a tenant. + + This removes the service account from GCP and clears the database. + + Args: + tenant_id: Tenant ID + + Returns: + True if deleted, False if no service account existed + + Raises: + Exception: If deletion fails + """ + with get_db_session() as session: + stmt = select(AdapterConfig).filter_by(tenant_id=tenant_id) + adapter_config = session.scalars(stmt).first() + + if not adapter_config or not adapter_config.gam_service_account_email: + return False + + service_account_email = adapter_config.gam_service_account_email + + try: + # Delete from GCP + delete_request = iam_admin_v1.DeleteServiceAccountRequest() + delete_request.name = f"projects/{self.gcp_project_id}/serviceAccounts/{service_account_email}" + self.iam_client.delete_service_account(request=delete_request) + logger.info(f"Deleted service account from GCP: {service_account_email}") + + # Clear from database + adapter_config.gam_service_account_email = None + adapter_config.gam_service_account_json = None + session.commit() + + logger.info(f"Cleared service account for tenant {tenant_id}") + return True + + except Exception as e: + logger.error(f"Failed to delete service account for tenant {tenant_id}: {e}", exc_info=True) + raise diff --git a/static/js/tenant_settings.js b/static/js/tenant_settings.js index 4d58f26a1..c1e714975 100644 --- a/static/js/tenant_settings.js +++ b/static/js/tenant_settings.js @@ -1233,3 +1233,84 @@ function savePrincipalMappings() { alert('Error: ' + error.message); }); } + +// Service Account Management Functions +function createServiceAccount() { + const button = document.getElementById('create-service-account-btn'); + button.disabled = true; + button.innerHTML = ' Creating...'; + + fetch(`${config.scriptName}/tenant/${config.tenantId}/gam/create-service-account`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('Service account created successfully!\n\nEmail: ' + data.service_account_email + '\n\n' + data.message); + // Reload page to show the service account email and next steps + location.reload(); + } else { + alert('Error creating service account: ' + (data.error || 'Unknown error')); + button.disabled = false; + button.innerHTML = '🔑 Create Service Account'; + } + }) + .catch(error => { + alert('Error: ' + error.message); + button.disabled = false; + button.innerHTML = '🔑 Create Service Account'; + }); +} + +function copyServiceAccountEmail() { + const emailElement = document.querySelector('code'); + if (emailElement) { + const email = emailElement.textContent; + navigator.clipboard.writeText(email).then(() => { + const button = event.target; + const originalText = button.textContent; + button.textContent = '✓ Copied!'; + button.classList.add('btn-success'); + button.classList.remove('btn-secondary'); + setTimeout(() => { + button.textContent = originalText; + button.classList.remove('btn-success'); + button.classList.add('btn-secondary'); + }, 2000); + }); + } +} + +function testGAMServiceAccountConnection() { + const button = event.target; + button.disabled = true; + button.innerHTML = ' Testing...'; + + // Use existing GAM test connection endpoint + // The backend will automatically use service account if configured + fetch(`${config.scriptName}/tenant/${config.tenantId}/gam/test-connection`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + button.disabled = false; + button.innerHTML = 'Test Connection'; + + if (data.success) { + alert('✅ Connection successful!\n\nNetwork: ' + (data.network_name || 'N/A') + '\nNetwork Code: ' + (data.network_code || 'N/A')); + } else { + alert('❌ Connection failed!\n\n' + (data.error || 'Unknown error') + '\n\nPlease make sure:\n1. You added the service account email to your GAM\n2. You assigned the Trafficker role\n3. You clicked Save in GAM'); + } + }) + .catch(error => { + button.disabled = false; + button.innerHTML = 'Test Connection'; + alert('Error: ' + error.message); + }); +} diff --git a/templates/tenant_settings.html b/templates/tenant_settings.html index 3323b9045..a34676a04 100644 --- a/templates/tenant_settings.html +++ b/templates/tenant_settings.html @@ -791,6 +791,55 @@

✅ Configuration Complete

{% endif %} {% endif %} + +
+

🔧 Alternative: Service Account Integration (Recommended for Partners)

+

+ For partner integrations, we can create and manage a service account for you. This is more secure and doesn't require sharing credentials. +

+ + {% if adapter_config and adapter_config.get('service_account_email') %} + +
+

✅ Service Account Created

+

+ Service Account Email:
+ {{ adapter_config.get('service_account_email') }} +

+
+

⚠️ Next Steps:

+
    +
  1. Log into your Google Ad Manager account
  2. +
  3. Go to Admin → Access & authorization → Users
  4. +
  5. Click New user
  6. +
  7. Add the service account email above
  8. +
  9. Assign the Trafficker role
  10. +
  11. Click Save
  12. +
  13. Come back and click "Test Connection" below
  14. +
+
+
+ + +
+
+ {% else %} + +
+

+ We'll create a service account in our GCP project and provide you with the email address. You then add this email as a user in your Google Ad Manager with the Trafficker role. +

+ +
+ {% endif %} +
+
{% if adapter_config and adapter_config.get('refresh_token') %} diff --git a/uv.lock b/uv.lock index fbf5d17f8..d35ba581a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version < '3.13'", ] @@ -75,6 +76,7 @@ dependencies = [ { name = "flask" }, { name = "flask-socketio" }, { name = "google-ads" }, + { name = "google-cloud-iam" }, { name = "google-generativeai" }, { name = "googleads" }, { name = "httpx" }, @@ -138,6 +140,7 @@ requires-dist = [ { name = "flask", specifier = ">=3.0.0" }, { name = "flask-socketio", specifier = ">=5.5.1" }, { name = "google-ads", specifier = ">=24.1.0" }, + { name = "google-cloud-iam", specifier = ">=2.19.1" }, { name = "google-generativeai", specifier = ">=0.5.4" }, { name = "googleads", specifier = "==46.0.0" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -839,7 +842,8 @@ dependencies = [ { name = "google-api-core" }, { name = "google-auth-oauthlib" }, { name = "googleapis-common-protos" }, - { name = "grpcio" }, + { name = "grpcio", version = "1.73.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.75.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "grpcio-status" }, { name = "proto-plus" }, { name = "protobuf" }, @@ -883,7 +887,8 @@ wheels = [ [package.optional-dependencies] grpc = [ - { name = "grpcio" }, + { name = "grpcio", version = "1.73.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.75.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "grpcio-status" }, ] @@ -943,6 +948,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/84/40ee070be95771acd2f4418981edb834979424565c3eec3cd88b6aa09d24/google_auth_oauthlib-1.2.2-py3-none-any.whl", hash = "sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2", size = 19072, upload-time = "2025-04-22T16:40:28.174Z" }, ] +[[package]] +name = "google-cloud-iam" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio", version = "1.73.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.75.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/53/e73091b5f012e669d1ec501b70c99e8e8e86f6fa09cf56f47ae3fb0395a5/google_cloud_iam-2.20.0.tar.gz", hash = "sha256:06568ed8313f59fac46d21a5aae4c54eb1dda9f6bcecf2736c58ab1065dc9173", size = 480552, upload-time = "2025-10-14T15:42:54.9Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/78/664349481896f85d7e79a1fdd5d322e9052126da8065f67c75ad2c17c97a/google_cloud_iam-2.20.0-py3-none-any.whl", hash = "sha256:643fcf6db3100772f222c7173bc1af15541a05ec1c43785191e835146ed150b8", size = 448329, upload-time = "2025-10-14T15:42:40.287Z" }, +] + [[package]] name = "google-generativeai" version = "0.8.5" @@ -988,6 +1011,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, ] +[package.optional-dependencies] +grpc = [ + { name = "grpcio", version = "1.73.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.75.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, +] + [[package]] name = "greenlet" version = "3.1.1" @@ -1021,10 +1050,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112, upload-time = "2024-09-20T17:09:28.753Z" }, ] +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio", version = "1.73.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.75.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, +] + [[package]] name = "grpcio" version = "1.73.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] sdist = { url = "https://files.pythonhosted.org/packages/79/e8/b43b851537da2e2f03fa8be1aef207e5cbfb1a2e014fbb6b40d24c177cd3/grpcio-1.73.1.tar.gz", hash = "sha256:7fce2cd1c0c1116cf3850564ebfc3264fba75d3c74a7414373f1238ea365ef87", size = 12730355, upload-time = "2025-06-26T01:53:24.622Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b8/41/456caf570c55d5ac26f4c1f2db1f2ac1467d5bf3bcd660cba3e0a25b195f/grpcio-1.73.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:921b25618b084e75d424a9f8e6403bfeb7abef074bb6c3174701e0f2542debcf", size = 5334621, upload-time = "2025-06-26T01:52:23.602Z" }, @@ -1049,13 +1097,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/d7/77ac689216daee10de318db5aa1b88d159432dc76a130948a56b3aa671a2/grpcio-1.73.1-cp313-cp313-win_amd64.whl", hash = "sha256:4a68f8c9966b94dff693670a5cf2b54888a48a5011c5d9ce2295a1a1465ee84f", size = 4335747, upload-time = "2025-06-26T01:53:01.233Z" }, ] +[[package]] +name = "grpcio" +version = "1.75.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/f7/8963848164c7604efb3a3e6ee457fdb3a469653e19002bd24742473254f8/grpcio-1.75.1.tar.gz", hash = "sha256:3e81d89ece99b9ace23a6916880baca613c03a799925afb2857887efa8b1b3d2", size = 12731327, upload-time = "2025-09-26T09:03:36.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/81/42be79e73a50aaa20af66731c2defeb0e8c9008d9935a64dd8ea8e8c44eb/grpcio-1.75.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:7b888b33cd14085d86176b1628ad2fcbff94cfbbe7809465097aa0132e58b018", size = 5668314, upload-time = "2025-09-26T09:01:55.424Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/3686ed15822fedc58c22f82b3a7403d9faf38d7c33de46d4de6f06e49426/grpcio-1.75.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8775036efe4ad2085975531d221535329f5dac99b6c2a854a995456098f99546", size = 11476125, upload-time = "2025-09-26T09:01:57.927Z" }, + { url = "https://files.pythonhosted.org/packages/14/85/21c71d674f03345ab183c634ecd889d3330177e27baea8d5d247a89b6442/grpcio-1.75.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb658f703468d7fbb5dcc4037c65391b7dc34f808ac46ed9136c24fc5eeb041d", size = 6246335, upload-time = "2025-09-26T09:02:00.76Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/3beb661bc56a385ae4fa6b0e70f6b91ac99d47afb726fe76aaff87ebb116/grpcio-1.75.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4b7177a1cdb3c51b02b0c0a256b0a72fdab719600a693e0e9037949efffb200b", size = 6916309, upload-time = "2025-09-26T09:02:02.894Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9c/eda9fe57f2b84343d44c1b66cf3831c973ba29b078b16a27d4587a1fdd47/grpcio-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d4fa6ccc3ec2e68a04f7b883d354d7fea22a34c44ce535a2f0c0049cf626ddf", size = 6435419, upload-time = "2025-09-26T09:02:05.055Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b8/090c98983e0a9d602e3f919a6e2d4e470a8b489452905f9a0fa472cac059/grpcio-1.75.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d86880ecaeb5b2f0a8afa63824de93adb8ebe4e49d0e51442532f4e08add7d6", size = 7064893, upload-time = "2025-09-26T09:02:07.275Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c0/6d53d4dbbd00f8bd81571f5478d8a95528b716e0eddb4217cc7cb45aae5f/grpcio-1.75.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a8041d2f9e8a742aeae96f4b047ee44e73619f4f9d24565e84d5446c623673b6", size = 8011922, upload-time = "2025-09-26T09:02:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7c/48455b2d0c5949678d6982c3e31ea4d89df4e16131b03f7d5c590811cbe9/grpcio-1.75.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3652516048bf4c314ce12be37423c79829f46efffb390ad64149a10c6071e8de", size = 7466181, upload-time = "2025-09-26T09:02:12.279Z" }, + { url = "https://files.pythonhosted.org/packages/fd/12/04a0e79081e3170b6124f8cba9b6275871276be06c156ef981033f691880/grpcio-1.75.1-cp312-cp312-win32.whl", hash = "sha256:44b62345d8403975513af88da2f3d5cc76f73ca538ba46596f92a127c2aea945", size = 3938543, upload-time = "2025-09-26T09:02:14.77Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d7/11350d9d7fb5adc73d2b0ebf6ac1cc70135577701e607407fe6739a90021/grpcio-1.75.1-cp312-cp312-win_amd64.whl", hash = "sha256:b1e191c5c465fa777d4cafbaacf0c01e0d5278022082c0abbd2ee1d6454ed94d", size = 4641938, upload-time = "2025-09-26T09:02:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/46/74/bac4ab9f7722164afdf263ae31ba97b8174c667153510322a5eba4194c32/grpcio-1.75.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3bed22e750d91d53d9e31e0af35a7b0b51367e974e14a4ff229db5b207647884", size = 5672779, upload-time = "2025-09-26T09:02:19.11Z" }, + { url = "https://files.pythonhosted.org/packages/a6/52/d0483cfa667cddaa294e3ab88fd2c2a6e9dc1a1928c0e5911e2e54bd5b50/grpcio-1.75.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5b8f381eadcd6ecaa143a21e9e80a26424c76a0a9b3d546febe6648f3a36a5ac", size = 11470623, upload-time = "2025-09-26T09:02:22.117Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e4/d1954dce2972e32384db6a30273275e8c8ea5a44b80347f9055589333b3f/grpcio-1.75.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5bf4001d3293e3414d0cf99ff9b1139106e57c3a66dfff0c5f60b2a6286ec133", size = 6248838, upload-time = "2025-09-26T09:02:26.426Z" }, + { url = "https://files.pythonhosted.org/packages/06/43/073363bf63826ba8077c335d797a8d026f129dc0912b69c42feaf8f0cd26/grpcio-1.75.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f82ff474103e26351dacfe8d50214e7c9322960d8d07ba7fa1d05ff981c8b2d", size = 6922663, upload-time = "2025-09-26T09:02:28.724Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6f/076ac0df6c359117676cacfa8a377e2abcecec6a6599a15a672d331f6680/grpcio-1.75.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0ee119f4f88d9f75414217823d21d75bfe0e6ed40135b0cbbfc6376bc9f7757d", size = 6436149, upload-time = "2025-09-26T09:02:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/6b/27/1d08824f1d573fcb1fa35ede40d6020e68a04391709939e1c6f4193b445f/grpcio-1.75.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:664eecc3abe6d916fa6cf8dd6b778e62fb264a70f3430a3180995bf2da935446", size = 7067989, upload-time = "2025-09-26T09:02:33.233Z" }, + { url = "https://files.pythonhosted.org/packages/c6/98/98594cf97b8713feb06a8cb04eeef60b4757e3e2fb91aa0d9161da769843/grpcio-1.75.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c32193fa08b2fbebf08fe08e84f8a0aad32d87c3ad42999c65e9449871b1c66e", size = 8010717, upload-time = "2025-09-26T09:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7e/bb80b1bba03c12158f9254762cdf5cced4a9bc2e8ed51ed335915a5a06ef/grpcio-1.75.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5cebe13088b9254f6e615bcf1da9131d46cfa4e88039454aca9cb65f639bd3bc", size = 7463822, upload-time = "2025-09-26T09:02:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/23/1c/1ea57fdc06927eb5640f6750c697f596f26183573069189eeaf6ef86ba2d/grpcio-1.75.1-cp313-cp313-win32.whl", hash = "sha256:4b4c678e7ed50f8ae8b8dbad15a865ee73ce12668b6aaf411bf3258b5bc3f970", size = 3938490, upload-time = "2025-09-26T09:02:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/4b/24/fbb8ff1ccadfbf78ad2401c41aceaf02b0d782c084530d8871ddd69a2d49/grpcio-1.75.1-cp313-cp313-win_amd64.whl", hash = "sha256:5573f51e3f296a1bcf71e7a690c092845fb223072120f4bdb7a5b48e111def66", size = 4642538, upload-time = "2025-09-26T09:02:42.519Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1b/9a0a5cecd24302b9fdbcd55d15ed6267e5f3d5b898ff9ac8cbe17ee76129/grpcio-1.75.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:c05da79068dd96723793bffc8d0e64c45f316248417515f28d22204d9dae51c7", size = 5673319, upload-time = "2025-09-26T09:02:44.742Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ec/9d6959429a83fbf5df8549c591a8a52bb313976f6646b79852c4884e3225/grpcio-1.75.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06373a94fd16ec287116a825161dca179a0402d0c60674ceeec8c9fba344fe66", size = 11480347, upload-time = "2025-09-26T09:02:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/09/7a/26da709e42c4565c3d7bf999a9569da96243ce34a8271a968dee810a7cf1/grpcio-1.75.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4484f4b7287bdaa7a5b3980f3c7224c3c622669405d20f69549f5fb956ad0421", size = 6254706, upload-time = "2025-09-26T09:02:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/f1/08/dcb26a319d3725f199c97e671d904d84ee5680de57d74c566a991cfab632/grpcio-1.75.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2720c239c1180eee69f7883c1d4c83fc1a495a2535b5fa322887c70bf02b16e8", size = 6922501, upload-time = "2025-09-26T09:02:52.711Z" }, + { url = "https://files.pythonhosted.org/packages/78/66/044d412c98408a5e23cb348845979a2d17a2e2b6c3c34c1ec91b920f49d0/grpcio-1.75.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:07a554fa31c668cf0e7a188678ceeca3cb8fead29bbe455352e712ec33ca701c", size = 6437492, upload-time = "2025-09-26T09:02:55.542Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9d/5e3e362815152aa1afd8b26ea613effa005962f9da0eec6e0e4527e7a7d1/grpcio-1.75.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3e71a2105210366bfc398eef7f57a664df99194f3520edb88b9c3a7e46ee0d64", size = 7081061, upload-time = "2025-09-26T09:02:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1a/46615682a19e100f46e31ddba9ebc297c5a5ab9ddb47b35443ffadb8776c/grpcio-1.75.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8679aa8a5b67976776d3c6b0521e99d1c34db8a312a12bcfd78a7085cb9b604e", size = 8010849, upload-time = "2025-09-26T09:03:00.548Z" }, + { url = "https://files.pythonhosted.org/packages/67/8e/3204b94ac30b0f675ab1c06540ab5578660dc8b690db71854d3116f20d00/grpcio-1.75.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aad1c774f4ebf0696a7f148a56d39a3432550612597331792528895258966dc0", size = 7464478, upload-time = "2025-09-26T09:03:03.096Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/2d90652b213863b2cf466d9c1260ca7e7b67a16780431b3eb1d0420e3d5b/grpcio-1.75.1-cp314-cp314-win32.whl", hash = "sha256:62ce42d9994446b307649cb2a23335fa8e927f7ab2cbf5fcb844d6acb4d85f9c", size = 4012672, upload-time = "2025-09-26T09:03:05.477Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/e2e6e9fc1c985cd1a59e6996a05647c720fe8a03b92f5ec2d60d366c531e/grpcio-1.75.1-cp314-cp314-win_amd64.whl", hash = "sha256:f86e92275710bea3000cb79feca1762dc0ad3b27830dd1a74e82ab321d4ee464", size = 4772475, upload-time = "2025-09-26T09:03:07.661Z" }, +] + [[package]] name = "grpcio-status" version = "1.71.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, - { name = "grpcio" }, + { name = "grpcio", version = "1.73.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.75.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677, upload-time = "2025-06-28T04:24:05.426Z" }