diff --git a/README.md b/README.md index 6aacc32e..0f6fa929 100644 --- a/README.md +++ b/README.md @@ -272,8 +272,13 @@ Follow the quick deploy steps on the deployment guide to deploy this solution to > **Note:** This solution accelerator requires **Azure Developer CLI (azd) version 1.18.0 or higher**. Please ensure you have the latest version installed before proceeding with deployment. [Download azd here](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd). +> **Note:** This solution accelerator also requires **Bicep CLI version 0.33.0 or higher** for compiling infrastructure templates. [Install Bicep](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install). + [Click here to launch the deployment guide](./docs/DeploymentGuide.md) +> ⚠️ **Post-Deployment Setup Required** +> `azd up` provisions infrastructure only. After it completes, run the post-deployment scripts to register schemas, upload sample data, and configure authentication. See [Step 5 of the Deployment Guide](./docs/DeploymentGuide.md#step-5-post-deployment-configuration) for commands and required permissions. + | [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/microsoft/content-processing-solution-accelerator) | [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/microsoft/content-processing-solution-accelerator) | [![Open in Visual Studio Code Web](https://img.shields.io/static/v1?style=for-the-badge&label=Visual%20Studio%20Code%20(Web)&message=Open&color=blue&logo=visualstudiocode&logoColor=white)](https://vscode.dev/azure/?vscode-azure-exp=foundry&agentPayload=eyJiYXNlVXJsIjogImh0dHBzOi8vcmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbS9taWNyb3NvZnQvY29udGVudC1wcm9jZXNzaW5nLXNvbHV0aW9uLWFjY2VsZXJhdG9yL3JlZnMvaGVhZHMvbWFpbi9pbmZyYS92c2NvZGVfd2ViIiwgImluZGV4VXJsIjogIi9pbmRleC5qc29uIiwgInZhcmlhYmxlcyI6IHsiYWdlbnRJZCI6ICIiLCAiY29ubmVjdGlvblN0cmluZyI6ICIiLCAidGhyZWFkSWQiOiAiIiwgInVzZXJNZXNzYWdlIjogIiIsICJwbGF5Z3JvdW5kTmFtZSI6ICIiLCAibG9jYXRpb24iOiAiIiwgInN1YnNjcmlwdGlvbklkIjogIiIsICJyZXNvdXJjZUlkIjogIiIsICJwcm9qZWN0UmVzb3VyY2VJZCI6ICIiLCAiZW5kcG9pbnQiOiAiIn0sICJjb2RlUm91dGUiOiBbImFpLXByb2plY3RzLXNkayIsICJweXRob24iLCAiZGVmYXVsdC1henVyZS1hdXRoIiwgImVuZHBvaW50Il19) | |---|---|---| diff --git a/azure.yaml b/azure.yaml index 3f34cb0e..adeb9752 100644 --- a/azure.yaml +++ b/azure.yaml @@ -9,14 +9,3 @@ requiredVersions: metadata: template: content-processing@1.0 name: content-processinge@1.0 - -hooks: - postprovision: - posix: - shell: sh - run: sed -i 's/\r$//' ./infra/scripts/post_deployment.sh; bash ./infra/scripts/post_deployment.sh - interactive: true - windows: - shell: pwsh - run: ./infra/scripts/post_deployment.ps1 - interactive: true diff --git a/azure_custom.yaml b/azure_custom.yaml index 56253c7f..de2868a4 100644 --- a/azure_custom.yaml +++ b/azure_custom.yaml @@ -64,13 +64,3 @@ services: registry: ${AZURE_CONTAINER_REGISTRY_ENDPOINT} remoteBuild: true -hooks: - postprovision: - posix: - shell: sh - run: sed -i 's/\r$//' ./infra/scripts/post_deployment.sh; bash ./infra/scripts/post_deployment.sh - interactive: true - windows: - shell: pwsh - run: ./infra/scripts/post_deployment.ps1 - interactive: true diff --git a/docs/AVMPostDeploymentGuide.md b/docs/AVMPostDeploymentGuide.md index e0a1fe0b..792dabb2 100644 --- a/docs/AVMPostDeploymentGuide.md +++ b/docs/AVMPostDeploymentGuide.md @@ -11,9 +11,10 @@ This document provides guidance on post-deployment steps after deploying the Con After successfully deploying the Content Processing Solution Accelerator using the AVM template, you need to: 1. **Register schemas** — upload schema files, create a schema set, and link them together -2. **Configure authentication** — set up app registration for secure access +2. **Process sample files** — upload and process sample claim bundles for verification +3. **Configure authentication** — set up app registration for secure access -> **Note:** When deploying via `azd up`, schema registration happens automatically through a post-provisioning hook. AVM deployments require the manual steps below. +> **Note:** Post-deployment data setup and authentication are manual steps for both `azd` and AVM deployments. Run the scripts in this guide after infrastructure provisioning. ## Prerequisites @@ -73,14 +74,27 @@ The script is idempotent — it skips schemas and schema sets that already exist > **Want custom schemas?** See [Customize Schema Data](./CustomizeSchemaData.md) to create your own document schemas. -### Step 4: Configure Authentication (Required) +### Step 4: Process Sample File Bundles (Optional) + +After schema registration, you can upload and process the included sample claim bundles to verify the deployment is working end to end. Each sample folder (`claim_date_of_loss/`, `claim_hail/`) contains a `bundle_info.json` manifest that maps files to their schema classes. + +The workflow for each bundle: +1. **Create a claim batch** with the schema set ID via `PUT /claimprocessor/claims` +2. **Upload each file** with its mapped schema ID via `POST /claimprocessor/claims/{claim_id}/files` +3. **Submit the batch** for processing via `POST /claimprocessor/claims` + +You can perform these steps via the web UI or the API directly. See the [API documentation](./API.md) and [Golden Path Workflows](./GoldenPathWorkflows.md) for details. + +> **Note:** In `azd` and AVM flows, sample file processing runs when you execute the post-deployment script manually. + +### Step 5: Configure Authentication (Required) **This step is mandatory for application access:** 1. Follow [App Authentication Configuration](./ConfigureAppAuthentication.md). 2. Wait up to 10 minutes for authentication changes to take effect. -### Step 5: Verify Deployment +### Step 6: Verify Deployment 1. Access your application using the Web App URL from your deployment output. 2. Confirm the application loads successfully. diff --git a/docs/ConfigureAppAuthentication.md b/docs/ConfigureAppAuthentication.md index 8de1c105..78e25950 100644 --- a/docs/ConfigureAppAuthentication.md +++ b/docs/ConfigureAppAuthentication.md @@ -1,5 +1,18 @@ # Set up Authentication in Azure Container App +> ### ✅ Recommended: run the authentication script first +> +> `azd up` no longer runs authentication setup automatically. Run the script below after deployment: +> +> - Windows: `./infra/scripts/setup_auth.ps1` +> - macOS/Linux: `sed -i 's/\r$//' ./infra/scripts/setup_auth.sh && bash ./infra/scripts/setup_auth.sh` +> +> See [DeploymentGuide.md § 5.3](./DeploymentGuide.md#53-configure-authentication-manual-script) for details. +> +> Follow the portal/manual steps below if: +> - Your tenant policy prohibits programmatic app registration or secret creation +> - The script reports a permission or policy failure that cannot be resolved in your current identity + This document provides step-by-step instructions to configure Azure App Registrations for the front-end and back-end applications. > **Note:** The solution deploys four container apps. Only the **Web** and **API** container apps require Entra ID authentication provider configuration. The **Content Processor** (app) and **Content Process Workflow** (wkfl) containers are internal services that communicate via Storage Queues using managed identity — they do not expose public endpoints. diff --git a/docs/CustomizeSchemaData.md b/docs/CustomizeSchemaData.md index 0e3105d8..73e492e7 100644 --- a/docs/CustomizeSchemaData.md +++ b/docs/CustomizeSchemaData.md @@ -73,18 +73,18 @@ flowchart LR A new JSON Schema document needs to be created that defines the schema as a declarative description of your document type. -> **Schema Folder:** [/src/ContentProcessorAPI/samples/schemas/](/src/ContentProcessorAPI/samples/schemas/) — All schema JSON files should be placed into this folder +> **Schema Folder:** [../src/ContentProcessorAPI/samples/schemas/](../src/ContentProcessorAPI/samples/schemas/) — All schema JSON files should be placed into this folder **Sample Schemas:** The accelerator ships with 4 sample schemas — use any as a starting template: -| Schema | File | Class Name | Auto-registered | +| Schema | File | Class Name | Included sample | | ------------------------- | --------------------------------------------------------------------------------- | ------------------------------- | --------------- | -| Auto Insurance Claim Form | [autoclaim.json](/src/ContentProcessorAPI/samples/schemas/autoclaim.json) | `AutoInsuranceClaimForm` | ✅ | -| Police Report | [policereport.json](/src/ContentProcessorAPI/samples/schemas/policereport.json) | `PoliceReportDocument` | ✅ | -| Repair Estimate | [repairestimate.json](/src/ContentProcessorAPI/samples/schemas/repairestimate.json) | `RepairEstimateDocument` | ✅ | -| Damaged Vehicle Image | [damagedcarimage.json](/src/ContentProcessorAPI/samples/schemas/damagedcarimage.json) | `DamagedVehicleImageAssessment` | ✅ | +| Auto Insurance Claim Form | [autoclaim.json](../src/ContentProcessorAPI/samples/schemas/autoclaim.json) | `AutoInsuranceClaimForm` | ✅ | +| Police Report | [policereport.json](../src/ContentProcessorAPI/samples/schemas/policereport.json) | `PoliceReportDocument` | ✅ | +| Repair Estimate | [repairestimate.json](../src/ContentProcessorAPI/samples/schemas/repairestimate.json) | `RepairEstimateDocument` | ✅ | +| Damaged Vehicle Image | [damagedcarimage.json](../src/ContentProcessorAPI/samples/schemas/damagedcarimage.json) | `DamagedVehicleImageAssessment` | ✅ | -> **Note:** All 4 schemas are automatically registered during deployment (via `azd up` or the `register_schema.py` script) and grouped into the **"Auto Claim"** schema set. +> **Note:** These 4 schemas are included in the repository and are registered when you run the manual post-deployment schema registration step (for example, `register_schemas.ps1` / `register_schemas.sh`, or `run_post_deployment.ps1` / `run_post_deployment.sh`). They are then grouped into the **"Auto Claim"** schema set. Duplicate one of these files and update with fields that represent your document type. @@ -158,7 +158,7 @@ Example using the REST Client extension: > **Note:** Install the [REST Client VSCode extension](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) to execute `.http` files directly in VS Code. -> **Sample requests:** [/src/ContentProcessorAPI/test_http/invoke_APIs.http](/src/ContentProcessorAPI/test_http/invoke_APIs.http) +> **Sample requests:** [../src/ContentProcessorAPI/test_http/invoke_APIs.http](../src/ContentProcessorAPI/test_http/invoke_APIs.http) The response returns a Schema `Id` — **save this** for Step 3. @@ -166,14 +166,14 @@ The response returns a Schema `Id` — **save this** for Step 3. ### Option B: Register via script (batch) -> **Note:** The default sample schemas are registered **automatically** during `azd up` via the post-provisioning hook. You only need to run the script manually if you are adding custom schemas or if automatic registration was skipped. +> **Note:** Default sample schemas are registered when you run the post-deployment script manually (see Deployment Guide Step 5.1). Run this script again whenever you add or update schemas. For bulk registration, use the provided script with a JSON manifest. The script performs three steps automatically: 1. **Registers** individual schema files via `/schemavault/` 2. **Creates** a schema set via `/schemasetvault/` 3. **Adds** each registered schema into the schema set -**Manifest file** ([schema_info.json](/src/ContentProcessorAPI/samples/schemas/schema_info.json)): +**Manifest file** ([schema_info.json](../src/ContentProcessorAPI/samples/schemas/schema_info.json)): ```json { "schemas": [ diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 94c3d2f3..51a5ee4d 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -159,6 +159,7 @@ Select one of the following options to deploy the Content Processing Solution Ac **Required Tools:** - [PowerShell 7.0+](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell) - [Azure Developer CLI (azd) 1.18.0+](https://aka.ms/install-azd) +- [Bicep CLI 0.33.0+](https://learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install) - [Python 3.9+](https://www.python.org/downloads/) - [Docker Desktop](https://www.docker.com/products/docker-desktop/) - [Git](https://git-scm.com/downloads) @@ -300,85 +301,127 @@ azd up ## Step 5: Post-Deployment Configuration -### 5.1 Schema Registration (Automatic) +### 5.1 Run Schema Registration (Manual) - > Want to customize the schemas for your own documents? [Learn more about adding your own schemas here.](./CustomizeSchemaData.md) +> Want to customize the schemas for your own documents? [Learn more about adding your own schemas here.](./CustomizeSchemaData.md) -Schema registration happens **automatically** as part of the `azd up` post-provisioning hook — no manual steps required. After infrastructure is deployed, the hook: +`azd up` now provisions infrastructure and application containers only. Post-provision data setup is split into **separate manual steps** so you can run, retry, or skip them independently. -1. Waits for the API container app to be ready -2. Registers the sample schema files (auto claim, damaged car image, police report, repair estimate) -3. Creates an **"Auto Claim"** schema set -4. Adds all registered schemas into the schema set +Run schema registration first to: -After successful deployment, the terminal displays container app details and schema registration output: +1. Wait for the API container app to be ready +2. Register the sample schema files (auto claim, damaged car image, police report, repair estimate) +3. Create the **"Auto Claim"** schema set +4. Add all registered schemas into the schema set +**Windows (PowerShell):** + +```powershell +./infra/scripts/register_schemas.ps1 +``` + +**macOS/Linux:** + +```bash +if [ "$(uname)" = "Darwin" ]; then + sed -i '' 's/\r$//' ./infra/scripts/register_schemas.sh +else + sed -i 's/\r$//' ./infra/scripts/register_schemas.sh +fi +bash ./infra/scripts/register_schemas.sh +``` + +The script is idempotent and safe to re-run. + +### 5.2 Run Sample Data Upload (Manual) + +After schema registration completes, upload the sample bundles as a separate explicit step. This step: + +1. Resolves the existing **Auto Claim** schema set and registered schema IDs +2. Creates sample claim batches for `claim_date_of_loss` and `claim_hail` +3. Uploads each file with its mapped schema +4. Submits each claim batch for processing + +**Windows (PowerShell):** + +```powershell +./infra/scripts/upload_sample_data.ps1 +``` + +**macOS/Linux:** + +```bash +if [ "$(uname)" = "Darwin" ]; then + sed -i '' 's/\r$//' ./infra/scripts/upload_sample_data.sh +else + sed -i 's/\r$//' ./infra/scripts/upload_sample_data.sh +fi +bash ./infra/scripts/upload_sample_data.sh +``` + +### 5.3 Configure Authentication (Manual Script) + +Run authentication setup as an explicit step after post-deployment data setup: + +**Windows (PowerShell):** + +```powershell +./infra/scripts/setup_auth.ps1 ``` -🧭 Web App Details: - ✅ Name: ca--web - 🌐 Endpoint: ca--web..azurecontainerapps.io - 🔗 Portal URL: https://portal.azure.com/#resource/... - -🧭 API App Details: - ✅ Name: ca--api - 🌐 Endpoint: ca--api..azurecontainerapps.io - 🔗 Portal URL: https://portal.azure.com/#resource/... - -🧭 Workflow App Details: - ✅ Name: ca--wkfl - 🔗 Portal URL: https://portal.azure.com/#resource/... - -📦 Registering schemas and creating schema set... - ⏳ Waiting for API to be ready... - ✅ API is ready. -============================================================ -Step 1: Register schemas -============================================================ -✓ Successfully registered: Auto Insurance Claim Form's Schema Id - -✓ Successfully registered: Damaged Vehicle Image Assessment's Schema Id - -✓ Successfully registered: Police Report Document's Schema Id - -✓ Successfully registered: Repair Estimate Document's Schema Id - - -============================================================ -Step 2: Create schema set -============================================================ -✓ Created schema set 'Auto Claim' with ID: - -============================================================ -Step 3: Add schemas to schema set -============================================================ - ✓ Added 'AutoInsuranceClaimForm' () to schema set - ✓ Added 'DamagedVehicleImageAssessment' () to schema set - ✓ Added 'PoliceReportDocument' () to schema set - ✓ Added 'RepairEstimateDocument' () to schema set - -============================================================ -Schema registration process completed. - Schema set ID: - Schemas added: 4 -============================================================ - ✅ Schema registration complete. + +**macOS/Linux:** + +```bash +if [ "$(uname)" = "Darwin" ]; then + sed -i '' 's/\r$//' ./infra/scripts/setup_auth.sh +else + sed -i 's/\r$//' ./infra/scripts/setup_auth.sh +fi +bash ./infra/scripts/setup_auth.sh +``` + +The auth script is idempotent and performs preflight validation before making changes. + +#### Skipping auth setup + +If your tenant blocks programmatic app registration, or you need to defer authentication setup, you can skip this step: + +```powershell +azd env set AZURE_SKIP_AUTH_SETUP true ``` -### 5.2 Configure Authentication (Required) +Then follow the manual instructions in [App Authentication Configuration](./ConfigureAppAuthentication.md). To re-enable later, set the value to `false` and re-run `setup_auth.ps1`. + +#### Required Permissions for auth setup + +- Create/update app registrations: **Application Administrator**, **Cloud Application Administrator**, or **Global Administrator** +- Grant admin consent: **Cloud Application Administrator** or **Global Administrator** +- Update Container Apps auth/secret settings: **Contributor** on the deployment resource group -**This step is mandatory for application access:** +If permissions are insufficient, the script exits early (or warns before consent) with clear remediation guidance. -1. Follow [App Authentication Configuration](./ConfigureAppAuthentication.md). -2. Wait up to 10 minutes for authentication changes to take effect. +> **Note:** EasyAuth can take up to 10 minutes to fully propagate. If the Web app returns 500/401 immediately after setup, wait a few minutes and retry. -### 5.3 Verify Deployment +#### WAF (Well-Architected Framework) deployments + +Authentication script execution is fully compatible with the WAF / production profile (`main.waf.parameters.json`, which enables `enablePrivateNetworking`, `enableRedundancy`, and `enableScalability`). The Web and API container apps keep external ingress in the default WAF profile, so registered redirect URIs (`https:///.auth/login/aad/callback`) remain valid public entry points. + +> If you customize WAF to make Web or API ingress internal-only, run the auth script from an environment that can reach those private endpoints (for example, the deployed jumpbox or a VPN-connected host). + +### 5.4 Verify Deployment 1. Access your application using the **Web App Endpoint** from the deployment output. 2. Confirm the application loads successfully. 3. Verify you can sign in with your authenticated account. -### 5.4 Test the Application +### 5.5 Test the Application + +> **Note:** If you ran [Step 5.2](#52-run-sample-data-upload-manual), two sample claim bundles (`claim_date_of_loss` and `claim_hail`) should already be processed in the web app. **Quick Test Steps:** -1. **Download Samples**: Get sample files from the [samples directory](../src/ContentProcessorAPI/samples) — use the `claim_date_of_loss/` or `claim_hail/` folders for auto claim documents. -2. **Upload**: In the app, select the **"Auto Claim"** schema set, choose a schema (e.g., Auto Insurance Claim Form), click Import Content, and upload a sample file. -3. **Review**: Wait for completion (~1 min), then click the row to verify the extracted data against the source document. +1. **Check Processed Results**: Open the web app — you should see the two sample claim batches already processed with extracted data. +2. **Review**: Click a processed claim row to verify the extracted data against the source document. +3. **Upload More (Optional)**: To test additional uploads, get sample files from the [samples directory](../src/ContentProcessorAPI/samples), select the **"Auto Claim"** schema set, and upload via Import Content. 📖 **Detailed Instructions:** See the complete [Golden Path Workflows](./GoldenPathWorkflows.md) guide for step-by-step testing procedures. diff --git a/infra/main.bicep b/infra/main.bicep index 2bc2b88c..a4e746b3 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1122,7 +1122,7 @@ module avmContainerApp_Web 'br/public:avm/res/app/container-app:0.22.1' = { } { name: 'APP_WEB_AUTHORITY' - value: '${environment().authentication.loginEndpoint}/${tenant().tenantId}' + value: '${environment().authentication.loginEndpoint}${tenant().tenantId}' } { name: 'APP_WEB_SCOPE' diff --git a/infra/scripts/configure_auth.ps1 b/infra/scripts/configure_auth.ps1 new file mode 100644 index 00000000..db3154db --- /dev/null +++ b/infra/scripts/configure_auth.ps1 @@ -0,0 +1,533 @@ +# Automates the app registration + EasyAuth configuration that is otherwise +# performed manually per docs/ConfigureAppAuthentication.md. +# +# Idempotent: safe to re-run. Reuses existing app registrations and container +# app secrets where possible. +# +# Skip with: azd env set AZURE_SKIP_AUTH_SETUP true + +$ErrorActionPreference = "Stop" + +if ($env:AZURE_SKIP_AUTH_SETUP -eq "true") { + Write-Host "⏭️ AZURE_SKIP_AUTH_SETUP=true — skipping auth configuration." + return +} + +$PreflightOnly = $args -contains "--preflight-only" +if ($PreflightOnly) { + Write-Host "" + Write-Host "============================================================" + Write-Host "🔍 Preflight permission check (read-only — no changes made)" + Write-Host "============================================================" +} else { + Write-Host "" + Write-Host "============================================================" + Write-Host "🔐 Configuring Entra ID authentication (Web + API)" + Write-Host "============================================================" +} + +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Error "Azure CLI (az) is not installed or not on PATH. Install from https://aka.ms/installazurecli and re-run." + exit 1 +} + +if (-not (Get-Command azd -ErrorAction SilentlyContinue)) { + Write-Error "Azure Developer CLI (azd) is not installed or not on PATH. Install from https://aka.ms/install-azd and re-run." + exit 1 +} + +try { + azd env get-values *> $null + if ($LASTEXITCODE -ne 0) { throw } +} catch { + Write-Error "No active azd environment found. Run 'azd env list' and 'azd env select ', then re-run." + exit 1 +} + +function Azd-Get($key, $default = "") { + try { return (azd env get-value $key 2>$null) } catch { return $default } +} + +$EnvName = Azd-Get "AZURE_ENV_NAME" "cps" +$ResourceGroup = Azd-Get "AZURE_RESOURCE_GROUP" +$SubscriptionId = Azd-Get "AZURE_SUBSCRIPTION_ID" +$TenantId = (az account show --query tenantId -o tsv 2>$null) +if (-not $TenantId) { $TenantId = Azd-Get "AZURE_TENANT_ID" "" } +# (Preflight Check 1 will catch missing authentication with a clear error message) + +$WebName = Azd-Get "CONTAINER_WEB_APP_NAME" +$WebFqdn = Azd-Get "CONTAINER_WEB_APP_FQDN" +$ApiName = Azd-Get "CONTAINER_API_APP_NAME" +$ApiFqdn = Azd-Get "CONTAINER_API_APP_FQDN" + +$WebDisplayName = "$EnvName-web-app" +$ApiDisplayName = "$EnvName-api-app" + +$WebUrl = "https://$WebFqdn" +$ApiUrl = "https://$ApiFqdn" +$WebAuthCallback = "$WebUrl/.auth/login/aad/callback" +$ApiAuthCallback = "$ApiUrl/.auth/login/aad/callback" + +$GraphAppId = "00000003-0000-0000-c000-000000000000" +$GraphUserReadScopeId = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" +$CaSecretName = "microsoft-provider-authentication-secret" +$ConsentPrecheckOk = $true + +function Retry($Block, $Max = 6, $Delay = 10) { + for ($i = 1; $i -le $Max; $i++) { + try { return & $Block } catch { + if ($i -eq $Max) { throw } + Write-Host " ↻ retry $i/$Max after ${Delay}s..." + Start-Sleep -Seconds $Delay + } + } +} + +function Find-AppIdByEnvOrName($EnvKey, $DisplayName) { + $id = Azd-Get $EnvKey "" + if ($id) { + $exists = az ad app show --id $id 2>$null + if ($LASTEXITCODE -eq 0) { return $id } + } + $ids = az ad app list --display-name $DisplayName --query "[].appId" -o tsv + $arr = @($ids -split "`n" | Where-Object { $_ }) + if ($arr.Count -gt 1) { throw "Multiple app registrations with displayName '$DisplayName'. Clean up or set $EnvKey manually." } + if ($arr.Count -eq 1) { return $arr[0] } + return "" +} + +function Write-Check($Status, $Label, $Detail = "") { + switch ($Status) { + "PASS" { Write-Host (" ✅ {0,-55}" -f $Label) } + "WARN" { + Write-Host (" ⚠️ {0,-54}" -f $Label) + if ($Detail) { Write-Host " $Detail" } + } + "FAIL" { + Write-Host (" ❌ {0,-55}" -f $Label) + if ($Detail) { Write-Host " $Detail" } + } + } +} + +function Validate-PrerequisitesAndPermissions { + Write-Host "" + Write-Host "============================================================" + Write-Host "Preflight: permission validation" + Write-Host "============================================================" + + $Fatal = $false + + # ── 1. Azure CLI authentication ────────────────────────────────── + $accountId = az account show --query id -o tsv 2>$null + if (-not $accountId) { + Write-Check FAIL "Azure CLI authenticated" ` + "Run 'az login' (or 'az login --use-device-code') then re-run this script." + $Fatal = $true + } else { + Write-Check PASS "Azure CLI authenticated (subscription: $accountId)" + } + + # ── 2. Required azd environment values present ─────────────────── + $RequiredKeys = @( + "AZURE_RESOURCE_GROUP", "AZURE_SUBSCRIPTION_ID", + "CONTAINER_WEB_APP_NAME", "CONTAINER_WEB_APP_FQDN", + "CONTAINER_API_APP_NAME", "CONTAINER_API_APP_FQDN" + ) + $MissingKeys = @() + foreach ($k in $RequiredKeys) { + $v = Azd-Get $k "" + if (-not $v) { $MissingKeys += $k } + } + if ($MissingKeys.Count -gt 0) { + Write-Check FAIL "Required azd env values present" ` + "Missing: $($MissingKeys -join ', '). Run 'azd env get-values' to inspect. Re-run 'azd up' if provisioning is incomplete." + $Fatal = $true + } else { + Write-Check PASS "Required azd env values present" + } + + # Abort early — remaining checks depend on these values + if ($Fatal) { + Write-Host "" + Write-Error "Preflight failed — fix the issues above and re-run configure_auth.ps1" + exit 1 + } + + # ── 3. Azure Container Apps CLI extension available ────────────── + az containerapp --help 1>$null 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Check PASS "Azure Container Apps CLI extension available" + } else { + Write-Check FAIL "Azure Container Apps CLI extension available" ` + "Install with: az extension add --name containerapp --upgrade" + $Fatal = $true + } + + # ── 4. Contributor (or Owner) on the resource group ────────────── + $CurrentPrincipal = az ad signed-in-user show --query id -o tsv 2>$null + $IsSp = $false + if (-not $CurrentPrincipal) { + $IsSp = $true + $CurrentPrincipal = az account show --query 'user.name' -o tsv 2>$null + } + + $RgScope = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup" + $SubScope = "/subscriptions/$SubscriptionId" + + $RbacRoles = az role assignment list --assignee $CurrentPrincipal --scope $RgScope ` + --query "[].roleDefinitionName" -o tsv 2>$null + $HasContributor = $RbacRoles -match "Owner|Contributor" + + if (-not $HasContributor) { + $SubRoles = az role assignment list --assignee $CurrentPrincipal --scope $SubScope ` + --query "[].roleDefinitionName" -o tsv 2>$null + $HasContributor = $SubRoles -match "Owner|Contributor" + if ($HasContributor) { + Write-Check PASS "Contributor/Owner role inherited from subscription scope" + } + } else { + Write-Check PASS "Contributor/Owner role on resource group '$ResourceGroup'" + } + + if (-not $HasContributor) { + Write-Check FAIL "Contributor/Owner role on resource group '$ResourceGroup'" ` + "Grant Contributor: az role assignment create --assignee `"$CurrentPrincipal`" --role Contributor --scope $RgScope" + $Fatal = $true + } + + # ── 5. Entra app registration read access ──────────────────────── + az ad app list --top 1 --query "[0].appId" -o tsv 1>$null 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Check PASS "Can read Entra app registrations" + } else { + Write-Check FAIL "Can read Entra app registrations" ` + "Ensure your identity has at least Directory Readers or Application Developer role in Entra." + $Fatal = $true + } + + # ── 6. Container App reachable ─────────────────────────────────── + az containerapp show -n $WebName -g $ResourceGroup --query name -o tsv 1>$null 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Check PASS "Container App '$WebName' is accessible" + } else { + Write-Check FAIL "Container App '$WebName' is accessible" ` + "Verify deployment completed and you have Contributor role on the resource group." + $Fatal = $true + } + + # ── 7. Entra directory-role check (users only) ─────────────────── + if ($IsSp) { + Write-Check WARN "Entra directory-role check" ` + "Logged in as a service principal — directory role check skipped. Ensure the SP has Application Administrator and admin-consent permissions." + $script:ConsentPrecheckOk = $false + } else { + $Roles = az rest --method GET ` + --url "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.directoryRole?`$select=displayName" ` + --query "value[].displayName" -o tsv 2>$null + + if (-not $Roles) { + Write-Check WARN "Entra directory roles resolvable" ` + "Could not enumerate roles. The script will continue; exact permission errors will surface at runtime." + } elseif ($Roles -notmatch "Global Administrator|Application Administrator|Cloud Application Administrator") { + Write-Check FAIL "App-registration permission (Application Administrator or higher)" ` + "Assign 'Application Administrator' (or higher) in Entra ID, then re-run.`n Portal: https://entra.microsoft.com → Roles and administrators" + $Fatal = $true + } else { + Write-Check PASS "App-registration permission (Application Administrator or higher)" + + if ($Roles -notmatch "Global Administrator|Cloud Application Administrator") { + $script:ConsentPrecheckOk = $false + Write-Check WARN "Admin-consent permission (Cloud Application Administrator or higher)" ` + "Admin consent step will be attempted but may fail. A tenant admin can grant consent at:`n https://login.microsoftonline.com/$TenantId/adminconsent?client_id=" + } else { + Write-Check PASS "Admin-consent permission (Cloud Application Administrator or higher)" + } + } + } + + # ── Summary ────────────────────────────────────────────────────── + Write-Host "" + if ($Fatal) { + Write-Error "One or more preflight checks FAILED. Resolve the issues above and re-run." + exit 1 + } + Write-Host " Preflight passed — proceeding with auth configuration." + Write-Host "============================================================" +} + +Validate-PrerequisitesAndPermissions + +if ($PreflightOnly) { + Write-Host "" + Write-Host "✅ Preflight-only mode: all permission checks passed. No changes were made." + exit 0 +} + +# --- Step 1: API app registration -------------------------------------------- +Write-Host "" +Write-Host "➡️ Step 1/6: API app registration ($ApiDisplayName)" + +$ApiClientId = Find-AppIdByEnvOrName "AZURE_AUTH_API_CLIENT_ID" $ApiDisplayName +if (-not $ApiClientId) { + $ApiClientId = az ad app create --display-name $ApiDisplayName ` + --sign-in-audience AzureADMyOrg ` + --web-redirect-uris $ApiAuthCallback ` + --enable-id-token-issuance true ` + --query appId -o tsv + Write-Host " ✓ Created API app: $ApiClientId" +} else { + Write-Host " ↺ Reusing API app: $ApiClientId" + Retry { az ad app update --id $ApiClientId --web-redirect-uris $ApiAuthCallback --enable-id-token-issuance true | Out-Null } +} +azd env set AZURE_AUTH_API_CLIENT_ID $ApiClientId | Out-Null + +Retry { + az ad sp show --id $ApiClientId 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { az ad sp create --id $ApiClientId | Out-Null } +} + +$ApiAppObjectId = az ad app show --id $ApiClientId --query id -o tsv +$ApiIdentifierUri = "api://$ApiClientId" + +$ApiScopeId = az ad app show --id $ApiClientId --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv +if (-not $ApiScopeId -or $ApiScopeId -eq "null") { + $ApiScopeId = [guid]::NewGuid().ToString() + $patch = @{ + identifierUris = @($ApiIdentifierUri) + api = @{ + oauth2PermissionScopes = @(@{ + id = $ApiScopeId + adminConsentDescription = "Allow the application to access the API on behalf of the signed-in user." + adminConsentDisplayName = "Access API as user" + userConsentDescription = "Allow the application to access the API on your behalf." + userConsentDisplayName = "Access API" + value = "user_impersonation" + type = "User" + isEnabled = $true + }) + } + } | ConvertTo-Json -Depth 10 + $tmp = New-TemporaryFile + $patch | Out-File -FilePath $tmp -Encoding utf8 + Retry { az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/$ApiAppObjectId" --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } + Remove-Item $tmp + Write-Host " ✓ Exposed scope api://$ApiClientId/user_impersonation" +} else { + Write-Host " ↺ API scope already exposed" +} +$ApiScopeValue = "api://$ApiClientId/user_impersonation" + +# --- Step 2: Web app registration -------------------------------------------- +Write-Host "" +Write-Host "➡️ Step 2/6: Web app registration ($WebDisplayName)" + +$WebClientId = Find-AppIdByEnvOrName "AZURE_AUTH_WEB_CLIENT_ID" $WebDisplayName +if (-not $WebClientId) { + $WebClientId = az ad app create --display-name $WebDisplayName ` + --sign-in-audience AzureADMyOrg ` + --web-redirect-uris $WebAuthCallback ` + --enable-id-token-issuance true ` + --enable-access-token-issuance true ` + --query appId -o tsv + Write-Host " ✓ Created Web app: $WebClientId" +} else { + Write-Host " ↺ Reusing Web app: $WebClientId" + Retry { az ad app update --id $WebClientId --web-redirect-uris $WebAuthCallback --enable-id-token-issuance true --enable-access-token-issuance true | Out-Null } +} +azd env set AZURE_AUTH_WEB_CLIENT_ID $WebClientId | Out-Null + +Retry { + az ad sp show --id $WebClientId 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { az ad sp create --id $WebClientId | Out-Null } +} + +$WebAppObjectId = az ad app show --id $WebClientId --query id -o tsv +$WebIdentifierUri = "api://$WebClientId" + +$WebScopeId = az ad app show --id $WebClientId --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv +if (-not $WebScopeId -or $WebScopeId -eq "null") { $WebScopeId = [guid]::NewGuid().ToString() } + +$webPatch = @{ + identifierUris = @($WebIdentifierUri) + spa = @{ redirectUris = @($WebUrl, "$WebUrl/") } + api = @{ + knownClientApplications = @() + oauth2PermissionScopes = @(@{ + id = $WebScopeId + adminConsentDescription = "Allow the app to sign in the user." + adminConsentDisplayName = "Sign in" + userConsentDescription = "Allow the app to sign you in." + userConsentDisplayName = "Sign in" + value = "user_impersonation" + type = "User" + isEnabled = $true + }) + } + requiredResourceAccess = @( + @{ resourceAppId = $ApiClientId; resourceAccess = @(@{ id = $ApiScopeId; type = "Scope" }) }, + @{ resourceAppId = $GraphAppId; resourceAccess = @(@{ id = $GraphUserReadScopeId; type = "Scope" }) } + ) +} | ConvertTo-Json -Depth 10 +$tmp = New-TemporaryFile +$webPatch | Out-File -FilePath $tmp -Encoding utf8 +Retry { az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/$WebAppObjectId" --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } +Remove-Item $tmp +Write-Host " ✓ Web SPA redirect, scope, and required permissions configured" +$WebScopeValue = "api://$WebClientId/user_impersonation" + +# --- Step 3: Admin consent --------------------------------------------------- +Write-Host "" +Write-Host "➡️ Step 3/6: Granting admin consent" +$ConsentOk = $true +try { + Retry { az ad app permission admin-consent --id $WebClientId | Out-Null } + Write-Host " ✓ Admin consent granted" +} catch { + $ConsentOk = $false + Write-Host " ⚠️ Admin consent failed. Sign-in may fail until a tenant admin runs:" + Write-Host " az ad app permission admin-consent --id $WebClientId" + Write-Host " Or: https://login.microsoftonline.com/$TenantId/adminconsent?client_id=$WebClientId" +} + +# Belt-and-suspenders: explicitly grant the API user_impersonation scope to +# the Web SP. `az ad app permission admin-consent` often skips custom-API +# delegated permissions, leaving MSAL.js silent token acquisition broken +# (which causes the SPA to render a blank page after sign-in). +$WebSpId = az ad sp show --id $WebClientId --query id -o tsv 2>$null +$ApiSpId = az ad sp show --id $ApiClientId --query id -o tsv 2>$null +if ($WebSpId -and $ApiSpId) { + $existing = az rest --method get ` + --url "https://graph.microsoft.com/v1.0/servicePrincipals/$WebSpId/oauth2PermissionGrants" ` + --query "value[?resourceId=='$ApiSpId'] | [0].id" -o tsv 2>$null + if (-not $existing -or $existing -eq "null") { + $body = "{`"clientId`":`"$WebSpId`",`"consentType`":`"AllPrincipals`",`"resourceId`":`"$ApiSpId`",`"scope`":`"user_impersonation`"}" + try { + az rest --method POST ` + --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" ` + --headers "Content-Type=application/json" ` + --body $body --output none + Write-Host " ✓ API user_impersonation scope granted to Web SP" + } catch { + Write-Host " ⚠️ Could not auto-grant API user_impersonation; SPA may show blank page until granted manually." + $ConsentOk = $false + } + } else { + Write-Host " ↺ API user_impersonation scope already granted" + } +} + +# --- Step 4: Container App secrets ------------------------------------------ +Write-Host "" +Write-Host "➡️ Step 4/6: Client secrets" + +function Ensure-CaSecret($AppId, $CaName) { + $existing = az containerapp secret list -n $CaName -g $ResourceGroup --query "[?name=='$CaSecretName'].name | [0]" -o tsv + if ($existing -and $existing -ne "null") { + Write-Host " ↺ Container App '$CaName' already has '$CaSecretName' — not rotating." + return + } + $secret = az ad app credential reset --id $AppId --append --display-name "containerapp-easyauth" --years 2 --query password -o tsv + az containerapp secret set -n $CaName -g $ResourceGroup --secrets "$CaSecretName=$secret" --output none + Write-Host " ✓ Stored new client secret in '$CaName'" +} + +Ensure-CaSecret $ApiClientId $ApiName +Ensure-CaSecret $WebClientId $WebName + +# --- Step 5: Enable EasyAuth ------------------------------------------------ +Write-Host "" +Write-Host "➡️ Step 5/6: Enabling EasyAuth on Web + API container apps" + +function Configure-EasyAuth($CaName, $ClientId) { + # Note: --tenant-id and --issuer are mutually exclusive. Do not override + # --allowed-token-audiences; EasyAuth issues ID tokens with aud=. + az containerapp auth microsoft update -n $CaName -g $ResourceGroup ` + --client-id $ClientId ` + --client-secret-name $CaSecretName ` + --tenant-id $TenantId ` + --yes --output none +} + +Configure-EasyAuth $ApiName $ApiClientId +Configure-EasyAuth $WebName $WebClientId + +az containerapp auth update -n $WebName -g $ResourceGroup --enabled true --unauthenticated-client-action AllowAnonymous --output none +az containerapp auth update -n $ApiName -g $ResourceGroup --enabled true --unauthenticated-client-action AllowAnonymous --output none +Write-Host " ✓ EasyAuth providers configured" + +# --- Step 6: Env vars + allowedApplications + lockdown ---------------------- +Write-Host "" +Write-Host "➡️ Step 6/6: Wiring env vars and caller allowlist" + +az containerapp update -n $WebName -g $ResourceGroup ` + --set-env-vars "APP_WEB_CLIENT_ID=$WebClientId" "APP_WEB_SCOPE=$WebScopeValue" "APP_API_SCOPE=$ApiScopeValue" "APP_WEB_AUTHORITY=https://login.microsoftonline.com/$TenantId" "APP_AUTH_ENABLED=true" ` + --output none +Write-Host " ✓ Web env vars updated" + +function Patch-AuthConfig($CaName, $ClientId, $AddWebAllowed) { + $url = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.App/containerApps/$CaName/authConfigs/current?api-version=2024-03-01" + $current = az rest --method get --url $url | ConvertFrom-Json + if (-not $current.properties) { $current | Add-Member -MemberType NoteProperty -Name properties -Value (@{}) } + if (-not $current.properties.identityProviders) { $current.properties | Add-Member -MemberType NoteProperty -Name identityProviders -Value (@{}) } + if (-not $current.properties.identityProviders.azureActiveDirectory) { $current.properties.identityProviders | Add-Member -MemberType NoteProperty -Name azureActiveDirectory -Value (@{}) } + $aad = $current.properties.identityProviders.azureActiveDirectory + if (-not $aad.registration) { $aad | Add-Member -MemberType NoteProperty -Name registration -Value (@{}) } + $aad.registration.openIdIssuer = "https://login.microsoftonline.com/$TenantId/v2.0" + if (-not $aad.validation) { $aad | Add-Member -MemberType NoteProperty -Name validation -Value (@{}) } + $aad.validation.allowedAudiences = @($ClientId, "api://$ClientId") + if (-not $aad.validation.defaultAuthorizationPolicy) { $aad.validation | Add-Member -MemberType NoteProperty -Name defaultAuthorizationPolicy -Value (@{}) } + $policy = $aad.validation.defaultAuthorizationPolicy + $allowed = @() + if ($policy.allowedApplications) { $allowed = @($policy.allowedApplications) } + if ($AddWebAllowed -and ($allowed -notcontains $WebClientId)) { $allowed += $WebClientId } + $policy.allowedApplications = $allowed + + if (-not $current.properties.platform) { $current.properties | Add-Member -MemberType NoteProperty -Name platform -Value (@{}) } + $current.properties.platform.enabled = $true + if (-not $current.properties.globalValidation) { $current.properties | Add-Member -MemberType NoteProperty -Name globalValidation -Value ([pscustomobject]@{}) } + $gv = $current.properties.globalValidation + if ($gv.PSObject.Properties.Name -notcontains 'requireAuthentication') { $gv | Add-Member -MemberType NoteProperty -Name requireAuthentication -Value $true } else { $gv.requireAuthentication = $true } + if ($AddWebAllowed) { + if ($gv.PSObject.Properties.Name -notcontains 'unauthenticatedClientAction') { $gv | Add-Member -MemberType NoteProperty -Name unauthenticatedClientAction -Value 'Return401' } else { $gv.unauthenticatedClientAction = 'Return401' } + if ($gv.PSObject.Properties.Name -contains 'redirectToProvider') { $gv.PSObject.Properties.Remove('redirectToProvider') } + } else { + if ($gv.PSObject.Properties.Name -notcontains 'unauthenticatedClientAction') { $gv | Add-Member -MemberType NoteProperty -Name unauthenticatedClientAction -Value 'RedirectToLoginPage' } else { $gv.unauthenticatedClientAction = 'RedirectToLoginPage' } + if ($gv.PSObject.Properties.Name -notcontains 'redirectToProvider') { $gv | Add-Member -MemberType NoteProperty -Name redirectToProvider -Value 'azureactivedirectory' } else { $gv.redirectToProvider = 'azureactivedirectory' } + } + + $tmp = New-TemporaryFile + $current | ConvertTo-Json -Depth 20 | Out-File -FilePath $tmp -Encoding utf8 + Retry { az rest --method put --url $url --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } + Remove-Item $tmp +} + +Patch-AuthConfig $ApiName $ApiClientId $true +Patch-AuthConfig $WebName $WebClientId $false +Write-Host " ✓ authConfigs normalized (issuer, audiences, allowedApplications)" + +Write-Host " ✓ Unauthenticated requests: Web → login, API → 401" + +# Restart active revisions so containers pick up newly-set client secrets. +# (`az containerapp secret set` does NOT trigger a new revision on its own.) +function Restart-ActiveRevision($CaName) { + $rev = az containerapp revision list -n $CaName -g $ResourceGroup --query "[?properties.active] | [0].name" -o tsv 2>$null + if ($rev -and $rev -ne "null") { + az containerapp revision restart -n $CaName -g $ResourceGroup --revision $rev --output none 2>$null + } +} +Restart-ActiveRevision $WebName +Restart-ActiveRevision $ApiName +Write-Host " ✓ Restarted Web + API container revisions to apply secrets" + +Write-Host "" +Write-Host "============================================================" +Write-Host "🔐 Auth configuration complete." +Write-Host " Web client id : $WebClientId" +Write-Host " API client id : $ApiClientId" +Write-Host " Web scope : $WebScopeValue" +Write-Host " API scope : $ApiScopeValue" +if (-not $ConsentOk) { Write-Host " ⚠️ Admin consent pending — see step 3 above." } +if (-not $ConsentPrecheckOk) { Write-Host " ⚠️ Permission pre-check predicted admin-consent limitations for this identity." } +Write-Host " Note: EasyAuth rollout can take up to 10 minutes." +Write-Host "============================================================" diff --git a/infra/scripts/configure_auth.sh b/infra/scripts/configure_auth.sh new file mode 100755 index 00000000..2973b5f0 --- /dev/null +++ b/infra/scripts/configure_auth.sh @@ -0,0 +1,655 @@ +#!/usr/bin/env bash +# Automates the app registration + EasyAuth configuration that is otherwise +# performed manually per docs/ConfigureAppAuthentication.md. +# +# Idempotent: safe to re-run. Reuses existing app registrations and container +# app secrets where possible. +# +# Skip with: azd env set AZURE_SKIP_AUTH_SETUP true + +set -euo pipefail + +if [[ "${AZURE_SKIP_AUTH_SETUP:-false}" == "true" ]]; then + echo "⏭️ AZURE_SKIP_AUTH_SETUP=true — skipping auth configuration." + exit 0 +fi + +PREFLIGHT_ONLY=false +[[ "${1:-}" == "--preflight-only" ]] && PREFLIGHT_ONLY=true + +if [[ "$PREFLIGHT_ONLY" == "true" ]]; then + echo "" + echo "============================================================" + echo "🔍 Preflight permission check (read-only — no changes made)" + echo "============================================================" +else + echo "" + echo "============================================================" + echo "🔐 Configuring Entra ID authentication (Web + API)" + echo "============================================================" +fi + +if ! command -v az >/dev/null 2>&1; then + echo "❌ Azure CLI (az) is not installed or not on PATH." >&2 + echo " Install it from https://aka.ms/installazurecli, then re-run." >&2 + exit 1 +fi + +if ! command -v azd >/dev/null 2>&1; then + echo "❌ Azure Developer CLI (azd) is not installed or not on PATH." >&2 + echo " Install it from https://aka.ms/install-azd, then re-run." >&2 + exit 1 +fi + +if ! azd env get-values >/dev/null 2>&1; then + echo "❌ No active azd environment found." >&2 + echo " Run 'azd env list' and 'azd env select ', then re-run." >&2 + exit 1 +fi + +# --- Load values from azd env ------------------------------------------------- +ENV_NAME="$(azd env get-value AZURE_ENV_NAME 2>/dev/null || echo "")" +RESOURCE_GROUP="$(azd env get-value AZURE_RESOURCE_GROUP 2>/dev/null || true)" +SUBSCRIPTION_ID="$(azd env get-value AZURE_SUBSCRIPTION_ID 2>/dev/null || true)" +TENANT_ID="$(az account show --query tenantId -o tsv 2>/dev/null || true)" +if [[ -z "$TENANT_ID" ]]; then + TENANT_ID="$(azd env get-value AZURE_TENANT_ID 2>/dev/null || true)" +fi +# (Preflight Check 1 will catch missing authentication with a clear error message) +WEB_NAME="$(azd env get-value CONTAINER_WEB_APP_NAME 2>/dev/null || true)" +WEB_FQDN="$(azd env get-value CONTAINER_WEB_APP_FQDN 2>/dev/null || true)" +API_NAME="$(azd env get-value CONTAINER_API_APP_NAME 2>/dev/null || true)" +API_FQDN="$(azd env get-value CONTAINER_API_APP_FQDN 2>/dev/null || true)" + +WEB_APP_DISPLAY_NAME="${ENV_NAME:-cps}-web-app" +API_APP_DISPLAY_NAME="${ENV_NAME:-cps}-api-app" + +WEB_URL="https://${WEB_FQDN}" +API_URL="https://${API_FQDN}" +WEB_AUTH_CALLBACK="${WEB_URL}/.auth/login/aad/callback" +API_AUTH_CALLBACK="${API_URL}/.auth/login/aad/callback" + +# Graph delegated User.Read permission +GRAPH_APP_ID="00000003-0000-0000-c000-000000000000" +GRAPH_USER_READ_SCOPE_ID="e1fe6dd8-ba31-4d61-89e7-88639da4683d" # User.Read (delegated) +CONSENT_PRECHECK_OK=true + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +# Find app reg by previously persisted appId in azd env, else by displayName. +# Returns: appId on stdout, empty if not found. +find_app_by_env_or_name() { + local env_key="$1" + local display_name="$2" + local app_id + app_id="$(azd env get-value "$env_key" 2>/dev/null || echo "")" + if [[ -n "$app_id" ]] && az ad app show --id "$app_id" >/dev/null 2>&1; then + echo "$app_id" + return 0 + fi + # Fall back to displayName + local ids + ids="$(az ad app list --display-name "$display_name" --query "[].appId" -o tsv 2>/dev/null || true)" + local count + count="$(echo "$ids" | grep -c . || true)" + if [[ "$count" -gt 1 ]]; then + echo "❌ Multiple app registrations found with displayName '$display_name'. Delete duplicates or set $env_key manually." >&2 + exit 1 + fi + echo "$ids" | head -n1 +} + +# Retry an az command on transient Graph propagation failures. +retry() { + local max=${RETRY_COUNT:-6} + local delay=${RETRY_DELAY:-10} + local i=1 + while true; do + if "$@"; then return 0; fi + if (( i >= max )); then return 1; fi + echo " ↻ retry $i/$max after ${delay}s..." + sleep "$delay" + i=$((i+1)) + done +} + +# Generate a UUID in a macOS/Linux portable way. +generate_uuid() { + if command -v uuidgen >/dev/null 2>&1; then + uuidgen + elif command -v python3 >/dev/null 2>&1; then + python3 -c 'import uuid; print(uuid.uuid4())' + elif [[ -r /proc/sys/kernel/random/uuid ]]; then + cat /proc/sys/kernel/random/uuid + else + echo "❌ Unable to generate UUID. Install uuidgen or python3." >&2 + exit 1 + fi +} + +# Print a preflight check result line +_check() { + local status="$1" # PASS | WARN | FAIL + local label="$2" + local detail="${3:-}" + case "$status" in + PASS) printf " ✅ %-55s\n" "$label" ;; + WARN) printf " ⚠️ %-54s\n" "$label" + [[ -n "$detail" ]] && echo " $detail" ;; + FAIL) printf " ❌ %-55s\n" "$label" + [[ -n "$detail" ]] && echo " $detail" ;; + esac +} + +validate_prerequisites_and_permissions() { + echo "" + echo "============================================================" + echo "Preflight: permission validation" + echo "============================================================" + + local fatal=false + + # ── 1. Azure CLI authentication ────────────────────────────────── + local account_id + account_id="$(az account show --query id -o tsv 2>/dev/null || true)" + if [[ -z "$account_id" ]]; then + _check FAIL "Azure CLI authenticated" \ + "Run 'az login' (or 'az login --use-device-code') then re-run this script." + fatal=true + else + _check PASS "Azure CLI authenticated (subscription: $account_id)" + fi + + # ── 2. Required azd environment values present ─────────────────── + local missing_keys=() + for key in AZURE_RESOURCE_GROUP AZURE_SUBSCRIPTION_ID CONTAINER_WEB_APP_NAME \ + CONTAINER_WEB_APP_FQDN CONTAINER_API_APP_NAME CONTAINER_API_APP_FQDN; do + local val + val="$(azd env get-value "$key" 2>/dev/null || true)" + if [[ -z "$val" ]]; then + missing_keys+=("$key") + fi + done + if [[ ${#missing_keys[@]} -gt 0 ]]; then + _check FAIL "Required azd env values present" \ + "Missing: ${missing_keys[*]}. Run 'azd env get-values' to inspect. Re-run 'azd up' if provisioning is incomplete." + fatal=true + else + _check PASS "Required azd env values present" + fi + + # Abort early if basics are missing — remaining checks depend on them + if [[ "$fatal" == "true" ]]; then + echo "" + echo "❌ Preflight failed — fix the issues above and re-run configure_auth.sh" >&2 + exit 1 + fi + + # ── 3. Azure Container Apps CLI extension available ────────────── + if az containerapp --help >/dev/null 2>&1; then + _check PASS "Azure Container Apps CLI extension available" + else + _check FAIL "Azure Container Apps CLI extension available" \ + "Install with: az extension add --name containerapp --upgrade" + fatal=true + fi + + # ── 3b. Python 3 available (used for authConfig JSON patching) ─── + if command -v python3 >/dev/null 2>&1; then + _check PASS "python3 available (required for authConfig patching)" + else + _check FAIL "python3 available (required for authConfig patching)" \ + "Install Python 3 and ensure 'python3' is on PATH, then re-run." + fatal=true + fi + + # ── 4. Contributor (or Owner) on the resource group ────────────── + local current_principal + current_principal="$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)" + local is_sp=false + if [[ -z "$current_principal" ]]; then + is_sp=true + current_principal="$(az account show --query 'user.name' -o tsv 2>/dev/null || true)" + fi + + local has_contributor=false + # Check RBAC on the resource group (works for users and SPs) + local rbac_roles + rbac_roles="$(az role assignment list \ + --assignee "$current_principal" \ + --scope "/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}" \ + --query "[].roleDefinitionName" -o tsv 2>/dev/null || true)" + if echo "$rbac_roles" | grep -Eiq 'Owner|Contributor'; then + has_contributor=true + _check PASS "Contributor/Owner role on resource group '$RESOURCE_GROUP'" + else + # Also accept subscription-level assignment inherited down + local rbac_sub_roles + rbac_sub_roles="$(az role assignment list \ + --assignee "$current_principal" \ + --scope "/subscriptions/${SUBSCRIPTION_ID}" \ + --query "[].roleDefinitionName" -o tsv 2>/dev/null || true)" + if echo "$rbac_sub_roles" | grep -Eiq 'Owner|Contributor'; then + has_contributor=true + _check PASS "Contributor/Owner role inherited from subscription scope" + else + _check FAIL "Contributor/Owner role on resource group '$RESOURCE_GROUP'" \ + "Grant Contributor on the resource group: az role assignment create --assignee \"$current_principal\" --role Contributor --scope /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP" + fatal=true + fi + fi + + # ── 5. Entra app registration read access ──────────────────────── + if az ad app list --top 1 --query "[0].appId" -o tsv >/dev/null 2>&1; then + _check PASS "Can read Entra app registrations" + else + _check FAIL "Can read Entra app registrations" \ + "Ensure your identity has at least Directory Readers or Application Developer role in Entra." + fatal=true + fi + + # ── 6. Container App reachable ─────────────────────────────────── + if az containerapp show -n "$WEB_NAME" -g "$RESOURCE_GROUP" --query name -o tsv >/dev/null 2>&1; then + _check PASS "Container App '$WEB_NAME' is accessible" + else + _check FAIL "Container App '$WEB_NAME' is accessible" \ + "Verify the deployment completed and you have Contributor role on the resource group." + fatal=true + fi + + # ── 7. Entra directory role check (users only) ─────────────────── + if [[ "$is_sp" == "true" ]]; then + _check WARN "Entra directory-role check" \ + "Logged in as a service principal — directory role check skipped. Ensure the SP has Application Administrator and admin-consent permissions." + CONSENT_PRECHECK_OK=false + else + local roles + roles="$(az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.directoryRole?\$select=displayName" \ + --query "value[].displayName" -o tsv 2>/dev/null || true)" + + if [[ -z "$roles" ]]; then + _check WARN "Entra directory roles resolvable" \ + "Could not enumerate roles. The script will continue; exact permission errors will surface at runtime." + elif ! echo "$roles" | grep -Eiq 'Global Administrator|Application Administrator|Cloud Application Administrator'; then + _check FAIL "App-registration permission (Application Administrator or higher)" \ + "Assign 'Application Administrator' (or higher) in Entra ID, then re-run.\n Portal: https://entra.microsoft.com → Roles and administrators" + fatal=true + else + _check PASS "App-registration permission (Application Administrator or higher)" + + if ! echo "$roles" | grep -Eiq 'Global Administrator|Cloud Application Administrator'; then + CONSENT_PRECHECK_OK=false + _check WARN "Admin-consent permission (Cloud Application Administrator or higher)" \ + "Admin consent step will be attempted but may fail. A tenant admin can grant consent at:\n https://login.microsoftonline.com/${TENANT_ID}/adminconsent?client_id=" + else + _check PASS "Admin-consent permission (Cloud Application Administrator or higher)" + fi + fi + fi + + # ── Summary ────────────────────────────────────────────────────── + echo "" + if [[ "$fatal" == "true" ]]; then + echo "❌ One or more preflight checks FAILED. Resolve the issues above and re-run." >&2 + exit 1 + fi + echo " Preflight passed — proceeding with auth configuration." + echo "============================================================" +} + +validate_prerequisites_and_permissions + +if [[ "$PREFLIGHT_ONLY" == "true" ]]; then + echo "" + echo "✅ Preflight-only mode: all permission checks passed. No changes were made." + exit 0 +fi + +# ----------------------------------------------------------------------------- +# Step 1: API app registration (exposes user_impersonation scope) +# ----------------------------------------------------------------------------- +echo "" +echo "➡️ Step 1/6: API app registration ($API_APP_DISPLAY_NAME)" + +API_CLIENT_ID="$(find_app_by_env_or_name AZURE_AUTH_API_CLIENT_ID "$API_APP_DISPLAY_NAME")" +if [[ -z "$API_CLIENT_ID" ]]; then + API_CLIENT_ID="$(az ad app create \ + --display-name "$API_APP_DISPLAY_NAME" \ + --sign-in-audience AzureADMyOrg \ + --web-redirect-uris "$API_AUTH_CALLBACK" \ + --enable-id-token-issuance true \ + --query appId -o tsv)" + echo " ✓ Created API app: $API_CLIENT_ID" +else + echo " ↺ Reusing API app: $API_CLIENT_ID" + retry az ad app update --id "$API_CLIENT_ID" \ + --web-redirect-uris "$API_AUTH_CALLBACK" \ + --enable-id-token-issuance true >/dev/null +fi +azd env set AZURE_AUTH_API_CLIENT_ID "$API_CLIENT_ID" >/dev/null + +# Ensure service principal exists (needed for consent + EasyAuth) +retry az ad sp show --id "$API_CLIENT_ID" >/dev/null 2>&1 \ + || az ad sp create --id "$API_CLIENT_ID" >/dev/null + +API_APP_OBJECT_ID="$(az ad app show --id "$API_CLIENT_ID" --query id -o tsv)" +API_IDENTIFIER_URI="api://${API_CLIENT_ID}" + +# Set identifierUri + expose user_impersonation scope (idempotent via Graph PATCH) +API_SCOPE_ID="$(az ad app show --id "$API_CLIENT_ID" \ + --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv)" +if [[ -z "$API_SCOPE_ID" || "$API_SCOPE_ID" == "null" ]]; then + API_SCOPE_ID="$(generate_uuid)" + cat > /tmp/api_scope_patch.json </dev/null + rm -f /tmp/api_scope_patch.json + echo " ✓ Exposed scope api://${API_CLIENT_ID}/user_impersonation" +else + echo " ↺ API scope already exposed" +fi +API_SCOPE_VALUE="api://${API_CLIENT_ID}/user_impersonation" + +# ----------------------------------------------------------------------------- +# Step 2: Web app registration (SPA + EasyAuth callback + exposes scope) +# ----------------------------------------------------------------------------- +echo "" +echo "➡️ Step 2/6: Web app registration ($WEB_APP_DISPLAY_NAME)" + +WEB_CLIENT_ID="$(find_app_by_env_or_name AZURE_AUTH_WEB_CLIENT_ID "$WEB_APP_DISPLAY_NAME")" +if [[ -z "$WEB_CLIENT_ID" ]]; then + WEB_CLIENT_ID="$(az ad app create \ + --display-name "$WEB_APP_DISPLAY_NAME" \ + --sign-in-audience AzureADMyOrg \ + --web-redirect-uris "$WEB_AUTH_CALLBACK" \ + --enable-id-token-issuance true \ + --enable-access-token-issuance true \ + --query appId -o tsv)" + echo " ✓ Created Web app: $WEB_CLIENT_ID" +else + echo " ↺ Reusing Web app: $WEB_CLIENT_ID" + retry az ad app update --id "$WEB_CLIENT_ID" \ + --web-redirect-uris "$WEB_AUTH_CALLBACK" \ + --enable-id-token-issuance true \ + --enable-access-token-issuance true >/dev/null +fi +azd env set AZURE_AUTH_WEB_CLIENT_ID "$WEB_CLIENT_ID" >/dev/null + +retry az ad sp show --id "$WEB_CLIENT_ID" >/dev/null 2>&1 \ + || az ad sp create --id "$WEB_CLIENT_ID" >/dev/null + +WEB_APP_OBJECT_ID="$(az ad app show --id "$WEB_CLIENT_ID" --query id -o tsv)" +WEB_IDENTIFIER_URI="api://${WEB_CLIENT_ID}" + +# Expose user_impersonation scope on the Web app (needed for loginRequest) +# + add SPA redirect URI + declare required resource access on API scope + Graph User.Read +WEB_SCOPE_ID="$(az ad app show --id "$WEB_CLIENT_ID" \ + --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv)" +[[ -z "$WEB_SCOPE_ID" || "$WEB_SCOPE_ID" == "null" ]] && WEB_SCOPE_ID="$(generate_uuid)" + +cat > /tmp/web_patch.json </dev/null +rm -f /tmp/web_patch.json +echo " ✓ Web SPA redirect, scope, and required permissions configured" + +WEB_SCOPE_VALUE="api://${WEB_CLIENT_ID}/user_impersonation" + +# ----------------------------------------------------------------------------- +# Step 3: Admin consent (best effort; hard warning if fails) +# ----------------------------------------------------------------------------- +echo "" +echo "➡️ Step 3/6: Granting admin consent" +CONSENT_OK=true +if ! retry az ad app permission admin-consent --id "$WEB_CLIENT_ID" 2>/tmp/consent_err; then + CONSENT_OK=false + echo " ⚠️ Admin consent failed. Sign-in may fail until a tenant admin runs:" + echo " az ad app permission admin-consent --id $WEB_CLIENT_ID" + echo " Or visit: https://login.microsoftonline.com/${TENANT_ID}/adminconsent?client_id=${WEB_CLIENT_ID}" + cat /tmp/consent_err | sed 's/^/ /' + rm -f /tmp/consent_err +else + echo " ✓ Admin consent granted" +fi + +# Belt-and-suspenders: explicitly grant the API scope to the Web SP. +# `az ad app permission admin-consent` is unreliable for app-to-app delegated +# permissions exposed by a freshly-created custom API — the consent often only +# covers Microsoft Graph permissions and silently skips the API. Without the +# API grant, MSAL.js acquireTokenSilent() fails on the SPA and the page is blank. +WEB_SP_ID="$(az ad sp show --id "$WEB_CLIENT_ID" --query id -o tsv 2>/dev/null || true)" +API_SP_ID="$(az ad sp show --id "$API_CLIENT_ID" --query id -o tsv 2>/dev/null || true)" +if [[ -n "$WEB_SP_ID" && -n "$API_SP_ID" ]]; then + EXISTING_GRANT="$(az rest --method get \ + --url "https://graph.microsoft.com/v1.0/servicePrincipals/${WEB_SP_ID}/oauth2PermissionGrants" \ + --query "value[?resourceId=='${API_SP_ID}'] | [0].id" -o tsv 2>/dev/null || true)" + if [[ -z "$EXISTING_GRANT" || "$EXISTING_GRANT" == "null" ]]; then + if az rest --method POST \ + --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" \ + --headers "Content-Type=application/json" \ + --body "{\"clientId\":\"${WEB_SP_ID}\",\"consentType\":\"AllPrincipals\",\"resourceId\":\"${API_SP_ID}\",\"scope\":\"user_impersonation\"}" \ + --output none 2>/dev/null; then + echo " ✓ API user_impersonation scope granted to Web SP" + else + echo " ⚠️ Could not auto-grant API user_impersonation; SPA may show blank page until granted manually." + CONSENT_OK=false + fi + else + echo " ↺ API user_impersonation scope already granted" + fi +fi + +# ----------------------------------------------------------------------------- +# Step 4: Client secrets + Container App secrets +# ----------------------------------------------------------------------------- +echo "" +echo "➡️ Step 4/6: Client secrets" + +CA_SECRET_NAME="microsoft-provider-authentication-secret" + +ensure_ca_secret_from_app_reg() { + local app_id="$1" + local ca_name="$2" + local existing + existing="$(az containerapp secret list -n "$ca_name" -g "$RESOURCE_GROUP" \ + --query "[?name=='$CA_SECRET_NAME'].name | [0]" -o tsv 2>/dev/null || true)" + if [[ -n "$existing" && "$existing" != "null" ]]; then + echo " ↺ Container App '$ca_name' already has '$CA_SECRET_NAME' — not rotating." + return 0 + fi + local secret + secret="$(az ad app credential reset --id "$app_id" --append \ + --display-name "containerapp-easyauth" --years 2 \ + --query password -o tsv)" + az containerapp secret set -n "$ca_name" -g "$RESOURCE_GROUP" \ + --secrets "${CA_SECRET_NAME}=${secret}" --output none + echo " ✓ Stored new client secret in '$ca_name'" +} + +ensure_ca_secret_from_app_reg "$API_CLIENT_ID" "$API_NAME" +ensure_ca_secret_from_app_reg "$WEB_CLIENT_ID" "$WEB_NAME" + +# ----------------------------------------------------------------------------- +# Step 5: Enable EasyAuth Microsoft provider on both Container Apps +# (allowUnauthenticated for now; env vars update next, strict last) +# ----------------------------------------------------------------------------- +echo "" +echo "➡️ Step 5/6: Enabling EasyAuth on Web + API container apps" + +configure_easyauth_app() { + local ca_name="$1" + local client_id="$2" + # Note: --tenant-id and --issuer are mutually exclusive; tenant-id derives + # the v2.0 issuer automatically. Do not override --allowed-token-audiences; + # EasyAuth issues ID tokens with aud=, which is the default. + az containerapp auth microsoft update -n "$ca_name" -g "$RESOURCE_GROUP" \ + --client-id "$client_id" \ + --client-secret-name "$CA_SECRET_NAME" \ + --tenant-id "$TENANT_ID" \ + --yes --output none +} + +configure_easyauth_app "$API_NAME" "$API_CLIENT_ID" +configure_easyauth_app "$WEB_NAME" "$WEB_CLIENT_ID" + +# Make sure auth is enabled and (temporarily) permissive so we can still push +# env vars / verify deployment. Final lockdown happens at the end. +az containerapp auth update -n "$WEB_NAME" -g "$RESOURCE_GROUP" \ + --enabled true --unauthenticated-client-action AllowAnonymous --output none +az containerapp auth update -n "$API_NAME" -g "$RESOURCE_GROUP" \ + --enabled true --unauthenticated-client-action AllowAnonymous --output none + +echo " ✓ EasyAuth providers configured" + +# ----------------------------------------------------------------------------- +# Step 6: Web env vars + API allowedApplications + final lockdown +# ----------------------------------------------------------------------------- +echo "" +echo "➡️ Step 6/6: Wiring env vars and caller allowlist" + +# Update Web container env vars (other values left untouched) +# Also overwrite APP_WEB_AUTHORITY to fix a pre-existing bicep bug that produces +# a malformed authority URL (double slash before tenant id). +az containerapp update -n "$WEB_NAME" -g "$RESOURCE_GROUP" \ + --set-env-vars \ + "APP_WEB_CLIENT_ID=$WEB_CLIENT_ID" \ + "APP_WEB_SCOPE=$WEB_SCOPE_VALUE" \ + "APP_API_SCOPE=$API_SCOPE_VALUE" \ + "APP_WEB_AUTHORITY=https://login.microsoftonline.com/$TENANT_ID" \ + "APP_AUTH_ENABLED=true" \ + --output none +echo " ✓ Web env vars: APP_WEB_CLIENT_ID / APP_WEB_SCOPE / APP_API_SCOPE / APP_WEB_AUTHORITY / APP_AUTH_ENABLED" + +# Patch both authConfigs: +# - API: add Web client id to allowedApplications +# - Both: reset allowedAudiences to only the clientId, normalize openIdIssuer +patch_authconfig() { + local ca_name="$1" + local client_id="$2" + local add_web_allowed="$3" # "true" (API side) / "false" (Web side) + local url="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.App/containerApps/${ca_name}/authConfigs/current?api-version=2024-03-01" + local cur patched + cur="$(az rest --method get --url "$url")" + patched="$(echo "$cur" | ADD_WEB="$add_web_allowed" WEB_CLIENT_ID="$WEB_CLIENT_ID" CLIENT_ID="$client_id" TENANT_ID="$TENANT_ID" python3 -c " +import json, os, sys +d = json.load(sys.stdin) +props = d.setdefault('properties', {}) +props['platform'] = props.get('platform') or {} +props['platform']['enabled'] = True +idp = props.setdefault('identityProviders', {}) +aad = idp.setdefault('azureActiveDirectory', {}) +reg = aad.setdefault('registration', {}) +reg['openIdIssuer'] = f\"https://login.microsoftonline.com/{os.environ['TENANT_ID']}/v2.0\" +val = aad.setdefault('validation', {}) +val['allowedAudiences'] = [os.environ['CLIENT_ID'], 'api://' + os.environ['CLIENT_ID']] +policy = val.setdefault('defaultAuthorizationPolicy', {}) +allowed = set(policy.get('allowedApplications') or []) +if os.environ['ADD_WEB'] == 'true': + allowed.add(os.environ['WEB_CLIENT_ID']) +policy['allowedApplications'] = sorted(allowed) +gv = props.setdefault('globalValidation', {}) +gv['requireAuthentication'] = True +if os.environ['ADD_WEB'] == 'true': + gv['unauthenticatedClientAction'] = 'Return401' + gv.pop('redirectToProvider', None) +else: + gv['unauthenticatedClientAction'] = 'RedirectToLoginPage' + gv['redirectToProvider'] = 'azureactivedirectory' +print(json.dumps(d)) +")" + echo "$patched" > /tmp/authconfig_patch.json + retry az rest --method put --url "$url" \ + --headers "Content-Type=application/json" \ + --body @/tmp/authconfig_patch.json >/dev/null + rm -f /tmp/authconfig_patch.json +} + +patch_authconfig "$API_NAME" "$API_CLIENT_ID" "true" +patch_authconfig "$WEB_NAME" "$WEB_CLIENT_ID" "false" +echo " ✓ authConfigs normalized (issuer, audiences, allowedApplications)" + +# Final lockdown handled in patch_authconfig globalValidation above. +echo " ✓ Unauthenticated requests: Web → login, API → 401" + +# Restart active revisions so containers pick up newly-set client secrets. +# (`az containerapp secret set` does NOT trigger a new revision on its own.) +restart_active_revision() { + local ca_name="$1" + local rev + rev="$(az containerapp revision list -n "$ca_name" -g "$RESOURCE_GROUP" \ + --query "[?properties.active] | [0].name" -o tsv 2>/dev/null || true)" + if [[ -n "$rev" && "$rev" != "null" ]]; then + az containerapp revision restart -n "$ca_name" -g "$RESOURCE_GROUP" \ + --revision "$rev" --output none 2>/dev/null || true + fi +} +restart_active_revision "$WEB_NAME" +restart_active_revision "$API_NAME" +echo " ✓ Restarted Web + API container revisions to apply secrets" + +echo "" +echo "============================================================" +echo "🔐 Auth configuration complete." +echo " Web client id : $WEB_CLIENT_ID" +echo " API client id : $API_CLIENT_ID" +echo " Web scope : $WEB_SCOPE_VALUE" +echo " API scope : $API_SCOPE_VALUE" +if [[ "$CONSENT_OK" != "true" ]]; then + echo " ⚠️ Admin consent pending — see step 3 above." +fi +if [[ "$CONSENT_PRECHECK_OK" != "true" ]]; then + echo " ⚠️ Permission pre-check predicted admin-consent limitations for this identity." +fi +echo " Note: EasyAuth rollout can take up to 10 minutes." +echo "============================================================" diff --git a/infra/scripts/post_deployment.ps1 b/infra/scripts/post_deployment.ps1 index aa116003..f942472a 100644 --- a/infra/scripts/post_deployment.ps1 +++ b/infra/scripts/post_deployment.ps1 @@ -1,7 +1,7 @@ # Stop script on any error $ErrorActionPreference = "Stop" -Write-Host "[Search] Fetching container app info from azd environment..." +Write-Host "- Fetching container app info from azd environment..." # Load values from azd env $CONTAINER_WEB_APP_NAME = azd env get-value CONTAINER_WEB_APP_NAME @@ -30,27 +30,32 @@ $DataScriptPath = Join-Path $ScriptDir "..\..\src\ContentProcessorAPI\samples\sc # Resolve to an absolute path $FullPath = Resolve-Path $DataScriptPath +$PostDeploymentMode = if ($env:POST_DEPLOYMENT_MODE) { $env:POST_DEPLOYMENT_MODE } else { "all" } +if ($PostDeploymentMode -notin @("all", "schema", "sample-data")) { + throw "Unsupported POST_DEPLOYMENT_MODE '$PostDeploymentMode'. Use one of: all, schema, sample-data." +} + # Output Write-Host "" -Write-Host "[Info] Web App Details:" -Write-Host " [OK] Name: $CONTAINER_WEB_APP_NAME" -Write-Host " [URL] Endpoint: $CONTAINER_WEB_APP_FQDN" -Write-Host " [Link] Portal URL: $WEB_APP_PORTAL_URL" +Write-Host "- Web App Details:" +Write-Host " - Name: $CONTAINER_WEB_APP_NAME" +Write-Host " - Endpoint: $CONTAINER_WEB_APP_FQDN" +Write-Host " - Portal URL: $WEB_APP_PORTAL_URL" Write-Host "" -Write-Host "[Info] API App Details:" -Write-Host " [OK] Name: $CONTAINER_API_APP_NAME" -Write-Host " [URL] Endpoint: $CONTAINER_API_APP_FQDN" -Write-Host " [Link] Portal URL: $API_APP_PORTAL_URL" +Write-Host "- API App Details:" +Write-Host " - Name: $CONTAINER_API_APP_NAME" +Write-Host " - Endpoint: $CONTAINER_API_APP_FQDN" +Write-Host " - Portal URL: $API_APP_PORTAL_URL" Write-Host "" -Write-Host "[Info] Workflow App Details:" -Write-Host " [OK] Name: $CONTAINER_WORKFLOW_APP_NAME" -Write-Host " [Link] Portal URL: $WORKFLOW_APP_PORTAL_URL" +Write-Host "- Workflow App Details:" +Write-Host " - Name: $CONTAINER_WORKFLOW_APP_NAME" +Write-Host " - Portal URL: $WORKFLOW_APP_PORTAL_URL" Write-Host "" -Write-Host "[Package] Registering schemas and creating schema set..." -Write-Host " [Wait] Waiting for API to be ready..." +Write-Host "- Post-deployment mode: $PostDeploymentMode" +Write-Host " - Waiting for API to be ready..." $MaxRetries = 10 $RetryInterval = 15 @@ -61,7 +66,7 @@ for ($i = 1; $i -le $MaxRetries; $i++) { try { $response = Invoke-WebRequest -Uri "$ApiBaseUrl/schemavault/" -Method GET -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop if ($response.StatusCode -eq 200) { - Write-Host " [OK] API is ready." + Write-Host " - API is ready." $ApiReady = $true break } @@ -76,166 +81,339 @@ if (-not $ApiReady) { Write-Host " API did not become ready after $MaxRetries attempts. Skipping schema registration." Write-Host " Run manually after the API is ready." } else { - # ---------- Schema registration (no Python dependency) ---------- $SchemaInfoFile = Join-Path $FullPath "schema_info.json" $Manifest = Get-Content $SchemaInfoFile -Raw | ConvertFrom-Json $SchemaVaultUrl = "$ApiBaseUrl/schemavault/" $SchemaSetVaultUrl = "$ApiBaseUrl/schemasetvault/" + $SetName = $Manifest.schemaset.Name + $SetDesc = $Manifest.schemaset.Description + $Registered = @{} + $SchemaSetId = $null - # --- Step 1: Register schemas --- - Write-Host "" - Write-Host ("=" * 60) - Write-Host "Step 1: Register schemas" - Write-Host ("=" * 60) - - # Fetch existing schemas - $ExistingSchemas = @() - try { - $ExistingSchemas = Invoke-RestMethod -Uri $SchemaVaultUrl -Method GET -TimeoutSec 30 -ErrorAction Stop - Write-Host "Fetched $($ExistingSchemas.Count) existing schema(s)." - } catch { - Write-Host "Warning: Could not fetch existing schemas. Proceeding..." - } - - $Registered = @{} # ClassName -> schema Id - - foreach ($entry in $Manifest.schemas) { - $ClassName = $entry.ClassName - $Description = $entry.Description - $SchemaFile = Join-Path $FullPath $entry.File - + if ($PostDeploymentMode -eq "sample-data") { Write-Host "" - Write-Host "Processing schema: $ClassName" + Write-Host ("=" * 60) + Write-Host "Resolving existing schemas and schema set for sample data upload" + Write-Host ("=" * 60) - if (-not (Test-Path $SchemaFile)) { - Write-Host "Error: Schema file '$SchemaFile' does not exist. Skipping..." - continue + $ExistingSchemas = @() + try { + $ExistingSchemas = Invoke-RestMethod -Uri $SchemaVaultUrl -Method GET -TimeoutSec 30 -ErrorAction Stop + } catch { + Write-Host "Warning: Could not fetch existing schemas. Proceeding..." } - # Check if already registered - $existing = $ExistingSchemas | Where-Object { $_.ClassName -eq $ClassName } | Select-Object -First 1 - if ($existing) { - $schemaId = $existing.Id - Write-Host " Schema '$ClassName' already exists with ID: $schemaId" - $Registered[$ClassName] = $schemaId - continue + foreach ($entry in $Manifest.schemas) { + $existing = $ExistingSchemas | Where-Object { $_.ClassName -eq $entry.ClassName } | Select-Object -First 1 + if ($existing) { + $Registered[$entry.ClassName] = $existing.Id + } else { + Write-Host " ⚠️ Schema '$($entry.ClassName)' is not registered. Run schema registration first." + } } - Write-Host " Registering new schema '$ClassName'..." + $ExistingSets = @() + try { + $ExistingSets = Invoke-RestMethod -Uri $SchemaSetVaultUrl -Method GET -TimeoutSec 30 -ErrorAction Stop + } catch { + Write-Host "Warning: Could not fetch existing schema sets. Proceeding..." + } - # Only JSON Schema descriptors are accepted. The legacy .py format - # was removed as part of the schemavault RCE remediation. - $extension = [System.IO.Path]::GetExtension($SchemaFile).ToLowerInvariant() - if ($extension -ne '.json') { - Write-Host " Unsupported schema extension '$extension' for '$SchemaFile'. Only .json is accepted. Skipping..." - continue + $existingSet = $ExistingSets | Where-Object { $_.Name -eq $SetName } | Select-Object -First 1 + if ($existingSet) { + $SchemaSetId = $existingSet.Id + Write-Host " ✅ Using existing schema set '$SetName' ($SchemaSetId)" + } else { + Write-Host " ⚠️ Schema set '$SetName' does not exist yet. Run schema registration first." } - $contentType = 'application/json' - - # Build multipart form data - $dataPayload = @{ ClassName = $ClassName; Description = $Description } | ConvertTo-Json -Compress - $fileBytes = [System.IO.File]::ReadAllBytes($SchemaFile) - $fileName = [System.IO.Path]::GetFileName($SchemaFile) - - $boundary = [System.Guid]::NewGuid().ToString() - $LF = "`r`n" - $bodyLines = ( - "--$boundary", - "Content-Disposition: form-data; name=`"data`"$LF", - $dataPayload, - "--$boundary", - "Content-Disposition: form-data; name=`"file`"; filename=`"$fileName`"", - "Content-Type: $contentType$LF", - [System.Text.Encoding]::UTF8.GetString($fileBytes), - "--$boundary--$LF" - ) -join $LF + } else { + Write-Host "" + Write-Host ("=" * 60) + Write-Host "Step 1: Register schemas" + Write-Host ("=" * 60) + $ExistingSchemas = @() try { - $resp = Invoke-RestMethod -Uri $SchemaVaultUrl -Method POST ` - -ContentType "multipart/form-data; boundary=$boundary" ` - -Body $bodyLines -TimeoutSec 60 -ErrorAction Stop - $schemaId = $resp.Id - Write-Host " Successfully registered: $Description's Schema Id - $schemaId" - $Registered[$ClassName] = $schemaId + $ExistingSchemas = Invoke-RestMethod -Uri $SchemaVaultUrl -Method GET -TimeoutSec 30 -ErrorAction Stop + Write-Host "Fetched $($ExistingSchemas.Count) existing schema(s)." } catch { - Write-Host " Failed to upload '$fileName'. Error: $_" + Write-Host "Warning: Could not fetch existing schemas. Proceeding..." } - } - # --- Step 2: Create schema set --- - Write-Host "" - Write-Host ("=" * 60) - Write-Host "Step 2: Create schema set" - Write-Host ("=" * 60) + foreach ($entry in $Manifest.schemas) { + $ClassName = $entry.ClassName + $Description = $entry.Description + $SchemaFile = Join-Path $FullPath $entry.File - $SetName = $Manifest.schemaset.Name - $SetDesc = $Manifest.schemaset.Description + Write-Host "" + Write-Host "Processing schema: $ClassName" - $ExistingSets = @() - try { - $ExistingSets = Invoke-RestMethod -Uri $SchemaSetVaultUrl -Method GET -TimeoutSec 30 -ErrorAction Stop - Write-Host "Fetched $($ExistingSets.Count) existing schema set(s)." - } catch { - Write-Host "Warning: Could not fetch existing schema sets. Proceeding..." - } + if (-not (Test-Path $SchemaFile)) { + Write-Host "Error: Schema file '$SchemaFile' does not exist. Skipping..." + continue + } - $SchemaSetId = $null - $existingSet = $ExistingSets | Where-Object { $_.Name -eq $SetName } | Select-Object -First 1 - if ($existingSet) { - $SchemaSetId = $existingSet.Id - Write-Host " Schema set '$SetName' already exists with ID: $SchemaSetId" - } else { - Write-Host " Creating schema set '$SetName'..." + $existing = $ExistingSchemas | Where-Object { $_.ClassName -eq $ClassName } | Select-Object -First 1 + if ($existing) { + $schemaId = $existing.Id + Write-Host " Schema '$ClassName' already exists with ID: $schemaId" + $Registered[$ClassName] = $schemaId + continue + } + + Write-Host " Registering new schema '$ClassName'..." + + $extension = [System.IO.Path]::GetExtension($SchemaFile).ToLowerInvariant() + if ($extension -ne '.json') { + Write-Host " Unsupported schema extension '$extension' for '$SchemaFile'. Only .json is accepted. Skipping..." + continue + } + $contentType = 'application/json' + + $dataPayload = @{ ClassName = $ClassName; Description = $Description } | ConvertTo-Json -Compress + $fileBytes = [System.IO.File]::ReadAllBytes($SchemaFile) + $fileName = [System.IO.Path]::GetFileName($SchemaFile) + + $boundary = [System.Guid]::NewGuid().ToString() + $LF = "`r`n" + $bodyLines = ( + "--$boundary", + "Content-Disposition: form-data; name=`"data`"$LF", + $dataPayload, + "--$boundary", + "Content-Disposition: form-data; name=`"file`"; filename=`"$fileName`"", + "Content-Type: $contentType$LF", + [System.Text.Encoding]::UTF8.GetString($fileBytes), + "--$boundary--$LF" + ) -join $LF + + try { + $resp = Invoke-RestMethod -Uri $SchemaVaultUrl -Method POST ` + -ContentType "multipart/form-data; boundary=$boundary" ` + -Body $bodyLines -TimeoutSec 60 -ErrorAction Stop + $schemaId = $resp.Id + Write-Host " Successfully registered: $Description's Schema Id - $schemaId" + $Registered[$ClassName] = $schemaId + } catch { + Write-Host " Failed to upload '$fileName'. Error: $_" + } + } + + Write-Host "" + Write-Host ("=" * 60) + Write-Host "Step 2: Create schema set" + Write-Host ("=" * 60) + + $ExistingSets = @() try { - $setResp = Invoke-RestMethod -Uri $SchemaSetVaultUrl -Method POST ` - -ContentType "application/json" ` - -Body (@{ Name = $SetName; Description = $SetDesc } | ConvertTo-Json) ` - -TimeoutSec 30 -ErrorAction Stop - $SchemaSetId = $setResp.Id - Write-Host " Created schema set '$SetName' with ID: $SchemaSetId" + $ExistingSets = Invoke-RestMethod -Uri $SchemaSetVaultUrl -Method GET -TimeoutSec 30 -ErrorAction Stop + Write-Host "Fetched $($ExistingSets.Count) existing schema set(s)." } catch { - Write-Host " Failed to create schema set. Error: $_" + Write-Host "Warning: Could not fetch existing schema sets. Proceeding..." + } + + $existingSet = $ExistingSets | Where-Object { $_.Name -eq $SetName } | Select-Object -First 1 + if ($existingSet) { + $SchemaSetId = $existingSet.Id + Write-Host " Schema set '$SetName' already exists with ID: $SchemaSetId" + } else { + Write-Host " Creating schema set '$SetName'..." + try { + $setResp = Invoke-RestMethod -Uri $SchemaSetVaultUrl -Method POST ` + -ContentType "application/json" ` + -Body (@{ Name = $SetName; Description = $SetDesc } | ConvertTo-Json) ` + -TimeoutSec 30 -ErrorAction Stop + $SchemaSetId = $setResp.Id + Write-Host " Created schema set '$SetName' with ID: $SchemaSetId" + } catch { + Write-Host " Failed to create schema set. Error: $_" + } } + + if (-not $SchemaSetId) { + Write-Host "Error: Could not create or find schema set. Aborting step 3." + } else { + Write-Host "" + Write-Host ("=" * 60) + Write-Host "Step 3: Add schemas to schema set" + Write-Host ("=" * 60) + + $AlreadyInSet = @() + try { + $AlreadyInSet = Invoke-RestMethod -Uri "$SchemaSetVaultUrl$SchemaSetId/schemas" -Method GET -TimeoutSec 30 -ErrorAction Stop + } catch { } + $AlreadyInSetIds = $AlreadyInSet | ForEach-Object { $_.Id } + + foreach ($className in $Registered.Keys) { + $schemaId = $Registered[$className] + if ($AlreadyInSetIds -contains $schemaId) { + Write-Host " Schema '$className' ($schemaId) already in schema set - skipped" + continue + } + + try { + Invoke-RestMethod -Uri "$SchemaSetVaultUrl$SchemaSetId/schemas" -Method POST ` + -ContentType "application/json" ` + -Body (@{ SchemaId = $schemaId } | ConvertTo-Json) ` + -TimeoutSec 30 -ErrorAction Stop | Out-Null + Write-Host " Added '$className' ($schemaId) to schema set" + } catch { + Write-Host " Failed to add '$className' to schema set. Error: $_" + } + } + } + + Write-Host "" + Write-Host ("=" * 60) + Write-Host "Schema registration process completed." + Write-Host " Schemas registered: $($Registered.Count)" + Write-Host ("=" * 60) } - if (-not $SchemaSetId) { - Write-Host "Error: Could not create or find schema set. Aborting step 3." - } else { - # --- Step 3: Add schemas to schema set --- + if ($PostDeploymentMode -eq "schema") { + Write-Host "" + Write-Host ("=" * 60) + Write-Host "Sample data upload skipped because POST_DEPLOYMENT_MODE=schema" + Write-Host "Next explicit step: run `$env:POST_DEPLOYMENT_MODE='sample-data'; ./infra/scripts/post_deployment.ps1" + Write-Host ("=" * 60) + } elseif ($SchemaSetId -and $Registered.Count -gt 0) { Write-Host "" Write-Host ("=" * 60) - Write-Host "Step 3: Add schemas to schema set" + Write-Host "Step 4: Process sample file bundles" Write-Host ("=" * 60) - $AlreadyInSet = @() - try { - $AlreadyInSet = Invoke-RestMethod -Uri "$SchemaSetVaultUrl$SchemaSetId/schemas" -Method GET -TimeoutSec 30 -ErrorAction Stop - } catch { } - $AlreadyInSetIds = $AlreadyInSet | ForEach-Object { $_.Id } - - foreach ($className in $Registered.Keys) { - $schemaId = $Registered[$className] - if ($AlreadyInSetIds -contains $schemaId) { - Write-Host " Schema '$className' ($schemaId) already in schema set - skipped" + $SamplesDir = Resolve-Path (Join-Path $ScriptDir "..\..\src\ContentProcessorAPI\samples") + $BundleFolders = @("claim_date_of_loss", "claim_hail") + $ClaimProcessorUrl = "$ApiBaseUrl/claimprocessor/claims" + + foreach ($bundle in $BundleFolders) { + $bundleDir = Join-Path $SamplesDir $bundle + $bundleInfoPath = Join-Path $bundleDir "bundle_info.json" + + if (-not (Test-Path $bundleInfoPath)) { + Write-Host " Skipping '$bundle' - no bundle_info.json found." continue } + Write-Host "" + Write-Host " Processing bundle: $bundle" + + $bundleManifest = Get-Content $bundleInfoPath -Raw | ConvertFrom-Json + + # Step 4a: Create claim batch with schemaset ID + Write-Host " - Creating claim batch..." try { - Invoke-RestMethod -Uri "$SchemaSetVaultUrl$SchemaSetId/schemas" -Method POST ` + $claimResp = Invoke-RestMethod -Uri $ClaimProcessorUrl -Method PUT ` -ContentType "application/json" ` - -Body (@{ SchemaId = $schemaId } | ConvertTo-Json) ` - -TimeoutSec 30 -ErrorAction Stop | Out-Null - Write-Host " Added '$className' ($schemaId) to schema set" + -Body (@{ schema_collection_id = $SchemaSetId } | ConvertTo-Json) ` + -TimeoutSec 30 -ErrorAction Stop + $claimId = $claimResp.claim_id + Write-Host " - Claim batch created with ID: $claimId" } catch { - Write-Host " Failed to add '$className' to schema set. Error: $_" + Write-Host " - Failed to create claim batch. Error: $_" + continue + } + + # Step 4b: Upload each file with its mapped schema ID + Add-Type -AssemblyName System.Net.Http + $httpClient = New-Object System.Net.Http.HttpClient + $httpClient.Timeout = [TimeSpan]::FromSeconds(60) + $uploadSuccess = $true + foreach ($entry in $bundleManifest.files) { + $schemaClass = $entry.schema_class + $fileName = $entry.file_name + $filePath = Join-Path $bundleDir $fileName + + if (-not (Test-Path $filePath)) { + Write-Host " - File '$fileName' not found. Skipping." + continue + } + + $schemaId = $Registered[$schemaClass] + if (-not $schemaId) { + Write-Host " - No schema ID found for '$schemaClass'. Marking bundle upload as failed and skipping submission." + $uploadSuccess = $false + break + } + + Write-Host " - Uploading '$fileName' (schema: $schemaClass)..." + + $dataPayload = @{ + Claim_Id = $claimId + Schema_Id = $schemaId + Metadata_Id = "sample-$bundle" + } | ConvertTo-Json -Compress + + $fileBytes = [System.IO.File]::ReadAllBytes((Resolve-Path $filePath)) + $mimeType = switch ([System.IO.Path]::GetExtension($fileName).ToLower()) { + ".pdf" { "application/pdf" } + ".png" { "image/png" } + ".jpg" { "image/jpeg" } + ".jpeg" { "image/jpeg" } + default { "application/octet-stream" } + } + + try { + $multipartContent = New-Object System.Net.Http.MultipartFormDataContent + $jsonContent = [System.Net.Http.StringContent]::new($dataPayload, [System.Text.Encoding]::UTF8, "application/json") + $jsonContent.Headers.ContentDisposition = [System.Net.Http.Headers.ContentDispositionHeaderValue]::Parse("form-data; name=`"data`"") + $multipartContent.Add($jsonContent, "data") + + $fileContent = [System.Net.Http.ByteArrayContent]::new($fileBytes) + $fileContent.Headers.ContentDisposition = [System.Net.Http.Headers.ContentDispositionHeaderValue]::Parse("form-data; name=`"file`"; filename=`"$fileName`"") + $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse($mimeType) + $multipartContent.Add($fileContent, "file", $fileName) + + $response = $httpClient.PostAsync("$ClaimProcessorUrl/$claimId/files", $multipartContent).Result + $responseBody = $response.Content.ReadAsStringAsync().Result + + if ($response.IsSuccessStatusCode) { + Write-Host " - Uploaded '$fileName' successfully." + } else { + Write-Host " - Failed to upload '$fileName'. HTTP Status: $($response.StatusCode)" + Write-Host " - Error: $responseBody" + $uploadSuccess = $false + } + } catch { + Write-Host " - Failed to upload '$fileName'. Error: $_" + $uploadSuccess = $false + } + } + $httpClient.Dispose() + + # Step 4c: Launch processing + if ($uploadSuccess) { + Write-Host " - Submitting claim batch for processing..." + try { + Invoke-RestMethod -Uri $ClaimProcessorUrl -Method POST ` + -ContentType "application/json" ` + -Body (@{ claim_process_id = $claimId } | ConvertTo-Json) ` + -TimeoutSec 30 -ErrorAction Stop | Out-Null + Write-Host " - Claim batch '$claimId' submitted for processing." + } catch { + Write-Host " - Failed to submit claim batch. Error: $_" + } + } else { + Write-Host " - Skipping batch submission due to upload failures." } } - } - Write-Host "" - Write-Host ("=" * 60) - Write-Host "Schema registration process completed." - Write-Host " Schemas registered: $($Registered.Count)" - Write-Host ("=" * 60) + Write-Host "" + Write-Host ("=" * 60) + Write-Host "Sample file processing completed." + Write-Host ("=" * 60) + } else { + Write-Host "" + Write-Host ("=" * 60) + Write-Host "Sample data upload skipped because required schemas or schema set were not found." + Write-Host "Run schema registration first, then re-run with POST_DEPLOYMENT_MODE=sample-data." + Write-Host ("=" * 60) + } } + +Write-Host "" +Write-Host ("=" * 60) +Write-Host "Post-deployment data setup completed." +Write-Host "Next manual step: configure authentication using infra/scripts/configure_auth.ps1" +Write-Host ("=" * 60) diff --git a/infra/scripts/post_deployment.sh b/infra/scripts/post_deployment.sh index 49644a4d..2175d956 100644 --- a/infra/scripts/post_deployment.sh +++ b/infra/scripts/post_deployment.sh @@ -40,6 +40,15 @@ DATA_SCRIPT_PATH="$SCRIPT_DIR/../../src/ContentProcessorAPI/samples/schemas" # Normalize the path (optional, in case of ../..) DATA_SCRIPT_PATH="$(realpath "$DATA_SCRIPT_PATH")" +POST_DEPLOYMENT_MODE="${POST_DEPLOYMENT_MODE:-all}" +case "$POST_DEPLOYMENT_MODE" in + all|schema|sample-data) ;; + *) + echo "❌ Unsupported POST_DEPLOYMENT_MODE '$POST_DEPLOYMENT_MODE'. Use one of: all, schema, sample-data." >&2 + exit 1 + ;; +esac + # Output echo "" echo "🧭 Web App Details:" @@ -59,7 +68,7 @@ echo " ✅ Name: $CONTAINER_WORKFLOW_APP_NAME" echo " 🔗 Portal URL: $WORKFLOW_APP_PORTAL_URL" echo "" -echo "📦 Registering schemas and creating schema set..." +echo "📦 Post-deployment mode: $POST_DEPLOYMENT_MODE" echo " ⏳ Waiting for API to be ready..." MAX_RETRIES=10 @@ -80,172 +89,319 @@ if [ "$STATUS" != "200" ]; then echo " API did not become ready after $MAX_RETRIES attempts. Skipping schema registration." echo " Run manually after the API is ready." else - # ---------- Schema registration (no Python dependency) ---------- SCHEMA_INFO_FILE="$DATA_SCRIPT_PATH/schema_info.json" SCHEMAVAULT_URL="$API_BASE_URL/schemavault/" SCHEMASETVAULT_URL="$API_BASE_URL/schemasetvault/" - - # --- Step 1: Register schemas --- - echo "" - echo "============================================================" - echo "Step 1: Register schemas" - echo "============================================================" - - # Fetch existing schemas - EXISTING_SCHEMAS=$(curl -s "$SCHEMAVAULT_URL" 2>/dev/null || echo "[]") - EXISTING_COUNT=$(echo "$EXISTING_SCHEMAS" | grep -o '"Id"' | wc -l) - echo "Fetched $EXISTING_COUNT existing schema(s)." - - # Read schema entries from manifest - SCHEMA_COUNT=$(cat "$SCHEMA_INFO_FILE" | grep -o '"File"' | wc -l) + SET_NAME=$(cat "$SCHEMA_INFO_FILE" | grep -A2 '"schemaset"' | grep '"Name"' | sed 's/.*"Name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') + SET_DESC=$(cat "$SCHEMA_INFO_FILE" | grep -A3 '"schemaset"' | grep '"Description"' | sed 's/.*"Description"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') REGISTERED_IDS=() REGISTERED_NAMES=() + SCHEMASET_ID="" - for idx in $(seq 0 $((SCHEMA_COUNT - 1))); do - # Parse entry fields using grep/sed (no python needed) - ENTRY=$(cat "$SCHEMA_INFO_FILE") - FILE_NAME=$(echo "$ENTRY" | grep -o '"File"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((idx + 1))p" | sed 's/.*"File"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - CLASS_NAME=$(echo "$ENTRY" | grep -o '"ClassName"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((idx + 1))p" | sed 's/.*"ClassName"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - DESCRIPTION=$(echo "$ENTRY" | grep -o '"Description"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((idx + 1))p" | sed 's/.*"Description"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - - SCHEMA_FILE="$DATA_SCRIPT_PATH/$FILE_NAME" + SCHEMA_COUNT=$(cat "$SCHEMA_INFO_FILE" | grep -o '"File"' | wc -l) + if [ "$POST_DEPLOYMENT_MODE" = "sample-data" ]; then echo "" - echo "Processing schema: $CLASS_NAME" + echo "============================================================" + echo "Resolving existing schemas and schema set for sample data upload" + echo "============================================================" - if [ ! -f "$SCHEMA_FILE" ]; then - echo "Error: Schema file '$SCHEMA_FILE' does not exist. Skipping..." - continue - fi + EXISTING_SCHEMAS=$(curl -s "$SCHEMAVAULT_URL" 2>/dev/null || echo "[]") + for idx in $(seq 0 $((SCHEMA_COUNT - 1))); do + ENTRY=$(cat "$SCHEMA_INFO_FILE") + CLASS_NAME=$(echo "$ENTRY" | grep -o '"ClassName"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((idx + 1))p" | sed 's/.*"ClassName"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') + EXISTING_ID=$(echo "$EXISTING_SCHEMAS" | sed 's/},/}\n/g' | grep "\"ClassName\"[[:space:]]*:[[:space:]]*\"$CLASS_NAME\"" | grep -o '"Id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true) + if [ -n "$EXISTING_ID" ]; then + REGISTERED_IDS+=("$EXISTING_ID") + REGISTERED_NAMES+=("$CLASS_NAME") + else + echo " ⚠️ Schema '$CLASS_NAME' is not registered. Run schema registration first." + fi + done - # Check if already registered - EXISTING_ID="" - # Use a simple approach: look for the ClassName in the existing schemas response - if echo "$EXISTING_SCHEMAS" | grep -q "\"ClassName\"[[:space:]]*:[[:space:]]*\"$CLASS_NAME\""; then - # Extract the Id for this ClassName – find the object containing it - EXISTING_ID=$(echo "$EXISTING_SCHEMAS" | sed 's/},/}\n/g' | grep "\"ClassName\"[[:space:]]*:[[:space:]]*\"$CLASS_NAME\"" | grep -o '"Id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + EXISTING_SETS=$(curl -s "$SCHEMASETVAULT_URL" 2>/dev/null || echo "[]") + if echo "$EXISTING_SETS" | grep -q "\"Name\"[[:space:]]*:[[:space:]]*\"$SET_NAME\""; then + SCHEMASET_ID=$(echo "$EXISTING_SETS" | sed 's/},/}\n/g' | grep "\"Name\"[[:space:]]*:[[:space:]]*\"$SET_NAME\"" | grep -o '"Id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + echo " ✅ Using existing schema set '$SET_NAME' ($SCHEMASET_ID)" + else + echo " ⚠️ Schema set '$SET_NAME' does not exist yet. Run schema registration first." fi + else + # ---------- Schema registration (no Python dependency) ---------- + echo "" + echo "============================================================" + echo "Step 1: Register schemas" + echo "============================================================" - if [ -n "$EXISTING_ID" ]; then - echo " Schema '$CLASS_NAME' already exists with ID: $EXISTING_ID" - REGISTERED_IDS+=("$EXISTING_ID") - REGISTERED_NAMES+=("$CLASS_NAME") - continue - fi + EXISTING_SCHEMAS=$(curl -s "$SCHEMAVAULT_URL" 2>/dev/null || echo "[]") + EXISTING_COUNT=$(echo "$EXISTING_SCHEMAS" | grep -o '"Id"' | wc -l) + echo "Fetched $EXISTING_COUNT existing schema(s)." - echo " Registering new schema '$CLASS_NAME'..." - DATA_PAYLOAD="{\"ClassName\": \"$CLASS_NAME\", \"Description\": \"$DESCRIPTION\"}" + for idx in $(seq 0 $((SCHEMA_COUNT - 1))); do + ENTRY=$(cat "$SCHEMA_INFO_FILE") + FILE_NAME=$(echo "$ENTRY" | grep -o '"File"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((idx + 1))p" | sed 's/.*"File"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') + CLASS_NAME=$(echo "$ENTRY" | grep -o '"ClassName"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((idx + 1))p" | sed 's/.*"ClassName"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') + DESCRIPTION=$(echo "$ENTRY" | grep -o '"Description"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((idx + 1))p" | sed 's/.*"Description"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - # Only JSON Schema descriptors are accepted. The legacy .py format - # was removed as part of the schemavault RCE remediation. - EXT=$(echo "${FILE_NAME##*.}" | tr '[:upper:]' '[:lower:]') - if [ "$EXT" != "json" ]; then - echo " Unsupported schema extension '.$EXT' for '$FILE_NAME'. Only .json is accepted. Skipping..." - continue - fi - CONTENT_TYPE="application/json" - - RESPONSE=$(curl -s -w "\n%{http_code}" \ - -X POST "$SCHEMAVAULT_URL" \ - -F "data=$DATA_PAYLOAD" \ - -F "file=@$SCHEMA_FILE;type=$CONTENT_TYPE" \ - --connect-timeout 60) - - HTTP_CODE=$(echo "$RESPONSE" | tail -1) - BODY=$(echo "$RESPONSE" | sed '$d') - - if [ "$HTTP_CODE" = "200" ]; then - SCHEMA_ID=$(echo "$BODY" | sed 's/.*"Id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - echo " Successfully registered: $DESCRIPTION's Schema Id - $SCHEMA_ID" - REGISTERED_IDS+=("$SCHEMA_ID") - REGISTERED_NAMES+=("$CLASS_NAME") - else - echo " Failed to upload '$FILE_NAME'. HTTP Status: $HTTP_CODE" - echo " Error Response: $BODY" - fi - done + SCHEMA_FILE="$DATA_SCRIPT_PATH/$FILE_NAME" - # --- Step 2: Create schema set --- - echo "" - echo "============================================================" - echo "Step 2: Create schema set" - echo "============================================================" + echo "" + echo "Processing schema: $CLASS_NAME" - # Parse schemaset config from manifest - SET_NAME=$(cat "$SCHEMA_INFO_FILE" | grep -A2 '"schemaset"' | grep '"Name"' | sed 's/.*"Name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - SET_DESC=$(cat "$SCHEMA_INFO_FILE" | grep -A3 '"schemaset"' | grep '"Description"' | sed 's/.*"Description"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') + if [ ! -f "$SCHEMA_FILE" ]; then + echo "Error: Schema file '$SCHEMA_FILE' does not exist. Skipping..." + continue + fi - # Fetch existing schema sets - EXISTING_SETS=$(curl -s "$SCHEMASETVAULT_URL" 2>/dev/null || echo "[]") + EXISTING_ID="" + if echo "$EXISTING_SCHEMAS" | grep -q "\"ClassName\"[[:space:]]*:[[:space:]]*\"$CLASS_NAME\""; then + EXISTING_ID=$(echo "$EXISTING_SCHEMAS" | sed 's/},/}\n/g' | grep "\"ClassName\"[[:space:]]*:[[:space:]]*\"$CLASS_NAME\"" | grep -o '"Id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + fi - SCHEMASET_ID="" - if echo "$EXISTING_SETS" | grep -q "\"Name\"[[:space:]]*:[[:space:]]*\"$SET_NAME\""; then - SCHEMASET_ID=$(echo "$EXISTING_SETS" | sed 's/},/}\n/g' | grep "\"Name\"[[:space:]]*:[[:space:]]*\"$SET_NAME\"" | grep -o '"Id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') - echo " Schema set '$SET_NAME' already exists with ID: $SCHEMASET_ID" - else - echo " Creating schema set '$SET_NAME'..." - RESPONSE=$(curl -s -w "\n%{http_code}" \ - -X POST "$SCHEMASETVAULT_URL" \ - -H "Content-Type: application/json" \ - -d "{\"Name\": \"$SET_NAME\", \"Description\": \"$SET_DESC\"}" \ - --connect-timeout 30) - - HTTP_CODE=$(echo "$RESPONSE" | tail -1) - BODY=$(echo "$RESPONSE" | sed '$d') - - if [ "$HTTP_CODE" = "200" ]; then - SCHEMASET_ID=$(echo "$BODY" | sed 's/.*"Id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') - echo " Created schema set '$SET_NAME' with ID: $SCHEMASET_ID" + if [ -n "$EXISTING_ID" ]; then + echo " Schema '$CLASS_NAME' already exists with ID: $EXISTING_ID" + REGISTERED_IDS+=("$EXISTING_ID") + REGISTERED_NAMES+=("$CLASS_NAME") + continue + fi + + echo " Registering new schema '$CLASS_NAME'..." + DATA_PAYLOAD="{\"ClassName\": \"$CLASS_NAME\", \"Description\": \"$DESCRIPTION\"}" + + EXT=$(echo "${FILE_NAME##*.}" | tr '[:upper:]' '[:lower:]') + if [ "$EXT" != "json" ]; then + echo " Unsupported schema extension '.$EXT' for '$FILE_NAME'. Only .json is accepted. Skipping..." + continue + fi + CONTENT_TYPE="application/json" + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "$SCHEMAVAULT_URL" \ + -F "data=$DATA_PAYLOAD" \ + -F "file=@$SCHEMA_FILE;type=$CONTENT_TYPE" \ + --connect-timeout 60) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" = "200" ]; then + SCHEMA_ID=$(echo "$BODY" | sed 's/.*"Id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') + echo " Successfully registered: $DESCRIPTION's Schema Id - $SCHEMA_ID" + REGISTERED_IDS+=("$SCHEMA_ID") + REGISTERED_NAMES+=("$CLASS_NAME") + else + echo " Failed to upload '$FILE_NAME'. HTTP Status: $HTTP_CODE" + echo " Error Response: $BODY" + fi + done + + echo "" + echo "============================================================" + echo "Step 2: Create schema set" + echo "============================================================" + + EXISTING_SETS=$(curl -s "$SCHEMASETVAULT_URL" 2>/dev/null || echo "[]") + + if echo "$EXISTING_SETS" | grep -q "\"Name\"[[:space:]]*:[[:space:]]*\"$SET_NAME\""; then + SCHEMASET_ID=$(echo "$EXISTING_SETS" | sed 's/},/}\n/g' | grep "\"Name\"[[:space:]]*:[[:space:]]*\"$SET_NAME\"" | grep -o '"Id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + echo " Schema set '$SET_NAME' already exists with ID: $SCHEMASET_ID" else - echo " Failed to create schema set. HTTP Status: $HTTP_CODE" - echo " Error Response: $BODY" + echo " Creating schema set '$SET_NAME'..." + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "$SCHEMASETVAULT_URL" \ + -H "Content-Type: application/json" \ + -d "{\"Name\": \"$SET_NAME\", \"Description\": \"$SET_DESC\"}" \ + --connect-timeout 30) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" = "200" ]; then + SCHEMASET_ID=$(echo "$BODY" | sed 's/.*"Id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') + echo " Created schema set '$SET_NAME' with ID: $SCHEMASET_ID" + else + echo " Failed to create schema set. HTTP Status: $HTTP_CODE" + echo " Error Response: $BODY" + fi fi + + if [ -z "$SCHEMASET_ID" ]; then + echo "Error: Could not create or find schema set. Aborting step 3." + else + echo "" + echo "============================================================" + echo "Step 3: Add schemas to schema set" + echo "============================================================" + + ALREADY_IN_SET=$(curl -s "${SCHEMASETVAULT_URL}${SCHEMASET_ID}/schemas" 2>/dev/null || echo "[]") + + for i in "${!REGISTERED_IDS[@]}"; do + SCHEMA_ID="${REGISTERED_IDS[$i]}" + CLASS_NAME="${REGISTERED_NAMES[$i]}" + + if echo "$ALREADY_IN_SET" | grep -q "\"Id\"[[:space:]]*:[[:space:]]*\"$SCHEMA_ID\""; then + echo " Schema '$CLASS_NAME' ($SCHEMA_ID) already in schema set - skipped" + continue + fi + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "${SCHEMASETVAULT_URL}${SCHEMASET_ID}/schemas" \ + -H "Content-Type: application/json" \ + -d "{\"SchemaId\": \"$SCHEMA_ID\"}" \ + --connect-timeout 30) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + + if [ "$HTTP_CODE" = "200" ]; then + echo " Added '$CLASS_NAME' ($SCHEMA_ID) to schema set" + else + BODY=$(echo "$RESPONSE" | sed '$d') + echo " Failed to add '$CLASS_NAME' to schema set. HTTP $HTTP_CODE" + echo " Error Response: $BODY" + fi + done + fi + + echo "" + echo "============================================================" + echo "Schema registration process completed." + echo " Schemas registered: ${#REGISTERED_IDS[@]}" + echo "============================================================" fi - if [ -z "$SCHEMASET_ID" ]; then - echo "Error: Could not create or find schema set. Aborting step 3." - else - # --- Step 3: Add schemas to schema set --- + if [ "$POST_DEPLOYMENT_MODE" = "schema" ]; then + echo "" + echo "============================================================" + echo "Sample data upload skipped because POST_DEPLOYMENT_MODE=schema" + echo "Next explicit step: run POST_DEPLOYMENT_MODE=sample-data bash ./infra/scripts/post_deployment.sh" + echo "============================================================" + elif [ -n "$SCHEMASET_ID" ] && [ ${#REGISTERED_IDS[@]} -gt 0 ]; then echo "" echo "============================================================" - echo "Step 3: Add schemas to schema set" + echo "Step 4: Process sample file bundles" echo "============================================================" - ALREADY_IN_SET=$(curl -s "${SCHEMASETVAULT_URL}${SCHEMASET_ID}/schemas" 2>/dev/null || echo "[]") + SAMPLES_DIR="$(realpath "$SCRIPT_DIR/../../src/ContentProcessorAPI/samples")" + CLAIM_PROCESSOR_URL="$API_BASE_URL/claimprocessor/claims" - # Iterate over registered schemas - for i in "${!REGISTERED_IDS[@]}"; do - SCHEMA_ID="${REGISTERED_IDS[$i]}" - CLASS_NAME="${REGISTERED_NAMES[$i]}" + for BUNDLE in claim_date_of_loss claim_hail; do + BUNDLE_DIR="$SAMPLES_DIR/$BUNDLE" + BUNDLE_INFO="$BUNDLE_DIR/bundle_info.json" - if echo "$ALREADY_IN_SET" | grep -q "\"Id\"[[:space:]]*:[[:space:]]*\"$SCHEMA_ID\""; then - echo " Schema '$CLASS_NAME' ($SCHEMA_ID) already in schema set - skipped" + if [ ! -f "$BUNDLE_INFO" ]; then + echo " Skipping '$BUNDLE' - no bundle_info.json found." continue fi + echo "" + echo " 📂 Processing bundle: $BUNDLE" + + # Step 4a: Create claim batch with schemaset ID + echo " - Creating claim batch..." RESPONSE=$(curl -s -w "\n%{http_code}" \ - -X POST "${SCHEMASETVAULT_URL}${SCHEMASET_ID}/schemas" \ + -X PUT "$CLAIM_PROCESSOR_URL" \ -H "Content-Type: application/json" \ - -d "{\"SchemaId\": \"$SCHEMA_ID\"}" \ + -d "{\"schema_collection_id\": \"$SCHEMASET_ID\"}" \ --connect-timeout 30) HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') - if [ "$HTTP_CODE" = "200" ]; then - echo " Added '$CLASS_NAME' ($SCHEMA_ID) to schema set" + if [ "$HTTP_CODE" != "200" ]; then + echo " ❌ Failed to create claim batch. HTTP $HTTP_CODE" + echo " Error: $BODY" + continue + fi + + CLAIM_ID=$(echo "$BODY" | grep -o '"claim_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + echo " ✅ Claim batch created with ID: $CLAIM_ID" + + # Step 4b: Upload each file with its mapped schema ID + UPLOAD_SUCCESS=true + FILE_COUNT=$(cat "$BUNDLE_INFO" | grep -o '"file_name"' | wc -l) + + for fidx in $(seq 0 $((FILE_COUNT - 1))); do + FILE_NAME=$(cat "$BUNDLE_INFO" | grep -o '"file_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((fidx + 1))p" | sed 's/.*"\([^"]*\)"$/\1/') + SCHEMA_CLASS=$(cat "$BUNDLE_INFO" | grep -o '"schema_class"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((fidx + 1))p" | sed 's/.*"\([^"]*\)"$/\1/') + + FILE_PATH="$BUNDLE_DIR/$FILE_NAME" + + if [ ! -f "$FILE_PATH" ]; then + echo " - File '$FILE_NAME' not found. Skipping." + continue + fi + + # Look up schema ID from registered schemas + SCHEMA_ID="" + for i in "${!REGISTERED_IDS[@]}"; do + if [ "${REGISTERED_NAMES[$i]}" = "$SCHEMA_CLASS" ]; then + SCHEMA_ID="${REGISTERED_IDS[$i]}" + break + fi + done + + if [ -z "$SCHEMA_ID" ]; then + echo " ❌ No schema ID found for '$SCHEMA_CLASS'. Marking bundle upload as failed and skipping submission." + UPLOAD_SUCCESS=false + break + fi + + echo " - Uploading '$FILE_NAME' (schema: $SCHEMA_CLASS)..." + + DATA_JSON="{\"Claim_Id\": \"$CLAIM_ID\", \"Schema_Id\": \"$SCHEMA_ID\", \"Metadata_Id\": \"sample-$BUNDLE\"}" + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "$CLAIM_PROCESSOR_URL/$CLAIM_ID/files" \ + -F "data=$DATA_JSON" \ + -F "file=@$FILE_PATH" \ + --connect-timeout 60) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + + if [ "$HTTP_CODE" = "200" ]; then + echo " ✅ Uploaded '$FILE_NAME' successfully." + else + BODY=$(echo "$RESPONSE" | sed '$d') + echo " ❌ Failed to upload '$FILE_NAME'. HTTP $HTTP_CODE" + echo " Error: $BODY" + UPLOAD_SUCCESS=false + fi + done + + # Step 4c: Launch processing + if [ "$UPLOAD_SUCCESS" = true ]; then + echo " - Submitting claim batch for processing..." + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "$CLAIM_PROCESSOR_URL" \ + -H "Content-Type: application/json" \ + -d "{\"claim_process_id\": \"$CLAIM_ID\"}" \ + --connect-timeout 30) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + + if [ "$HTTP_CODE" = "202" ]; then + echo " ✅ Claim batch '$CLAIM_ID' submitted for processing." + else + BODY=$(echo "$RESPONSE" | sed '$d') + echo " ❌ Failed to submit claim batch. HTTP $HTTP_CODE" + echo " Error: $BODY" + fi else - BODY=$(echo "$RESPONSE" | sed '$d') - echo " Failed to add '$CLASS_NAME' to schema set. HTTP $HTTP_CODE" - echo " Error Response: $BODY" + echo " - Skipping batch submission due to upload failures." fi done - fi - echo "" - echo "============================================================" - echo "Schema registration process completed." - echo " Schemas registered: ${#REGISTERED_IDS[@]}" - echo "============================================================" + echo "" + echo "============================================================" + echo "Sample file processing completed." + echo "============================================================" + else + echo "" + echo "============================================================" + echo "Sample data upload skipped because required schemas or schema set were not found." + echo "Run schema registration first, then re-run with POST_DEPLOYMENT_MODE=sample-data." + echo "============================================================" + fi fi # --- Refresh Content Understanding Cognitive Services account --- @@ -270,3 +426,10 @@ else echo " ❌ Failed to refresh Cognitive Services account '$CU_ACCOUNT_NAME'." fi fi + + +echo "" +echo "============================================================" +echo "Post-deployment data setup completed." +echo "Next manual step: configure authentication using infra/scripts/configure_auth.sh" +echo "============================================================" diff --git a/infra/scripts/register_schemas.ps1 b/infra/scripts/register_schemas.ps1 new file mode 100644 index 00000000..44703fa2 --- /dev/null +++ b/infra/scripts/register_schemas.ps1 @@ -0,0 +1,4 @@ +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot +$env:POST_DEPLOYMENT_MODE = "schema" +& (Join-Path $ScriptDir "post_deployment.ps1") diff --git a/infra/scripts/register_schemas.sh b/infra/scripts/register_schemas.sh new file mode 100644 index 00000000..8fb5f424 --- /dev/null +++ b/infra/scripts/register_schemas.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +sed -i 's/\r$//' "$SCRIPT_DIR/post_deployment.sh" +chmod +x "$SCRIPT_DIR/post_deployment.sh" + +POST_DEPLOYMENT_MODE=schema bash "$SCRIPT_DIR/post_deployment.sh" diff --git a/infra/scripts/run_post_deployment.ps1 b/infra/scripts/run_post_deployment.ps1 new file mode 100644 index 00000000..b850b5df --- /dev/null +++ b/infra/scripts/run_post_deployment.ps1 @@ -0,0 +1,175 @@ +# run_post_deployment.ps1 +# +# Manual post-deployment setup for Content Processing Solution Accelerator. +# Run this script AFTER `azd up` has finished provisioning infrastructure. +# +# Steps executed: +# Step 1 - Schema registration (register_schemas.ps1) +# Step 2 - Sample data upload (upload_sample_data.ps1) +# Step 3 - Entra ID authentication setup (setup_auth.ps1) +# +# Skip individual steps by setting env vars before running: +# $env:SKIP_SCHEMA_REGISTRATION = "true"; .\infra\scripts\run_post_deployment.ps1 +# $env:SKIP_SAMPLE_DATA_UPLOAD = "true"; .\infra\scripts\run_post_deployment.ps1 +# $env:SKIP_AUTH_SETUP = "true"; .\infra\scripts\run_post_deployment.ps1 +# +# To skip auth setup permanently: +# azd env set AZURE_SKIP_AUTH_SETUP true +# +# Usage (from repo root): +# .\infra\scripts\run_post_deployment.ps1 + +$ErrorActionPreference = "Stop" + +$ScriptDir = $PSScriptRoot + +function Print-Banner { + Write-Host "" + Write-Host "╔══════════════════════════════════════════════════════════════╗" + Write-Host "║ Content Processing Solution Accelerator ║" + Write-Host "║ Post-Deployment Manual Setup ║" + Write-Host "╚══════════════════════════════════════════════════════════════╝" + Write-Host "" + Write-Host " This script runs post-deployment steps that are intentionally" + Write-Host " decoupled from 'azd up' so they can be executed separately," + Write-Host " retried independently, and skipped when permissions are limited." + Write-Host "" +} + +function Print-Step($Num, $Title) { + Write-Host "" + Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + Write-Host " Step $Num`: $Title" + Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +} + +function Write-StepOk($Num) { Write-Host ""; Write-Host " ✅ Step $Num completed successfully." } +function Write-StepSkip($Num, $Reason) { Write-Host ""; Write-Host " ⏭️ Step $Num skipped ($Reason)." } +function Write-StepFail($Num) { Write-Host ""; Write-Host " ❌ Step $Num failed — see errors above." } + +function Azd-Get($Key) { + try { return (azd env get-value $Key 2>$null) } catch { return "" } +} + +Print-Banner + +if (-not (Get-Command azd -ErrorAction SilentlyContinue)) { + Write-Error "Azure Developer CLI (azd) is not installed or not on PATH.`nInstall it from https://aka.ms/install-azd, then re-run." + exit 1 +} + +azd env get-values 1>$null 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Error "No active azd environment found.`nRun 'azd env list' and 'azd env select ', then re-run." + exit 1 +} + +Write-Host " Active azd environment : $(Azd-Get 'AZURE_ENV_NAME')" +Write-Host " Resource group : $(Azd-Get 'AZURE_RESOURCE_GROUP')" +Write-Host " Subscription : $(Azd-Get 'AZURE_SUBSCRIPTION_ID')" +Write-Host "" + +$Step1Script = Join-Path $ScriptDir "register_schemas.ps1" + +Print-Step 1 "Schema registration" +Write-Host " Script : $Step1Script" +Write-Host " Purpose: Register sample schemas, create the schema set, and link schemas to it." +Write-Host "" + +if ($env:SKIP_SCHEMA_REGISTRATION -eq "true") { + Write-StepSkip 1 "SKIP_SCHEMA_REGISTRATION=true" +} else { + if (-not (Test-Path $Step1Script)) { + Write-Error "Script not found: $Step1Script" + exit 1 + } + + try { + & $Step1Script + Write-StepOk 1 + } catch { + Write-StepFail 1 + Write-Host " To retry : & \"$Step1Script\"" + Write-Host " To skip : `$env:SKIP_SCHEMA_REGISTRATION = 'true'; & \"$(Join-Path $ScriptDir 'run_post_deployment.ps1')\"" + exit 1 + } +} + +$Step2Script = Join-Path $ScriptDir "upload_sample_data.ps1" + +Print-Step 2 "Sample data upload" +Write-Host " Script : $Step2Script" +Write-Host " Purpose: Create sample claim batches, upload sample bundles, and submit them for processing." +Write-Host "" + +if ($env:SKIP_SAMPLE_DATA_UPLOAD -eq "true") { + Write-StepSkip 2 "SKIP_SAMPLE_DATA_UPLOAD=true" +} else { + if (-not (Test-Path $Step2Script)) { + Write-Error "Script not found: $Step2Script" + exit 1 + } + + try { + & $Step2Script + Write-StepOk 2 + } catch { + Write-StepFail 2 + Write-Host " To retry : & \"$Step2Script\"" + Write-Host " To skip : `$env:SKIP_SAMPLE_DATA_UPLOAD = 'true'; & \"$(Join-Path $ScriptDir 'run_post_deployment.ps1')\"" + exit 1 + } +} + +$Step3Script = Join-Path $ScriptDir "setup_auth.ps1" + +Print-Step 3 "Entra ID authentication setup (app registrations + EasyAuth)" +Write-Host " Script : $Step3Script" +Write-Host " Purpose: Create app registrations for Web + API, configure EasyAuth," +Write-Host " grant admin consent, and wire environment variables." +Write-Host "" +Write-Host " Required permissions:" +Write-Host " * Application Administrator (or higher) — to create app registrations" +Write-Host " * Cloud Application Administrator / Global Administrator — to grant admin consent" +Write-Host " * Contributor on resource group — to update Container Apps" +Write-Host "" +Write-Host " To skip this step:" +Write-Host " `$env:SKIP_AUTH_SETUP = 'true'; & \"$(Join-Path $ScriptDir 'run_post_deployment.ps1')\"" +Write-Host " — or —" +Write-Host " azd env set AZURE_SKIP_AUTH_SETUP true" +Write-Host " then run & \"$Step3Script\" later when permissions are available." +Write-Host "" + +$AzureSkipAuth = Azd-Get "AZURE_SKIP_AUTH_SETUP" + +if ($env:SKIP_AUTH_SETUP -eq "true" -or $AzureSkipAuth -eq "true" -or $env:AZURE_SKIP_AUTH_SETUP -eq "true") { + Write-StepSkip 3 "SKIP_AUTH_SETUP=true or AZURE_SKIP_AUTH_SETUP=true" + Write-Host " Run manually when permissions are available:" + Write-Host " & \"$Step3Script\"" +} else { + if (-not (Test-Path $Step3Script)) { + Write-Error "Script not found: $Step3Script" + exit 1 + } + + try { + & $Step3Script + Write-StepOk 3 + } catch { + Write-StepFail 3 + Write-Host " To retry auth setup : & \"$Step3Script\"" + Write-Host " For manual portal steps: docs/ConfigureAppAuthentication.md" + exit 1 + } +} + +Write-Host "" +Write-Host "╔══════════════════════════════════════════════════════════════╗" +Write-Host "║ Post-deployment setup complete. ║" +Write-Host "║ ║" +Write-Host "║ Next steps: ║" +Write-Host "║ 1. Wait up to 10 minutes for EasyAuth to propagate. ║" +Write-Host "║ 2. Open the Web App URL and sign in. ║" +Write-Host "║ 3. Verify the two sample claim bundles appear in the UI. ║" +Write-Host "╚══════════════════════════════════════════════════════════════╝" +Write-Host "" diff --git a/infra/scripts/run_post_deployment.sh b/infra/scripts/run_post_deployment.sh new file mode 100644 index 00000000..ea5550ea --- /dev/null +++ b/infra/scripts/run_post_deployment.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# run_post_deployment.sh +# +# Manual post-deployment setup for Content Processing Solution Accelerator. +# Run this script AFTER `azd up` has finished provisioning infrastructure. +# +# Steps executed: +# Step 1 – Schema registration (register_schemas.sh) +# Step 2 – Sample data upload (upload_sample_data.sh) +# Step 3 – Entra ID authentication setup (setup_auth.sh) +# +# Skip individual steps: +# SKIP_SCHEMA_REGISTRATION=true ./infra/scripts/run_post_deployment.sh +# SKIP_SAMPLE_DATA_UPLOAD=true ./infra/scripts/run_post_deployment.sh +# SKIP_AUTH_SETUP=true ./infra/scripts/run_post_deployment.sh +# +# To skip auth setup permanently: +# azd env set AZURE_SKIP_AUTH_SETUP true +# +# Usage (from repo root): +# bash ./infra/scripts/run_post_deployment.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +print_banner() { + echo "" + echo "╔══════════════════════════════════════════════════════════════╗" + echo "║ Content Processing Solution Accelerator ║" + echo "║ Post-Deployment Manual Setup ║" + echo "╚══════════════════════════════════════════════════════════════╝" + echo "" + echo " This script runs post-deployment steps that are intentionally" + echo " decoupled from 'azd up' so they can be executed separately," + echo " retried independently, and skipped when permissions are limited." + echo "" +} + +print_step() { + local num="$1" + local title="$2" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " Step $num: $title" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +} + +step_ok() { echo ""; echo " ✅ Step $1 completed successfully."; } +step_skip() { echo ""; echo " ⏭️ Step $1 skipped (${2})."; } +step_fail() { echo ""; echo " ❌ Step $1 failed — see errors above."; } + +print_banner + +if ! command -v azd &>/dev/null; then + echo "❌ Azure Developer CLI (azd) is not installed or not on PATH." >&2 + echo " Install it from https://aka.ms/install-azd, then re-run." >&2 + exit 1 +fi + +if ! azd env get-values &>/dev/null; then + echo "❌ No active azd environment found." >&2 + echo " Run 'azd env list' and 'azd env select ', then re-run." >&2 + exit 1 +fi + +echo " Active azd environment : $(azd env get-value AZURE_ENV_NAME 2>/dev/null || echo '')" +echo " Resource group : $(azd env get-value AZURE_RESOURCE_GROUP 2>/dev/null || echo '')" +echo " Subscription : $(azd env get-value AZURE_SUBSCRIPTION_ID 2>/dev/null || echo '')" +echo "" + +STEP1_SCRIPT="$SCRIPT_DIR/register_schemas.sh" + +print_step 1 "Schema registration" +echo " Script : $STEP1_SCRIPT" +echo " Purpose: Register sample schemas, create the schema set, and link schemas to it." +echo "" + +if [[ "${SKIP_SCHEMA_REGISTRATION:-false}" == "true" ]]; then + step_skip 1 "SKIP_SCHEMA_REGISTRATION=true" +else + if [[ ! -f "$STEP1_SCRIPT" ]]; then + echo " ❌ Script not found: $STEP1_SCRIPT" >&2 + exit 1 + fi + + sed -i 's/\r$//' "$STEP1_SCRIPT" + chmod +x "$STEP1_SCRIPT" + + STEP1_EXIT=0 + bash "$STEP1_SCRIPT" || STEP1_EXIT=$? + + if [[ $STEP1_EXIT -eq 0 ]]; then + step_ok 1 + else + step_fail 1 + echo " To retry: bash $STEP1_SCRIPT" + echo " To skip: SKIP_SCHEMA_REGISTRATION=true bash $SCRIPT_DIR/run_post_deployment.sh" + exit $STEP1_EXIT + fi +fi + +STEP2_SCRIPT="$SCRIPT_DIR/upload_sample_data.sh" + +print_step 2 "Sample data upload" +echo " Script : $STEP2_SCRIPT" +echo " Purpose: Create sample claim batches, upload sample bundles, and submit them for processing." +echo "" + +if [[ "${SKIP_SAMPLE_DATA_UPLOAD:-false}" == "true" ]]; then + step_skip 2 "SKIP_SAMPLE_DATA_UPLOAD=true" +else + if [[ ! -f "$STEP2_SCRIPT" ]]; then + echo " ❌ Script not found: $STEP2_SCRIPT" >&2 + exit 1 + fi + + sed -i 's/\r$//' "$STEP2_SCRIPT" + chmod +x "$STEP2_SCRIPT" + + STEP2_EXIT=0 + bash "$STEP2_SCRIPT" || STEP2_EXIT=$? + + if [[ $STEP2_EXIT -eq 0 ]]; then + step_ok 2 + else + step_fail 2 + echo " To retry: bash $STEP2_SCRIPT" + echo " To skip: SKIP_SAMPLE_DATA_UPLOAD=true bash $SCRIPT_DIR/run_post_deployment.sh" + exit $STEP2_EXIT + fi +fi + +STEP3_SCRIPT="$SCRIPT_DIR/setup_auth.sh" + +print_step 3 "Entra ID authentication setup (app registrations + EasyAuth)" +echo " Script : $STEP3_SCRIPT" +echo " Purpose: Create app registrations for Web + API, configure EasyAuth," +echo " grant admin consent, and wire environment variables." +echo "" +echo " Required permissions:" +echo " • Application Administrator (or higher) — to create app registrations" +echo " • Cloud Application Administrator / Global Administrator — to grant admin consent" +echo " • Contributor on resource group — to update Container Apps" +echo "" +echo " To skip this step:" +echo " SKIP_AUTH_SETUP=true bash $SCRIPT_DIR/run_post_deployment.sh" +echo " — or —" +echo " azd env set AZURE_SKIP_AUTH_SETUP true" +echo " then re-run setup_auth.sh later when permissions are available." +echo "" + +AZURE_SKIP_AUTH_SETUP_VAL="${AZURE_SKIP_AUTH_SETUP:-$(azd env get-value AZURE_SKIP_AUTH_SETUP 2>/dev/null || echo "false")}" + +if [[ "${SKIP_AUTH_SETUP:-false}" == "true" ]] || [[ "$AZURE_SKIP_AUTH_SETUP_VAL" == "true" ]]; then + step_skip 3 "SKIP_AUTH_SETUP=true or AZURE_SKIP_AUTH_SETUP=true" + echo " Run manually when permissions are available:" + echo " bash $STEP3_SCRIPT" +else + if [[ ! -f "$STEP3_SCRIPT" ]]; then + echo " ❌ Script not found: $STEP3_SCRIPT" >&2 + exit 1 + fi + + sed -i 's/\r$//' "$STEP3_SCRIPT" + chmod +x "$STEP3_SCRIPT" + + STEP3_EXIT=0 + bash "$STEP3_SCRIPT" || STEP3_EXIT=$? + + if [[ $STEP3_EXIT -eq 0 ]]; then + step_ok 3 + else + step_fail 3 + echo " To retry auth setup: bash $STEP3_SCRIPT" + echo " For manual portal steps: docs/ConfigureAppAuthentication.md" + exit $STEP3_EXIT + fi +fi + +echo "" +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ Post-deployment setup complete. ║" +echo "║ ║" +echo "║ Next steps: ║" +echo "║ 1. Wait up to 10 minutes for EasyAuth to propagate. ║" +echo "║ 2. Open the Web App URL and sign in. ║" +echo "║ 3. Verify the two sample claim bundles appear in the UI. ║" +echo "╚══════════════════════════════════════════════════════════════╝" +echo "" diff --git a/infra/scripts/setup_auth.ps1 b/infra/scripts/setup_auth.ps1 new file mode 100644 index 00000000..2811988b --- /dev/null +++ b/infra/scripts/setup_auth.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot +& (Join-Path $ScriptDir "configure_auth.ps1") @args \ No newline at end of file diff --git a/infra/scripts/setup_auth.sh b/infra/scripts/setup_auth.sh new file mode 100644 index 00000000..c45d3c77 --- /dev/null +++ b/infra/scripts/setup_auth.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +sed -i 's/\r$//' "$SCRIPT_DIR/configure_auth.sh" +chmod +x "$SCRIPT_DIR/configure_auth.sh" + +bash "$SCRIPT_DIR/configure_auth.sh" "$@" \ No newline at end of file diff --git a/infra/scripts/test_configure_auth_preflight.ps1 b/infra/scripts/test_configure_auth_preflight.ps1 new file mode 100644 index 00000000..cfc9138d --- /dev/null +++ b/infra/scripts/test_configure_auth_preflight.ps1 @@ -0,0 +1,256 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Validates configure_auth.ps1 --preflight-only behavior under each + insufficient-permission scenario. No real Azure credentials are required. + +.DESCRIPTION + Creates temporary mock az.cmd / azd.cmd executables (backed by PowerShell + helper scripts) in a temp folder, prepends that folder to PATH, then calls + configure_auth.ps1 --preflight-only for each scenario and asserts the + expected exit code and output text. + + Scenarios tested (10 total): + T01 Happy path — all checks pass + T02 Check 1: Azure CLI not authenticated + T03 Check 2: required azd env values missing + T04 Check 3: Container Apps CLI extension absent + T05 Check 4: no Contributor/Owner RBAC on resource group + T06 Check 5: cannot read Entra app registrations + T07 Check 6: target Container App is inaccessible + T08 Check 7: Entra role below Application Administrator (FAIL) + T09 Check 7: consent-only WARN — non-fatal (exit 0) + T10 Check 7: service-principal login — dir check skipped (exit 0) + +.EXAMPLE + .\test_configure_auth_preflight.ps1 +#> + +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot +$Subject = Join-Path $ScriptDir "configure_auth.ps1" + +if (-not (Test-Path $Subject)) { + Write-Error "configure_auth.ps1 not found at $Subject" + exit 1 +} + +$PassCount = 0 +$FailCount = 0 +$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) "auth_pfl_test_$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $TempDir | Out-Null + +# Clean up temp dir on script exit +try { + +# ============================================================================= +# Mock PowerShell helper for az.cmd +# Behaviour controlled by AZ_MOCK_SCENARIO environment variable. +# ============================================================================= +$MockAzPs1Content = @' +$allArgs = $args +$SCENARIO = if ($env:AZ_MOCK_SCENARIO) { $env:AZ_MOCK_SCENARIO } else { "happy" } +$S = ($allArgs -join " ") +function has($t) { $S -like "*$t*" } + +if ((has "account show") -and -not (has "role assignment")) { + if ($SCENARIO -eq "no_auth") { exit 1 } + if (has "tenantId") { Write-Output "mock-tenant-id"; exit 0 } + if (has "user.name") { Write-Output "sp-mock@service.principal"; exit 0 } + Write-Output "mock-sub-id-12345"; exit 0 +} +if (has "signed-in-user") { + if ($SCENARIO -eq "sp_login") { exit 1 } + Write-Output "mock-user-object-id-abc123"; exit 0 +} +if (has "role assignment list") { + if ($SCENARIO -eq "no_rbac") { Write-Output ""; exit 0 } + Write-Output "Contributor"; exit 0 +} +if ((has "ad app list") -and -not (has "ad app show")) { + if ($SCENARIO -eq "no_entra_read") { exit 1 } + Write-Output "mock-app-id-00001"; exit 0 +} +if ((has "containerapp") -and (has " --help")) { + if ($SCENARIO -eq "no_extension") { exit 1 } + exit 0 +} +if (has "containerapp show") { + if ($SCENARIO -eq "no_container_app") { exit 1 } + Write-Output "ca-testenv-web"; exit 0 +} +if (has "rest") { + switch ($SCENARIO) { + "insufficient_dir_role" { Write-Output "Directory Readers"; exit 0 } + "consent_warn_only" { Write-Output "Application Administrator"; exit 0 } + default { Write-Output "Global Administrator"; exit 0 } + } +} +if (has "ad app show") { exit 1 } +exit 0 +'@ + +# ============================================================================= +# Mock PowerShell helper for azd.cmd +# Behaviour controlled by AZD_MOCK_SCENARIO environment variable. +# ============================================================================= +$MockAzdPs1Content = @' +$allArgs = $args +$SCENARIO = if ($env:AZD_MOCK_SCENARIO) { $env:AZD_MOCK_SCENARIO } else { "happy" } +$S = ($allArgs -join " ") + +if ($S -like "*env get-value*") { + $KEY = $allArgs[-1] + if ($SCENARIO -eq "no_env") { + if ($KEY -eq "AZURE_ENV_NAME") { Write-Output "testenv" } else { Write-Output "" } + exit 0 + } + switch ($KEY) { + "AZURE_ENV_NAME" { Write-Output "testenv" } + "AZURE_RESOURCE_GROUP" { Write-Output "mock-rg" } + "AZURE_SUBSCRIPTION_ID" { Write-Output "mock-sub-id" } + "AZURE_TENANT_ID" { Write-Output "mock-tenant-id" } + "CONTAINER_WEB_APP_NAME" { Write-Output "ca-testenv-web" } + "CONTAINER_WEB_APP_FQDN" { Write-Output "ca-testenv-web.azurecontainerapps.io" } + "CONTAINER_API_APP_NAME" { Write-Output "ca-testenv-api" } + "CONTAINER_API_APP_FQDN" { Write-Output "ca-testenv-api.azurecontainerapps.io" } + default { Write-Output "" } + } + exit 0 +} +exit 0 +'@ + +# Write helper scripts +$MockAzPs1Content | Out-File -FilePath (Join-Path $TempDir "mock_az.ps1") -Encoding UTF8 +$MockAzdPs1Content | Out-File -FilePath (Join-Path $TempDir "mock_azd.ps1") -Encoding UTF8 + +# Write .cmd wrappers — %~dp0 resolves to the directory containing the .cmd file +@" +@echo off +powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0mock_az.ps1" %* +"@ | Out-File -FilePath (Join-Path $TempDir "az.cmd") -Encoding ASCII + +@" +@echo off +powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0mock_azd.ps1" %* +"@ | Out-File -FilePath (Join-Path $TempDir "azd.cmd") -Encoding ASCII + +# ============================================================================= +# Test runner +# ============================================================================= +function Run-Test { + param( + [string]$Name, + [int] $ExpectedExit, + [string]$ExpectedText = "", + [string]$AzScenario = "happy", + [string]$AzdScenario = "happy" + ) + + $origPath = $env:PATH + $env:PATH = "$TempDir;$env:PATH" + $env:AZ_MOCK_SCENARIO = $AzScenario + $env:AZD_MOCK_SCENARIO = $AzdScenario + $env:AZURE_SKIP_AUTH_SETUP = "" + + $rawOutput = pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass ` + -File $Subject "--preflight-only" 2>&1 + $exitCode = $LASTEXITCODE + $outputStr = ($rawOutput | Out-String) + + $env:PATH = $origPath + $env:AZ_MOCK_SCENARIO = $null + $env:AZD_MOCK_SCENARIO = $null + + $ok = $true; $reason = "" + if ($exitCode -ne $ExpectedExit) { + $ok = $false; $reason = "exit $exitCode (expected $ExpectedExit)" + } elseif ($ExpectedText -and ($outputStr -notlike "*$ExpectedText*")) { + $ok = $false; $reason = "expected text '$ExpectedText' not in output" + } + + if ($ok) { + Write-Host (" `u{2705} {0,-62}" -f $Name) + $script:PassCount++ + } else { + Write-Host (" `u{274C} {0,-62} [{1}]" -f $Name, $reason) + ($outputStr -split "`n" | Select-Object -Last 4) | ForEach-Object { + Write-Host " $_" + } + $script:FailCount++ + } +} + +# ============================================================================= +# Test scenarios +# ============================================================================= +Write-Host "" +Write-Host "============================================================" +Write-Host " configure_auth.ps1 — preflight permission scenario tests" +Write-Host "============================================================" + +# T01 — Happy path: every check should pass +Run-Test "T01 Happy path: all checks pass" ` + -ExpectedExit 0 -ExpectedText "Preflight-only mode" ` + -AzScenario "happy" -AzdScenario "happy" + +# T02 — Check 1: Azure CLI not authenticated +Run-Test "T02 Check 1: not authenticated" ` + -ExpectedExit 1 -ExpectedText "Azure CLI authenticated" ` + -AzScenario "no_auth" -AzdScenario "happy" + +# T03 — Check 2: required azd env values missing +Run-Test "T03 Check 2: missing required azd env values" ` + -ExpectedExit 1 -ExpectedText "Required azd env values" ` + -AzScenario "happy" -AzdScenario "no_env" + +# T04 — Check 3: Azure Container Apps CLI extension absent +Run-Test "T04 Check 3: containerapp CLI extension missing" ` + -ExpectedExit 1 -ExpectedText "Container Apps CLI" ` + -AzScenario "no_extension" -AzdScenario "happy" + +# T05 — Check 4: no Contributor or Owner role on resource group +Run-Test "T05 Check 4: no RBAC Contributor/Owner on resource group" ` + -ExpectedExit 1 -ExpectedText "Contributor/Owner" ` + -AzScenario "no_rbac" -AzdScenario "happy" + +# T06 — Check 5: cannot read Entra app registrations +Run-Test "T06 Check 5: cannot read Entra app registrations" ` + -ExpectedExit 1 -ExpectedText "Entra app registrations" ` + -AzScenario "no_entra_read" -AzdScenario "happy" + +# T07 — Check 6: target Container App is inaccessible +Run-Test "T07 Check 6: Container App is inaccessible" ` + -ExpectedExit 1 -ExpectedText "Container App" ` + -AzScenario "no_container_app" -AzdScenario "happy" + +# T08 — Check 7: Entra role present but below Application Administrator (FAIL) +Run-Test "T08 Check 7: insufficient Entra directory role (FAIL)" ` + -ExpectedExit 1 -ExpectedText "App-registration permission" ` + -AzScenario "insufficient_dir_role" -AzdScenario "happy" + +# T09 — Check 7: Application Administrator present, consent role absent (WARN, non-fatal) +Run-Test "T09 Check 7: consent-only WARN is non-fatal (exit 0)" ` + -ExpectedExit 0 -ExpectedText "Admin-consent permission" ` + -AzScenario "consent_warn_only" -AzdScenario "happy" + +# T10 — Check 7: service principal login — directory-role check skipped (WARN, non-fatal) +Run-Test "T10 Check 7: SP login — directory-role check skipped (exit 0)" ` + -ExpectedExit 0 -ExpectedText "directory-role check" ` + -AzScenario "sp_login" -AzdScenario "happy" + +# ============================================================================= +# Summary +# ============================================================================= +Write-Host "" +Write-Host "============================================================" +Write-Host " Results: $PassCount passed, $FailCount failed" +Write-Host "============================================================" +Write-Host "" + +} finally { + Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($FailCount -gt 0) { exit 1 } diff --git a/infra/scripts/test_configure_auth_preflight.sh b/infra/scripts/test_configure_auth_preflight.sh new file mode 100644 index 00000000..9696966d --- /dev/null +++ b/infra/scripts/test_configure_auth_preflight.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +# ============================================================================= +# test_configure_auth_preflight.sh +# +# Validates that configure_auth.sh --preflight-only exits with the correct code +# and outputs the expected diagnostic text for each insufficient-permission +# scenario. No real Azure credentials are required; az and azd are mocked. +# +# Usage: +# bash infra/scripts/test_configure_auth_preflight.sh +# +# Exit code: 0 if all tests pass, 1 if any test fails. +# ============================================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SUBJECT="$SCRIPT_DIR/configure_auth.sh" + +if [[ ! -f "$SUBJECT" ]]; then + echo "❌ configure_auth.sh not found at $SUBJECT" >&2 + exit 1 +fi + +PASS_COUNT=0 +FAIL_COUNT=0 + +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +# ============================================================================= +# Mock: az +# Behaviour controlled by AZ_MOCK_SCENARIO environment variable. +# Scenarios: happy | no_auth | no_extension | no_rbac | no_entra_read | +# no_container_app | insufficient_dir_role | consent_warn_only | +# sp_login +# ============================================================================= +cat > "$TEMP_DIR/az" << 'MOCK_AZ' +#!/usr/bin/env bash +SCENARIO="${AZ_MOCK_SCENARIO:-happy}" +ARGS="$*" +has() { printf '%s' "$ARGS" | grep -qF -- "$1"; } + +# az account show (but NOT role assignment list — those share "account" in scope paths) +if has "account show" && ! has "role assignment"; then + [[ "$SCENARIO" == "no_auth" ]] && exit 1 + has "tenantId" && { echo "mock-tenant-id"; exit 0; } + has "user.name" && { echo "sp-mock@service.principal"; exit 0; } + echo "mock-sub-id-12345"; exit 0 +fi + +# az ad signed-in-user show +if has "signed-in-user"; then + [[ "$SCENARIO" == "sp_login" ]] && exit 1 + echo "mock-user-object-id-abc123"; exit 0 +fi + +# az role assignment list +if has "role assignment list"; then + [[ "$SCENARIO" == "no_rbac" ]] && { echo ""; exit 0; } + echo "Contributor"; exit 0 +fi + +# az ad app list +if has "ad app list"; then + [[ "$SCENARIO" == "no_entra_read" ]] && exit 1 + echo "mock-app-id-00001"; exit 0 +fi + +# az containerapp --help (check before "containerapp show") +if has "containerapp" && has " --help"; then + [[ "$SCENARIO" == "no_extension" ]] && exit 1 + exit 0 +fi + +# az containerapp show +if has "containerapp show"; then + [[ "$SCENARIO" == "no_container_app" ]] && exit 1 + echo "ca-testenv-web"; exit 0 +fi + +# az rest (Graph directory-roles query) +if has "rest"; then + case "$SCENARIO" in + insufficient_dir_role) echo "Directory Readers"; exit 0 ;; + consent_warn_only) echo "Application Administrator"; exit 0 ;; + *) echo "Global Administrator"; exit 0 ;; + esac +fi + +# az ad app show — always "not found" in test context (force create path) +if has "ad app show"; then exit 1; fi + +exit 0 +MOCK_AZ +chmod +x "$TEMP_DIR/az" + +# ============================================================================= +# Mock: azd +# Behaviour controlled by AZD_MOCK_SCENARIO environment variable. +# Scenarios: happy | no_env +# ============================================================================= +cat > "$TEMP_DIR/azd" << 'MOCK_AZD' +#!/usr/bin/env bash +SCENARIO="${AZD_MOCK_SCENARIO:-happy}" +ARGS="$*" + +if printf '%s' "$ARGS" | grep -qF "env get-value"; then + KEY="${@: -1}" # the key name is always the last argument + + if [[ "$SCENARIO" == "no_env" ]]; then + [[ "$KEY" == "AZURE_ENV_NAME" ]] && { echo "testenv"; exit 0; } + echo ""; exit 0 + fi + + case "$KEY" in + AZURE_ENV_NAME) echo "testenv" ;; + AZURE_RESOURCE_GROUP) echo "mock-rg" ;; + AZURE_SUBSCRIPTION_ID) echo "mock-sub-id" ;; + AZURE_TENANT_ID) echo "mock-tenant-id" ;; + CONTAINER_WEB_APP_NAME) echo "ca-testenv-web" ;; + CONTAINER_WEB_APP_FQDN) echo "ca-testenv-web.azurecontainerapps.io" ;; + CONTAINER_API_APP_NAME) echo "ca-testenv-api" ;; + CONTAINER_API_APP_FQDN) echo "ca-testenv-api.azurecontainerapps.io" ;; + *) echo "" ;; + esac + exit 0 +fi + +# env set and any other azd commands — succeed silently +exit 0 +MOCK_AZD +chmod +x "$TEMP_DIR/azd" + +# ============================================================================= +# Test runner +# ============================================================================= +# run_test