diff --git a/infra/deploy_instance.py b/infra/deploy_instance.py new file mode 100644 index 000000000..1608d68d6 --- /dev/null +++ b/infra/deploy_instance.py @@ -0,0 +1,860 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +CoPyRIT GUI — Deploy a new isolated instance. + +Automates the full deployment of an isolated CoPyRIT GUI instance: + 1. Resource group + 2. Entra app registration + API scope + group claims + 3. Entra security group (optional — can use existing) + 4. Azure SQL server + database + 5. Key Vault + populate .env secret + 6. Bicep deployment (Container App, MI, networking, logging) + 7. Post-deploy: SPA redirect URI, RBAC role assignments + +Usage: + python infra/deploy_instance.py \\ + --instance-name partners-demo \\ + --env-file ./my-demo.env \\ + --subscription "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \\ + --location eastus2 \\ + --acr-name myacr \\ + --container-image myacr.azurecr.io/pyrit:abc1234 \\ + --allowed-groups "group-oid-1,group-oid-2" + +""" + +import argparse +import json +import logging +import subprocess +import sys +import time +import uuid +from pathlib import Path + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + +INFRA_DIR = Path(__file__).resolve().parent +BICEP_TEMPLATE = INFRA_DIR / "main.bicep" + + +def run_az( + *, + args: list[str], + capture: bool = True, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + """ + Run an Azure CLI command. + + Args: + args (list[str]): The az CLI arguments (without the leading 'az'). + capture (bool): Whether to capture stdout/stderr. Defaults to True. + check (bool): Whether to raise on non-zero exit. Defaults to True. + + Returns: + subprocess.CompletedProcess[str]: The completed process. + + Raises: + subprocess.CalledProcessError: If the command fails and check is True. + """ + cmd = ["az"] + args + logger.debug("Running: %s", " ".join(cmd)) + return subprocess.run( + cmd, + capture_output=capture, + text=True, + check=check, + ) + + +def run_az_json(*, args: list[str]) -> dict | list | str: + """ + Run an Azure CLI command and parse JSON output. + + Args: + args (list[str]): The az CLI arguments (without the leading 'az'). + + Returns: + dict | list | str: The parsed JSON output. + """ + result = run_az(args=args + ["-o", "json"]) + return json.loads(result.stdout) + + +def set_subscription(subscription: str) -> None: + """ + Set the active Azure subscription. + + Args: + subscription (str): The subscription name or ID. + """ + logger.info("Setting subscription to: %s", subscription) + run_az(args=["account", "set", "--subscription", subscription]) + + +def create_resource_group(*, name: str, location: str) -> None: + """ + Create an Azure resource group. + + Args: + name (str): The resource group name. + location (str): The Azure region. + """ + logger.info("Creating resource group: %s in %s", name, location) + run_az(args=["group", "create", "--name", name, "--location", location]) + + +def create_entra_app(*, display_name: str) -> dict: + """ + Create an Entra app registration with API scope and group claims. + + Args: + display_name (str): The display name for the app registration. + + Returns: + dict: A dict with keys 'app_id', 'app_object_id', 'tenant_id', 'sp_id'. + """ + logger.info("Creating Entra app registration: %s", display_name) + + # Create app registration and capture output directly (avoids fragile name-based lookup) + app_create_result = run_az_json( + args=[ + "ad", + "app", + "create", + "--display-name", + display_name, + "--sign-in-audience", + "AzureADMyOrg", + "--query", + "{appId:appId, id:id}", + ] + ) + app_id = app_create_result["appId"] + app_object_id = app_create_result["id"] + + tenant_id = run_az_json(args=["account", "show", "--query", "tenantId"]) + + # Create service principal (enterprise app) + logger.info("Creating service principal for app: %s", app_id) + run_az(args=["ad", "sp", "create", "--id", app_id]) + sp_info = run_az_json( + args=[ + "ad", + "sp", + "show", + "--id", + app_id, + "--query", + "{id:id}", + ] + ) + sp_id = sp_info["id"] + + # Set Application ID URI + logger.info("Setting Application ID URI: api://%s", app_id) + run_az( + args=[ + "rest", + "--method", + "PATCH", + "--url", + f"https://graph.microsoft.com/v1.0/applications/{app_object_id}", + "--body", + json.dumps({"identifierUris": [f"api://{app_id}"]}), + ] + ) + + # Add 'access' scope + scope_id = str(uuid.uuid4()) + logger.info("Adding 'access' scope (id: %s)", scope_id) + scope_body = { + "api": { + "oauth2PermissionScopes": [ + { + "id": scope_id, + "isEnabled": True, + "type": "User", + "value": "access", + "adminConsentDisplayName": "Access PyRIT GUI", + "adminConsentDescription": "Allow access to the PyRIT GUI API", + "userConsentDisplayName": "Access PyRIT GUI", + "userConsentDescription": "Allow access to the PyRIT GUI API", + } + ] + } + } + run_az( + args=[ + "rest", + "--method", + "PATCH", + "--url", + f"https://graph.microsoft.com/v1.0/applications/{app_object_id}", + "--body", + json.dumps(scope_body), + ] + ) + + # Configure group claims (ApplicationGroup) + logger.info("Configuring group claims: ApplicationGroup") + run_az( + args=[ + "rest", + "--method", + "PATCH", + "--url", + f"https://graph.microsoft.com/v1.0/applications/{app_object_id}", + "--body", + json.dumps({"groupMembershipClaims": "ApplicationGroup"}), + ] + ) + + # Configure optional claims (groups in ID and access tokens) + logger.info("Adding 'groups' optional claim to ID and access tokens") + optional_claims_body = { + "optionalClaims": { + "idToken": [{"name": "groups", "essential": False}], + "accessToken": [{"name": "groups", "essential": False}], + } + } + run_az( + args=[ + "rest", + "--method", + "PATCH", + "--url", + f"https://graph.microsoft.com/v1.0/applications/{app_object_id}", + "--body", + json.dumps(optional_claims_body), + ] + ) + + # Restrict token issuance to assigned users/groups + logger.info("Restricting token issuance to assigned users/groups") + run_az(args=["ad", "sp", "update", "--id", sp_id, "--set", "appRoleAssignmentRequired=true"]) + + return { + "app_id": app_id, + "app_object_id": app_object_id, + "tenant_id": tenant_id, + "sp_id": sp_id, + } + + +def assign_groups_to_app(*, sp_id: str, group_ids: list[str]) -> None: + """ + Assign Entra security groups to the enterprise app. + + Args: + sp_id (str): The service principal object ID. + group_ids (list[str]): List of group object IDs to assign. + """ + for group_id in group_ids: + logger.info("Assigning group %s to enterprise app %s", group_id, sp_id) + body = { + "principalId": group_id, + "resourceId": sp_id, + "appRoleId": "00000000-0000-0000-0000-000000000000", + } + run_az( + args=[ + "rest", + "--method", + "POST", + "--url", + f"https://graph.microsoft.com/v1.0/servicePrincipals/{sp_id}/appRoleAssignments", + "--body", + json.dumps(body), + ] + ) + + +def create_sql_server_and_db( + *, + resource_group: str, + location: str, + server_name: str, + database_name: str, +) -> dict: + """ + Create an Azure SQL server with Entra-only auth and a database. + + Args: + resource_group (str): The resource group name. + location (str): The Azure region. + server_name (str): The SQL server name. + database_name (str): The database name. + + Returns: + dict: A dict with keys 'server_fqdn' and 'database_name'. + """ + # Get current user for Entra admin + current_user = run_az_json( + args=[ + "ad", + "signed-in-user", + "show", + "--query", + "{displayName:displayName, id:id}", + ] + ) + + logger.info("Creating SQL server: %s (Entra admin: %s)", server_name, current_user["displayName"]) + run_az( + args=[ + "sql", + "server", + "create", + "--name", + server_name, + "--resource-group", + resource_group, + "--location", + location, + "--enable-ad-only-auth", + "--external-admin-principal-type", + "User", + "--external-admin-name", + current_user["displayName"], + "--external-admin-sid", + current_user["id"], + ] + ) + + server_fqdn = run_az_json( + args=[ + "sql", + "server", + "show", + "--name", + server_name, + "--resource-group", + resource_group, + "--query", + "fullyQualifiedDomainName", + ] + ) + + logger.info("Creating database: %s on server %s", database_name, server_name) + run_az( + args=[ + "sql", + "db", + "create", + "--name", + database_name, + "--resource-group", + resource_group, + "--server", + server_name, + "--edition", + "Basic", + "--capacity", + "5", + ] + ) + + # Allow Azure services to access the SQL server + logger.info("Allowing Azure services to access SQL server") + run_az( + args=[ + "sql", + "server", + "firewall-rule", + "create", + "--resource-group", + resource_group, + "--server", + server_name, + "--name", + "AllowAzureServices", + "--start-ip-address", + "0.0.0.0", + "--end-ip-address", + "0.0.0.0", + ] + ) + + return {"server_fqdn": server_fqdn, "database_name": database_name} + + +def create_key_vault( + *, + resource_group: str, + location: str, + vault_name: str, + env_file: Path, +) -> str: + """ + Create a Key Vault and populate it with the .env secret. + + Args: + resource_group (str): The resource group name. + location (str): The Azure region. + vault_name (str): The Key Vault name. + env_file (Path): Path to the .env file to upload. + + Returns: + str: The Key Vault resource ID. + """ + logger.info("Creating Key Vault: %s", vault_name) + run_az( + args=[ + "keyvault", + "create", + "--name", + vault_name, + "--resource-group", + resource_group, + "--location", + location, + "--enable-rbac-authorization", + "true", + "--enable-purge-protection", + "true", + ] + ) + + kv_id = run_az_json( + args=[ + "keyvault", + "show", + "--name", + vault_name, + "--query", + "id", + ] + ) + + # Grant current user Secrets Officer so we can write the secret + current_user_id = run_az_json( + args=[ + "ad", + "signed-in-user", + "show", + "--query", + "id", + ] + ) + logger.info("Granting Key Vault Secrets Officer to current user") + run_az( + args=[ + "role", + "assignment", + "create", + "--assignee-object-id", + current_user_id, + "--assignee-principal-type", + "User", + "--role", + "Key Vault Secrets Officer", + "--scope", + kv_id, + ] + ) + + logger.info("Uploading .env to Key Vault secret: env-global") + # RBAC propagation can take up to a few minutes on a fresh vault. + # Retry with backoff to handle the race condition. + max_retries = 6 + for attempt in range(1, max_retries + 1): + result = run_az( + args=[ + "keyvault", + "secret", + "set", + "--vault-name", + vault_name, + "--name", + "env-global", + "--file", + str(env_file), + ], + check=False, + ) + if result.returncode == 0: + break + if attempt < max_retries: + wait = 10 * attempt + logger.warning( + "KV secret write failed (attempt %d/%d) — RBAC may still be propagating. Retrying in %ds...", + attempt, + max_retries, + wait, + ) + time.sleep(wait) + else: + logger.error("KV secret write failed after %d attempts. stderr: %s", max_retries, result.stderr) + raise subprocess.CalledProcessError(result.returncode, result.args) + + return kv_id + + +def deploy_bicep( + *, + resource_group: str, + app_name: str, + container_image: str, + tenant_id: str, + client_id: str, + group_ids: str, + sql_server_fqdn: str, + sql_database_name: str, + kv_resource_id: str, + acr_name: str, +) -> dict: + """ + Deploy the Bicep template. + + Args: + resource_group (str): The resource group name. + app_name (str): The Container App name. + container_image (str): The container image reference. + tenant_id (str): The Entra tenant ID. + client_id (str): The Entra app registration client ID. + group_ids (str): Comma-separated group object IDs. + sql_server_fqdn (str): The SQL server FQDN. + sql_database_name (str): The SQL database name. + kv_resource_id (str): The Key Vault resource ID. + acr_name (str): The ACR name. + + Returns: + dict: The deployment outputs. + """ + logger.info("Deploying Bicep template to resource group: %s", resource_group) + return run_az_json( + args=[ + "deployment", + "group", + "create", + "--resource-group", + resource_group, + "--template-file", + str(BICEP_TEMPLATE), + "--parameters", + f"appName={app_name}", + f"containerImage={container_image}", + f"entraTenantId={tenant_id}", + f"entraClientId={client_id}", + f"allowedGroupObjectIds={group_ids}", + f"sqlServerFqdn={sql_server_fqdn}", + f"sqlDatabaseName={sql_database_name}", + f"keyVaultResourceId={kv_resource_id}", + f"acrName={acr_name}", + "enablePrivateEndpoint=false", + "--query", + "properties.outputs", + ] + ) + + +def post_deploy( + *, + app_id: str, + app_object_id: str, + fqdn: str, + mi_principal_id: str, + acr_name: str, + kv_resource_id: str, +) -> None: + """ + Run post-deployment steps: SPA redirect URI, RBAC role assignments. + + Args: + app_id (str): The Entra app registration client ID. + app_object_id (str): The Entra app registration object ID (for Graph API). + fqdn (str): The deployed app FQDN. + mi_principal_id (str): The managed identity principal ID. + acr_name (str): The ACR name for AcrPull role. + kv_resource_id (str): The Key Vault resource ID for Secrets User role. + """ + # Set SPA redirect URI via Graph REST API (more portable than --spa-redirect-uris flag) + logger.info("Setting SPA redirect URI: https://%s", fqdn) + spa_body = {"spa": {"redirectUris": [f"https://{fqdn}"]}} + run_az( + args=[ + "rest", + "--method", + "PATCH", + "--url", + f"https://graph.microsoft.com/v1.0/applications/{app_object_id}", + "--body", + json.dumps(spa_body), + ] + ) + + # Grant AcrPull + acr_id = run_az_json( + args=[ + "acr", + "show", + "--name", + acr_name, + "--query", + "id", + ] + ) + logger.info("Granting AcrPull to managed identity on ACR: %s", acr_name) + run_az( + args=[ + "role", + "assignment", + "create", + "--assignee-object-id", + mi_principal_id, + "--assignee-principal-type", + "ServicePrincipal", + "--role", + "AcrPull", + "--scope", + acr_id, + ] + ) + + # Grant Key Vault Secrets User + logger.info("Granting Key Vault Secrets User to managed identity") + run_az( + args=[ + "role", + "assignment", + "create", + "--assignee-object-id", + mi_principal_id, + "--assignee-principal-type", + "ServicePrincipal", + "--role", + "Key Vault Secrets User", + "--scope", + kv_resource_id, + ] + ) + + +def parse_args(args: list[str] | None = None) -> argparse.Namespace: + """ + Parse command-line arguments. + + Args: + args (list[str] | None): Arguments to parse. Defaults to sys.argv. + + Returns: + argparse.Namespace: The parsed arguments. + """ + parser = argparse.ArgumentParser( + description="Deploy an isolated CoPyRIT GUI instance.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--instance-name", + required=True, + help="Name for this instance (used in resource naming, e.g., 'partners-demo')", + ) + parser.add_argument( + "--env-file", + required=True, + type=Path, + help="Path to the .env file containing target endpoints and secrets", + ) + parser.add_argument( + "--subscription", + required=True, + help="Azure subscription name or ID", + ) + parser.add_argument( + "--location", + default="eastus2", + help="Azure region (default: eastus2)", + ) + parser.add_argument( + "--acr-name", + required=True, + help="Shared ACR name (the image must already be pushed)", + ) + parser.add_argument( + "--container-image", + required=True, + help="Container image reference (e.g., myacr.azurecr.io/pyrit:abc1234)", + ) + parser.add_argument( + "--allowed-groups", + required=True, + help="Comma-separated Entra group object IDs to grant access", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be done without executing", + ) + return parser.parse_args(args) + + +def main(args: list[str] | None = None) -> int: + """ + Main entry point for the deployment script. + + Args: + args (list[str] | None): CLI arguments. Defaults to sys.argv. + + Returns: + int: Exit code (0 for success). + """ + parsed = parse_args(args) + + instance = parsed.instance_name + env_file = parsed.env_file.resolve() + + if not env_file.exists(): + logger.error("Env file not found: %s", env_file) + return 1 + + if not BICEP_TEMPLATE.exists(): + logger.error("Bicep template not found: %s", BICEP_TEMPLATE) + return 1 + + # Derive resource names from instance name + rg_name = f"copyrit-{instance}" + app_name = f"copyrit-{instance}" + sql_server_name = f"copyrit-{instance}-sql" + sql_db_name = f"pyrit-{instance}" + kv_name = f"copyrit-{instance}-kv" + entra_app_name = f"CoPyRIT GUI ({instance})" + group_ids = [g.strip() for g in parsed.allowed_groups.split(",") if g.strip()] + + # Validate Azure resource name length constraints + if len(kv_name) > 24: + logger.error( + "Key Vault name '%s' exceeds 24-char limit (got %d). Use a shorter --instance-name (max 13 characters).", + kv_name, + len(kv_name), + ) + return 1 + if len(app_name) > 32: + logger.error( + "Container App name '%s' exceeds 32-char limit (got %d). " + "Use a shorter --instance-name (max 24 characters).", + app_name, + len(app_name), + ) + return 1 + + if parsed.dry_run: + logger.info("=== DRY RUN ===") + logger.info("Instance name: %s", instance) + logger.info("Resource group: %s", rg_name) + logger.info("App name: %s", app_name) + logger.info("SQL server: %s", sql_server_name) + logger.info("SQL database: %s", sql_db_name) + logger.info("Key Vault: %s", kv_name) + logger.info("Entra app: %s", entra_app_name) + logger.info("Allowed groups: %s", group_ids) + logger.info("Env file: %s", env_file) + logger.info("Container image: %s", parsed.container_image) + logger.info("ACR: %s", parsed.acr_name) + logger.info("Location: %s", parsed.location) + logger.info("Subscription: %s", parsed.subscription) + return 0 + + try: + # Step 1: Set subscription + set_subscription(parsed.subscription) + + # Step 2: Create resource group + create_resource_group(name=rg_name, location=parsed.location) + + # Step 3: Create Entra app registration + entra = create_entra_app(display_name=entra_app_name) + + # Step 4: Assign groups to enterprise app + assign_groups_to_app(sp_id=entra["sp_id"], group_ids=group_ids) + + # Step 5: Create SQL server + database + sql = create_sql_server_and_db( + resource_group=rg_name, + location=parsed.location, + server_name=sql_server_name, + database_name=sql_db_name, + ) + + # Step 6: Create Key Vault + upload .env + kv_id = create_key_vault( + resource_group=rg_name, + location=parsed.location, + vault_name=kv_name, + env_file=env_file, + ) + + # Step 7: Deploy Bicep + outputs = deploy_bicep( + resource_group=rg_name, + app_name=app_name, + container_image=parsed.container_image, + tenant_id=entra["tenant_id"], + client_id=entra["app_id"], + group_ids=parsed.allowed_groups, + sql_server_fqdn=sql["server_fqdn"], + sql_database_name=sql["database_name"], + kv_resource_id=kv_id, + acr_name=parsed.acr_name, + ) + + fqdn = outputs["appFqdn"]["value"] + mi_principal_id = outputs["managedIdentityPrincipalId"]["value"] + + # Step 8: Post-deploy (SPA redirect, RBAC) + post_deploy( + app_id=entra["app_id"], + app_object_id=entra["app_object_id"], + fqdn=fqdn, + mi_principal_id=mi_principal_id, + acr_name=parsed.acr_name, + kv_resource_id=kv_id, + ) + + # Summary + logger.info("") + logger.info("=" * 60) + logger.info("DEPLOYMENT COMPLETE") + logger.info("=" * 60) + logger.info("Instance: %s", instance) + logger.info("URL: https://%s", fqdn) + logger.info("Resource group: %s", rg_name) + logger.info("Entra app ID: %s", entra["app_id"]) + logger.info("SQL server: %s", sql["server_fqdn"]) + logger.info("SQL database: %s", sql["database_name"]) + logger.info("Key Vault: %s", kv_name) + logger.info("") + logger.info("MANUAL STEPS REQUIRED:") + logger.info(" 1. Create SQL contained user:") + logger.info(" Connect to %s as Entra admin and run:", sql["server_fqdn"]) + logger.info(" CREATE USER [%s-identity] FROM EXTERNAL PROVIDER;", app_name) + logger.info(" ALTER ROLE db_datareader ADD MEMBER [%s-identity];", app_name) + logger.info(" ALTER ROLE db_datawriter ADD MEMBER [%s-identity];", app_name) + logger.info(" 2. Add users to the Entra security group(s)") + logger.info(" 3. Grant Cognitive Services roles if using MI-auth for AOAI:") + logger.info(" az role assignment create --assignee-object-id %s \\", mi_principal_id) + logger.info(" --assignee-principal-type ServicePrincipal \\") + logger.info(" --role 'Cognitive Services OpenAI User' --scope ") + logger.info("") + logger.info("Restart the container app after completing step 1:") + logger.info(" az containerapp revision restart -n %s -g %s \\", app_name, rg_name) + logger.info(" --revision $(az containerapp show -n %s -g %s \\", app_name, rg_name) + logger.info(" --query properties.latestRevisionName -o tsv)") + logger.info("=" * 60) + + return 0 + + except subprocess.CalledProcessError as e: + logger.error("Command failed (exit code %d): %s", e.returncode, " ".join(e.cmd)) + if e.stderr: + logger.error("stderr: %s", e.stderr.strip()) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/infra/env.demo.template b/infra/env.demo.template new file mode 100644 index 000000000..8726beebe --- /dev/null +++ b/infra/env.demo.template @@ -0,0 +1,75 @@ +# CoPyRIT GUI — Demo Instance .env Template +# +# Copy this file, fill in your values, and pass it to deploy_instance.py: +# cp infra/env.demo.template my-demo.env +# # Edit my-demo.env with real endpoints/keys +# python infra/deploy_instance.py --instance-name my-demo --env-file my-demo.env ... +# +# The deployment script uploads this as a Key Vault secret. The container +# reads it via PYRIT_ENV_CONTENTS and writes it to ~/.pyrit/.env at startup. +# +# Endpoints don't need to match the AIRT instance — point them at whatever +# models make sense for your demo audience. +# +# See pyrit/setup/initializers/components/targets.py for all supported env vars. + +# ─── Chat Target (required — at least one chat model for the GUI to be useful) ─── +AZURE_OPENAI_GPT4O_ENDPOINT=https://YOUR_ENDPOINT.openai.azure.com/ +AZURE_OPENAI_GPT4O_KEY= +AZURE_OPENAI_GPT4O_MODEL=YOUR_DEPLOYMENT_NAME +AZURE_OPENAI_GPT4O_UNDERLYING_MODEL=gpt-4o + +# ─── Unsafe Chat (required for converters via airt initializer) ─── +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_ENDPOINT=https://YOUR_UNSAFE_ENDPOINT.openai.azure.com/ +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_KEY= +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_MODEL=YOUR_UNSAFE_DEPLOYMENT_NAME +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_UNDERLYING_MODEL=gpt-4o + +# ─── Unsafe Chat 2 (required for scoring via airt initializer) ─── +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_ENDPOINT2=https://YOUR_UNSAFE_ENDPOINT2.openai.azure.com/ +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_KEY2= +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_MODEL2=YOUR_UNSAFE_DEPLOYMENT_NAME2 +AZURE_OPENAI_GPT4O_UNSAFE_CHAT_UNDERLYING_MODEL2=gpt-4o + +# ─── Content Safety (required for harm detection via airt initializer) ─── +AZURE_CONTENT_SAFETY_API_ENDPOINT=https://YOUR_CONTENT_SAFETY.cognitiveservices.azure.com/ +AZURE_CONTENT_SAFETY_API_KEY= + +# ─── Image Target (optional — for image generation demos) ─── +# OPENAI_IMAGE_ENDPOINT1=https://YOUR_IMAGE_ENDPOINT.openai.azure.com/ +# OPENAI_IMAGE_API_KEY1= +# OPENAI_IMAGE_MODEL1=dall-e-3 +# OPENAI_IMAGE_UNDERLYING_MODEL1=dall-e-3 + +# ─── TTS Target (optional — for text-to-speech demos) ─── +# OPENAI_TTS_ENDPOINT1=https://YOUR_TTS_ENDPOINT.openai.azure.com/ +# OPENAI_TTS_KEY1= +# OPENAI_TTS_MODEL1=tts-1 +# OPENAI_TTS_UNDERLYING_MODEL1=tts-1 + +# ─── Video Target (optional — for video generation demos) ─── +# AZURE_OPENAI_VIDEO_ENDPOINT=https://YOUR_VIDEO_ENDPOINT.openai.azure.com/ +# AZURE_OPENAI_VIDEO_KEY= +# AZURE_OPENAI_VIDEO_MODEL=sora-2 +# AZURE_OPENAI_VIDEO_UNDERLYING_MODEL=sora-2 + +# ─── Responses Target (optional — for o4-mini / reasoning demos) ─── +# AZURE_OPENAI_RESPONSES_ENDPOINT=https://YOUR_RESPONSES_ENDPOINT.openai.azure.com/ +# AZURE_OPENAI_RESPONSES_KEY= +# AZURE_OPENAI_RESPONSES_MODEL=o4-mini +# AZURE_OPENAI_RESPONSES_UNDERLYING_MODEL=o4-mini + +# ─── Realtime Target (optional — for realtime API demos) ─── +# AZURE_OPENAI_REALTIME_ENDPOINT=https://YOUR_REALTIME_ENDPOINT.openai.azure.com/ +# AZURE_OPENAI_REALTIME_API_KEY= +# AZURE_OPENAI_REALTIME_MODEL=gpt-4o-realtime +# AZURE_OPENAI_REALTIME_UNDERLYING_MODEL=gpt-4o-realtime + +# ─── Database (required by airt initializer and AzureSQLMemory) ─── +# Bicep sets AZURE_SQL_SERVER and AZURE_SQL_DATABASE as container env vars, +# but AzureSQLMemory reads the connection string from AZURE_SQL_DB_CONNECTION_STRING. +# Construct this from the SQL server and database created by the deploy script. +AZURE_SQL_DB_CONNECTION_STRING=mssql+pyodbc://@YOUR_SQL_SERVER.database.windows.net/YOUR_DATABASE?driver=ODBC+Driver+18+for+SQL+Server + +# ─── Storage (required by airt initializer for blob-backed data persistence) ─── +AZURE_STORAGE_ACCOUNT_DB_DATA_CONTAINER_URL=https://YOUR_STORAGE.blob.core.windows.net/dbdata diff --git a/infra/parameters.demo.json b/infra/parameters.demo.json new file mode 100644 index 000000000..65fdf8576 --- /dev/null +++ b/infra/parameters.demo.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "_usage": "This template is for manual 'az deployment group create' runs. The deploy_instance.py script passes parameters directly via CLI args and does not use this file.", + "parameters": { + "appName": { + "value": "copyrit-INSTANCE_NAME" + }, + "containerImage": { + "value": "REPLACE_ACR.azurecr.io/pyrit:REPLACE_COMMIT_SHA" + }, + "entraTenantId": { + "value": "REPLACE_TENANT_ID" + }, + "entraClientId": { + "value": "REPLACE_ENTRA_CLIENT_ID" + }, + "allowedGroupObjectIds": { + "value": "REPLACE_GROUP_OBJECT_IDS" + }, + "allowedCidr": { + "value": "" + }, + "sqlServerFqdn": { + "value": "REPLACE_SQL_SERVER.database.windows.net" + }, + "sqlDatabaseName": { + "value": "REPLACE_SQL_DATABASE" + }, + "pyritInitializer": { + "value": "target airt" + }, + "envSecretName": { + "value": "env-global" + }, + "logRetentionDays": { + "value": 90 + }, + "enableOtel": { + "value": false + }, + "enablePrivateEndpoint": { + "value": false + }, + "tags": { + "value": { + "Service": "pyrit-gui", + "Owner": "AI Red Team", + "DataClass": "Confidential", + "Instance": "INSTANCE_NAME" + } + }, + "acrName": { + "value": "REPLACE_ACR_NAME" + }, + "keyVaultResourceId": { + "value": "REPLACE_KEY_VAULT_RESOURCE_ID" + }, + "infrastructureSubnetId": { + "value": "" + }, + "logAnalyticsWorkspaceId": { + "value": "" + }, + "logAnalyticsCustomerId": { + "value": "" + }, + "logAnalyticsSharedKey": { + "value": "" + } + } +} diff --git a/infra/teardown_instance.py b/infra/teardown_instance.py new file mode 100644 index 000000000..8c4719d2a --- /dev/null +++ b/infra/teardown_instance.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +CoPyRIT GUI — Tear down an isolated instance. + +Removes all Azure resources for an instance deployed by deploy_instance.py. +Entra resources (app registration, service principal) must be deleted separately +since they live outside the resource group. + +Usage: + python infra/teardown_instance.py --instance-name partners-demo \\ + --subscription "AI Red Team Tooling" + + # Include Entra cleanup: + python infra/teardown_instance.py --instance-name partners-demo \\ + --subscription "AI Red Team Tooling" --delete-entra-app + +""" + +import argparse +import json +import logging +import subprocess +import sys + +logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") +logger = logging.getLogger(__name__) + + +def run_az( + *, + args: list[str], + capture: bool = True, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + """ + Run an Azure CLI command. + + Args: + args (list[str]): The az CLI arguments (without the leading 'az'). + capture (bool): Whether to capture stdout/stderr. Defaults to True. + check (bool): Whether to raise on non-zero exit. Defaults to True. + + Returns: + subprocess.CompletedProcess[str]: The completed process. + + Raises: + subprocess.CalledProcessError: If the command fails and check is True. + """ + cmd = ["az"] + args + logger.debug("Running: %s", " ".join(cmd)) + return subprocess.run( + cmd, + capture_output=capture, + text=True, + check=check, + ) + + +def run_az_json(*, args: list[str]) -> dict | list | str | None: + """ + Run an Azure CLI command and parse JSON output. + + Args: + args (list[str]): The az CLI arguments (without the leading 'az'). + + Returns: + dict | list | str | None: The parsed JSON output, or None on failure. + """ + result = run_az(args=args + ["-o", "json"], check=False) + if result.returncode != 0: + return None + return json.loads(result.stdout) + + +def parse_args(args: list[str] | None = None) -> argparse.Namespace: + """ + Parse command-line arguments. + + Args: + args (list[str] | None): Arguments to parse. Defaults to sys.argv. + + Returns: + argparse.Namespace: The parsed arguments. + """ + parser = argparse.ArgumentParser( + description="Tear down an isolated CoPyRIT GUI instance.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--instance-name", + required=True, + help="Instance name (must match the name used during deployment)", + ) + parser.add_argument( + "--subscription", + required=True, + help="Azure subscription name or ID", + ) + parser.add_argument( + "--delete-entra-app", + action="store_true", + help="Also delete the Entra app registration and service principal", + ) + parser.add_argument( + "--yes", + action="store_true", + help="Skip confirmation prompt", + ) + return parser.parse_args(args) + + +def main(args: list[str] | None = None) -> int: + """ + Main entry point for the teardown script. + + Args: + args (list[str] | None): CLI arguments. Defaults to sys.argv. + + Returns: + int: Exit code (0 for success). + """ + parsed = parse_args(args) + + instance = parsed.instance_name + rg_name = f"copyrit-{instance}" + entra_app_name = f"CoPyRIT GUI ({instance})" + + logger.info("Instance: %s", instance) + logger.info("Resource group: %s", rg_name) + if parsed.delete_entra_app: + logger.info("Entra app: %s (will be deleted)", entra_app_name) + + if not parsed.yes: + confirm = input(f"\nDelete resource group '{rg_name}' and all its resources? [y/N] ") + if confirm.lower() != "y": + logger.info("Aborted.") + return 0 + + try: + # Set subscription + logger.info("Setting subscription to: %s", parsed.subscription) + run_az(args=["account", "set", "--subscription", parsed.subscription]) + + # Delete resource group (and all Azure resources in it) + logger.info("Deleting resource group: %s (this may take several minutes)", rg_name) + run_az(args=["group", "delete", "--name", rg_name, "--yes", "--no-wait"]) + logger.info("Resource group deletion initiated (running in background)") + + # Delete Entra app registration if requested + if parsed.delete_entra_app: + logger.info("Looking up Entra app: %s", entra_app_name) + app_info = run_az_json( + args=[ + "ad", + "app", + "list", + "--display-name", + entra_app_name, + "--query", + "[0].appId", + ] + ) + + if app_info: + logger.info("Deleting Entra app registration: %s", app_info) + run_az(args=["ad", "app", "delete", "--id", app_info]) + logger.info("Entra app deleted") + else: + logger.warning("Entra app '%s' not found — skipping", entra_app_name) + + logger.info("") + logger.info("=" * 60) + logger.info("TEARDOWN COMPLETE") + logger.info("=" * 60) + logger.info("Resource group '%s' is being deleted.", rg_name) + logger.info("This includes: Container App, SQL server, Key Vault, MI, networking, logs.") + logger.info("") + logger.info("Note: Key Vault uses purge protection. The vault name '%s'", f"copyrit-{instance}-kv") + logger.info("will be reserved for ~90 days after deletion.") + logger.info("=" * 60) + + return 0 + + except subprocess.CalledProcessError as e: + logger.error("Command failed (exit code %d): %s", e.returncode, " ".join(e.cmd)) + if e.stderr: + logger.error("stderr: %s", e.stderr.strip()) + return 1 + + +if __name__ == "__main__": + sys.exit(main())