#!/usr/bin/env bash # Entra spike setup for entra_spike.py (#551 re-scope boundary spike). # Manual test tooling — not run in CI. Creates throwaway Entra app registrations. # # ./entra_setup.sh setup create app registrations + consent + .env # ./entra_setup.sh cleanup delete everything it created (incl. .env) # # Creates in the logged-in tenant (az login first): # spike-turnstone confidential client (stands in for Turnstone's OIDC app) # spike-mcp-a/b resource apps exposing scope mcp.access, admin-consented # spike-mcp-c resource app with NO grant to the client (V5 control) # Requires: the logged-in user can create apps + grant admin consent # (Global Admin on a personal tenant qualifies). set -euo pipefail cd "$(dirname "$0")" ENV_FILE=".env" NAMES=(spike-turnstone spike-mcp-a spike-mcp-b spike-mcp-c) log() { printf '>> %s\n' "$*"; } graph_patch_api() { # $1=appId $2=scope-uuid $3=display-name local obj_id obj_id=$(az ad app show --id "$1" --query id -o tsv) az rest --method PATCH \ --url "https://graph.microsoft.com/v1.0/applications/${obj_id}" \ --headers 'Content-Type=application/json' \ --body "{ \"identifierUris\": [\"api://$1\"], \"api\": { \"requestedAccessTokenVersion\": 2, \"oauth2PermissionScopes\": [{ \"id\": \"$2\", \"value\": \"mcp.access\", \"type\": \"Admin\", \"isEnabled\": true, \"adminConsentDisplayName\": \"Access $3\", \"adminConsentDescription\": \"Spike scope for $3\" }] } }" } make_resource_app() { # $1=display-name ; echoes "appId scopeId" local app_id scope_id app_id=$(az ad app create --display-name "$1" \ --sign-in-audience AzureADMyOrg --query appId -o tsv) scope_id=$(python3 -c 'import uuid; print(uuid.uuid4())') graph_patch_api "$app_id" "$scope_id" "$1" >/dev/null az ad sp create --id "$app_id" >/dev/null 2>&1 || true echo "$app_id $scope_id" } cmd_setup() { local tenant_id tenant_id=$(az account show --query tenantId -o tsv) log "tenant: ${tenant_id}" log "creating resource apps (a, b, c)..." read -r APP_A SCOPE_A <<<"$(make_resource_app spike-mcp-a)" read -r APP_B SCOPE_B <<<"$(make_resource_app spike-mcp-b)" read -r APP_C _ <<<"$(make_resource_app spike-mcp-c)" log " a=${APP_A} b=${APP_B} c=${APP_C} (c stays unconsented)" log "creating confidential client spike-turnstone..." CLIENT_ID=$(az ad app create --display-name spike-turnstone \ --sign-in-audience AzureADMyOrg \ --web-redirect-uris "http://localhost:8765/callback" \ --query appId -o tsv) az ad sp create --id "$CLIENT_ID" >/dev/null 2>&1 || true # No stderr suppression here: the secret is load-bearing (it lands in .env), # so under `set -e` a reset failure must abort LOUDLY, not silently. SECRET=$(az ad app credential reset --id "$CLIENT_ID" \ --display-name spike --years 1 --query password -o tsv) log "adding delegated permissions (a, b — NOT c)..." # Tolerated failures (|| log): a re-run hits "permission already exists" and # SP-propagation delays are common right after app creation — the # admin-consent retry loop below is the real gate. `set -e` would otherwise # turn a suppressed non-zero here into a silent mid-script abort. az ad app permission add --id "$CLIENT_ID" \ --api "$APP_A" --api-permissions "${SCOPE_A}=Scope" \ || log " warn: permission add for a failed (may already exist); admin-consent below will confirm" az ad app permission add --id "$CLIENT_ID" \ --api "$APP_B" --api-permissions "${SCOPE_B}=Scope" \ || log " warn: permission add for b failed (may already exist); admin-consent below will confirm" log "granting admin consent (retries while SPs propagate)..." local ok="" for i in 1 2 3 4 5; do if az ad app permission admin-consent --id "$CLIENT_ID" 2>/dev/null; then ok=1; break fi log " not yet (attempt $i) — waiting 15s" sleep 15 done [ -n "$ok" ] || { log "admin-consent failed after retries — grant manually in the portal (API permissions blade) and re-run the spike"; } # Single-quote the values in the generated .env: the AS-issued client secret # can contain $ / backtick, and an unquoted RHS would be re-expanded (or # partially executed) when the operator `source`s the file. The heredoc still # interpolates ${...} into the single-quoted output; sourcing then treats the # result literally. (Azure secrets are base64-ish — no single quotes to escape.) umask 177 cat > "$ENV_FILE" <