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..9ce2b776 100644 --- a/docs/ConfigureAppAuthentication.md +++ b/docs/ConfigureAppAuthentication.md @@ -1,5 +1,25 @@ # 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:** +> ```powershell +> ./infra/scripts/setup_auth.ps1 +> ``` +> +> **macOS/Linux:** +> ```bash +> bash ./infra/scripts/setup_auth.sh +> ``` +> +> See [DeploymentGuide.md § 5.3](./DeploymentGuide.md#53-configure-authentication-manual-script) for step-by-step instructions. +> +> 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 d9919c44..b01cb068 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -301,85 +301,96 @@ 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: +Schema registration is **not** run automatically by `azd up`. The `azd up` command provisions infrastructure and application containers only, and 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 ``` -🧭 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 +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 ``` -### 5.2 Configure Authentication (Required) +**macOS/Linux:** + +```bash +bash ./infra/scripts/upload_sample_data.sh +``` -**This step is mandatory for application access:** +### 5.3 Configure Authentication (Manual Script) -1. Follow [App Authentication Configuration](./ConfigureAppAuthentication.md). -2. Wait up to 10 minutes for authentication changes to take effect. +Run authentication setup as an explicit step after post-deployment data setup: -### 5.3 Verify Deployment +**Windows (PowerShell):** + +```powershell +./infra/scripts/setup_auth.ps1 +``` + +**macOS/Linux:** + +```bash +bash ./infra/scripts/setup_auth.sh +``` + +The auth script is idempotent and performs preflight validation before making changes. + +#### 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 + +If permissions are insufficient, the script exits early (or warns before consent) with clear remediation guidance. + +> **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.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 9f4ec91e..9d686d23 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1128,7 +1128,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..fd97e5c1 --- /dev/null +++ b/infra/scripts/configure_auth.ps1 @@ -0,0 +1,566 @@ +# 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" + +# Check AZURE_SKIP_AUTH_SETUP from both env var and azd env (if available) +$skipAuth = $false +if ($env:AZURE_SKIP_AUTH_SETUP -eq "true") { + $skipAuth = $true +} +elseif (Get-Command azd -ErrorAction SilentlyContinue) { + try { + $azdValue = azd env get-value AZURE_SKIP_AUTH_SETUP 2>$null + if ($azdValue -eq "true") { + $skipAuth = $true + } + } catch {} +} +if ($skipAuth) { + 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" + +# If already logged in, pin Azure CLI context to the azd environment subscription. +# If not logged in, preflight check 1 will provide the auth guidance. +if ($SubscriptionId) { + $currentSub = (az account show --query id -o tsv 2>$null) + if ($currentSub) { + az account set --subscription $SubscriptionId 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to switch Azure CLI context to subscription '$SubscriptionId'. Verify access and re-run." + exit 1 + } + + $activeSub = (az account show --query id -o tsv 2>$null) + if (-not $activeSub -or $activeSub -ne $SubscriptionId) { + Write-Error "Azure CLI active subscription '$activeSub' does not match AZURE_SUBSCRIPTION_ID '$SubscriptionId'." + exit 1 + } + } +} + +$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..eaaacdeb --- /dev/null +++ b/infra/scripts/configure_auth.sh @@ -0,0 +1,710 @@ +#!/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 + +TEMP_FILES=() + +cleanup_temp_files() { + local f + for f in "${TEMP_FILES[@]:-}"; do + if [[ -n "$f" ]]; then + rm -f "$f" || true + fi + done + return 0 +} + +make_temp_file() { + local prefix="$1" + local tmp_file + if ! tmp_file="$(mktemp "${TMPDIR:-/tmp}/${prefix}.XXXXXX" 2>/dev/null)"; then + tmp_file="$(mktemp -t "${prefix}.XXXXXX" 2>/dev/null)" || { + echo "❌ Failed to create temp file for ${prefix}" >&2 + exit 1 + } + fi + TEMP_FILES+=("$tmp_file") + printf '%s\n' "$tmp_file" +} + +trap cleanup_temp_files EXIT + +# Check AZURE_SKIP_AUTH_SETUP from both env var and azd env (if available) +AZURE_SKIP_AUTH_SETUP_EFFECTIVE="${AZURE_SKIP_AUTH_SETUP:-}" +if command -v azd >/dev/null 2>&1 && azd env get-values >/dev/null 2>&1; then + AZD_ENV_SKIP_AUTH_SETUP="$(azd env get-value AZURE_SKIP_AUTH_SETUP 2>/dev/null || echo "")" + if [[ -z "$AZURE_SKIP_AUTH_SETUP_EFFECTIVE" && -n "$AZD_ENV_SKIP_AUTH_SETUP" ]]; then + AZURE_SKIP_AUTH_SETUP_EFFECTIVE="$AZD_ENV_SKIP_AUTH_SETUP" + fi +fi + +if [[ "${AZURE_SKIP_AUTH_SETUP_EFFECTIVE,,}" == "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)" + +# If already logged in, pin Azure CLI context to the azd environment subscription. +# If not logged in, preflight check 1 will provide the auth guidance. +if [[ -n "$SUBSCRIPTION_ID" ]]; then + CURRENT_SUB="$(az account show --query id -o tsv 2>/dev/null || true)" + if [[ -n "$CURRENT_SUB" ]]; then + if ! az account set --subscription "$SUBSCRIPTION_ID" >/dev/null 2>&1; then + echo "❌ Failed to switch Azure CLI context to subscription '$SUBSCRIPTION_ID'. Verify access and re-run." >&2 + exit 1 + fi + + ACTIVE_SUB="$(az account show --query id -o tsv 2>/dev/null || true)" + if [[ -z "$ACTIVE_SUB" || "$ACTIVE_SUB" != "$SUBSCRIPTION_ID" ]]; then + echo "❌ Azure CLI active subscription '$ACTIVE_SUB' does not match AZURE_SUBSCRIPTION_ID '$SUBSCRIPTION_ID'." >&2 + exit 1 + fi + fi +fi + +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" ]] && printf " %b\n" "$detail" ;; + FAIL) printf " ❌ %-55s\n" "$label" + [[ -n "$detail" ]] && printf " %b\n" "$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)" + api_scope_patch_file="$(make_temp_file api_scope_patch)" + cat > "$api_scope_patch_file" </dev/null + 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)" + +web_patch_file="$(make_temp_file web_patch)" +cat > "$web_patch_file" </dev/null +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 +consent_err_file="$(make_temp_file consent_err)" +if ! retry az ad app permission admin-consent --id "$WEB_CLIENT_ID" 2>"$consent_err_file"; 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}" + sed 's/^/ /' "$consent_err_file" +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: set allowedAudiences to clientId and its API scope variant, 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)) +")" + authconfig_patch_file="$(make_temp_file authconfig_patch)" + echo "$patched" > "$authconfig_patch_file" + retry az rest --method put --url "$url" \ + --headers "Content-Type=application/json" \ + --body @"$authconfig_patch_file" >/dev/null +} + +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/docker-build.ps1 b/infra/scripts/docker-build.ps1 index 71d87f09..f78dad7c 100644 --- a/infra/scripts/docker-build.ps1 +++ b/infra/scripts/docker-build.ps1 @@ -61,12 +61,19 @@ function Ensure-AzLogin { exit 1 } - # Set Azure subscription - az account set --subscription "$AZURE_SUBSCRIPTION_ID" - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to set Azure subscription." - exit 1 - } + } + + # Always pin the Azure CLI context to the azd environment subscription. + az account set --subscription "$AZURE_SUBSCRIPTION_ID" + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to set Azure subscription to '$AZURE_SUBSCRIPTION_ID'." + exit 1 + } + + $activeSubscriptionId = az account show --query id -o tsv --only-show-errors 2>$null + if (-not $activeSubscriptionId -or $activeSubscriptionId -ne $AZURE_SUBSCRIPTION_ID) { + Write-Error "Azure CLI active subscription '$activeSubscriptionId' does not match AZURE_SUBSCRIPTION_ID '$AZURE_SUBSCRIPTION_ID'." + exit 1 } } diff --git a/infra/scripts/docker-build.sh b/infra/scripts/docker-build.sh index ccd5909c..cc1a6c6a 100755 --- a/infra/scripts/docker-build.sh +++ b/infra/scripts/docker-build.sh @@ -55,7 +55,17 @@ echo "Checking Azure login status..." if ! az account show --only-show-errors &>/dev/null; then echo "No active Azure session found. Logging in..." az login --only-show-errors - az account set --subscription "$AZURE_SUBSCRIPTION_ID" +fi + +if ! az account set --subscription "$AZURE_SUBSCRIPTION_ID" >/dev/null 2>&1; then + echo "❌ Failed to set Azure subscription to '$AZURE_SUBSCRIPTION_ID'." >&2 + exit 1 +fi + +ACTIVE_SUBSCRIPTION_ID=$(az account show --query id -o tsv --only-show-errors 2>/dev/null || true) +if [ -z "$ACTIVE_SUBSCRIPTION_ID" ] || [ "$ACTIVE_SUBSCRIPTION_ID" != "$AZURE_SUBSCRIPTION_ID" ]; then + echo "❌ Azure CLI active subscription '$ACTIVE_SUBSCRIPTION_ID' does not match AZURE_SUBSCRIPTION_ID '$AZURE_SUBSCRIPTION_ID'." >&2 + exit 1 fi # Deploy container registry diff --git a/infra/scripts/post_deployment.ps1 b/infra/scripts/post_deployment.ps1 index aa116003..b3788713 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 @@ -16,6 +16,23 @@ $CONTAINER_WORKFLOW_APP_NAME = azd env get-value CONTAINER_WORKFLOW_APP_NAME $SUBSCRIPTION_ID = azd env get-value AZURE_SUBSCRIPTION_ID $RESOURCE_GROUP = azd env get-value AZURE_RESOURCE_GROUP +# If already logged in, pin Azure CLI context to the azd environment subscription. +# If not logged in, the az command that needs auth will surface the login guidance. +if ($SUBSCRIPTION_ID) { + $CurrentSub = (az account show --query id -o tsv 2>$null) + if ($CurrentSub) { + az account set --subscription $SUBSCRIPTION_ID 2>$null + if ($LASTEXITCODE -ne 0) { + throw "Failed to switch Azure CLI context to subscription '$SUBSCRIPTION_ID'. Verify access and re-run." + } + + $ActiveSub = (az account show --query id -o tsv 2>$null) + if (-not $ActiveSub -or $ActiveSub -ne $SUBSCRIPTION_ID) { + throw "Azure CLI active subscription '$ActiveSub' does not match AZURE_SUBSCRIPTION_ID '$SUBSCRIPTION_ID'." + } + } +} + # Construct Azure Portal URLs $WEB_APP_PORTAL_URL = "https://portal.azure.com/#resource/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_WEB_APP_NAME" $API_APP_PORTAL_URL = "https://portal.azure.com/#resource/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_API_APP_NAME" @@ -30,27 +47,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 +83,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 +98,374 @@ 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" } + } + + $multipartContent = $null + $response = $null + + 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 + } finally { + if ($null -ne $response) { + $response.Dispose() + } + if ($null -ne $multipartContent) { + $multipartContent.Dispose() + } + } + } + $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 "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) } +} + +# --- Refresh Content Understanding Cognitive Services account --- +Write-Host "" +Write-Host ("=" * 60) +Write-Host "Refreshing Content Understanding Cognitive Services account..." +Write-Host ("=" * 60) - Write-Host "" - Write-Host ("=" * 60) - Write-Host "Schema registration process completed." - Write-Host " Schemas registered: $($Registered.Count)" - Write-Host ("=" * 60) +$CuAccountName = azd env get-value CONTENT_UNDERSTANDING_ACCOUNT_NAME 2>$null + +if (-not $CuAccountName) { + Write-Host " ⚠️ CONTENT_UNDERSTANDING_ACCOUNT_NAME not found in azd env. Skipping refresh." +} else { + Write-Host " Refreshing account: $CuAccountName in resource group: $RESOURCE_GROUP" + az cognitiveservices account update ` + -g $RESOURCE_GROUP ` + -n $CuAccountName ` + --tags refresh=true ` + --output none + + if ($LASTEXITCODE -eq 0) { + Write-Host " ✅ Successfully refreshed Cognitive Services account '$CuAccountName'." + } else { + Write-Host " ❌ Failed to refresh Cognitive Services account '$CuAccountName'." + } } + +Write-Host "" +Write-Host ("=" * 60) +Write-Host "Post-deployment data setup completed." +Write-Host "Next manual step: configure authentication using infra/scripts/setup_auth.ps1" +Write-Host ("=" * 60) diff --git a/infra/scripts/post_deployment.sh b/infra/scripts/post_deployment.sh index 49644a4d..7adf49f2 100644 --- a/infra/scripts/post_deployment.sh +++ b/infra/scripts/post_deployment.sh @@ -18,6 +18,24 @@ CONTAINER_WORKFLOW_APP_NAME=$(azd env get-value CONTAINER_WORKFLOW_APP_NAME) SUBSCRIPTION_ID=$(azd env get-value AZURE_SUBSCRIPTION_ID) RESOURCE_GROUP=$(azd env get-value AZURE_RESOURCE_GROUP) +# If already logged in, pin Azure CLI context to the azd environment subscription. +# If not logged in, the az command that needs auth will surface the login guidance. +if [ -n "$SUBSCRIPTION_ID" ]; then + CURRENT_SUB=$(az account show --query id -o tsv 2>/dev/null || true) + if [ -n "$CURRENT_SUB" ]; then + if ! az account set --subscription "$SUBSCRIPTION_ID" >/dev/null 2>&1; then + echo "❌ Failed to switch Azure CLI context to subscription '$SUBSCRIPTION_ID'. Verify access and re-run." >&2 + exit 1 + fi + + ACTIVE_SUB=$(az account show --query id -o tsv 2>/dev/null || true) + if [ -z "$ACTIVE_SUB" ] || [ "$ACTIVE_SUB" != "$SUBSCRIPTION_ID" ]; then + echo "❌ Azure CLI active subscription '$ACTIVE_SUB' does not match AZURE_SUBSCRIPTION_ID '$SUBSCRIPTION_ID'." >&2 + exit 1 + fi + fi +fi + # Construct Azure Portal URLs WEB_APP_PORTAL_URL="https://portal.azure.com/#resource/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_WEB_APP_NAME" API_APP_PORTAL_URL="https://portal.azure.com/#resource/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.App/containerApps/$CONTAINER_API_APP_NAME" @@ -32,13 +50,22 @@ echo " 🔗 Web App Portal URL: $WEB_APP_PORTAL_URL" echo " 🔗 API App Portal URL: $API_APP_PORTAL_URL" # Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_DIR="${ORIGINAL_SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" # Go from infra/scripts → root → src DATA_SCRIPT_PATH="$SCRIPT_DIR/../../src/ContentProcessorAPI/samples/schemas" -# Normalize the path (optional, in case of ../..) -DATA_SCRIPT_PATH="$(realpath "$DATA_SCRIPT_PATH")" +# Normalize the directory path portably (resolves ../.. without requiring realpath) +DATA_SCRIPT_PATH="$(cd "$DATA_SCRIPT_PATH" && pwd -P)" + +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 "" @@ -59,7 +86,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 +107,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="$(cd "$SCRIPT_DIR/../../src/ContentProcessorAPI/samples" && pwd -P)" + 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 +444,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/setup_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..b635c70b --- /dev/null +++ b/infra/scripts/register_schemas.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="${ORIGINAL_SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +tmp_file="" +cleanup() { + if [[ -n "$tmp_file" ]]; then + rm -f "$tmp_file" || true + fi +} +trap cleanup EXIT + +if ! tmp_file="$(mktemp "${TMPDIR:-/tmp}/cpsa-schema.XXXXXX" 2>/dev/null)"; then + tmp_file="$(mktemp -t cpsa-schema.XXXXXX 2>/dev/null)" || { + echo "Failed to create temp file" >&2 + exit 1 + } +fi +if ! tr -d '\r' < "$SCRIPT_DIR/post_deployment.sh" > "$tmp_file"; then + rm -f "$tmp_file" + echo "Failed to normalize line endings for: $SCRIPT_DIR/post_deployment.sh" >&2 + exit 1 +fi +chmod +x "$tmp_file" +ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR" POST_DEPLOYMENT_MODE=schema bash "$tmp_file" 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..ba469f26 --- /dev/null +++ b/infra/scripts/run_post_deployment.sh @@ -0,0 +1,208 @@ +#!/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."; } + +normalize_line_endings() { + local script_file="$1" + local tmp_file + if ! tmp_file="$(mktemp "${TMPDIR:-/tmp}/cpsa-postdeploy.XXXXXX" 2>/dev/null)"; then + tmp_file="$(mktemp -t cpsa-postdeploy.XXXXXX 2>/dev/null)" || { + echo " ❌ Failed to create temp file" >&2 + exit 1 + } + fi + if ! tr -d '\r' < "$script_file" > "$tmp_file"; then + rm -f "$tmp_file" + echo " ❌ Failed to normalize line endings for: $script_file" >&2 + exit 1 + fi + chmod +x "$tmp_file" + echo "$tmp_file" +} + +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 + + STEP1_RUNNER="$(normalize_line_endings "$STEP1_SCRIPT")" + + STEP1_EXIT=0 + ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR" bash "$STEP1_RUNNER" || STEP1_EXIT=$? + rm -f "$STEP1_RUNNER" + + 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 + + STEP2_RUNNER="$(normalize_line_endings "$STEP2_SCRIPT")" + + STEP2_EXIT=0 + ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR" bash "$STEP2_RUNNER" || STEP2_EXIT=$? + rm -f "$STEP2_RUNNER" + + 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 + + STEP3_RUNNER="$(normalize_line_endings "$STEP3_SCRIPT")" + + STEP3_EXIT=0 + ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR" bash "$STEP3_RUNNER" || STEP3_EXIT=$? + rm -f "$STEP3_RUNNER" + + 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..57abcbd0 --- /dev/null +++ b/infra/scripts/setup_auth.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="${ORIGINAL_SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +tmp_file="" +cleanup() { + if [[ -n "$tmp_file" ]]; then + rm -f "$tmp_file" || true + fi +} +trap cleanup EXIT + +if ! tmp_file="$(mktemp "${TMPDIR:-/tmp}/cpsa-auth.XXXXXX" 2>/dev/null)"; then + tmp_file="$(mktemp -t cpsa-auth.XXXXXX 2>/dev/null)" || { + echo "Failed to create temp file" >&2 + exit 1 + } +fi +if ! tr -d '\r' < "$SCRIPT_DIR/configure_auth.sh" > "$tmp_file"; then + rm -f "$tmp_file" + echo "Failed to normalize line endings for: $SCRIPT_DIR/configure_auth.sh" >&2 + exit 1 +fi +chmod +x "$tmp_file" +ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR" bash "$tmp_file" "$@" \ No newline at end of file diff --git a/infra/scripts/upload_sample_data.ps1 b/infra/scripts/upload_sample_data.ps1 new file mode 100644 index 00000000..79747ace --- /dev/null +++ b/infra/scripts/upload_sample_data.ps1 @@ -0,0 +1,4 @@ +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot +$env:POST_DEPLOYMENT_MODE = "sample-data" +& (Join-Path $ScriptDir "post_deployment.ps1") diff --git a/infra/scripts/upload_sample_data.sh b/infra/scripts/upload_sample_data.sh new file mode 100644 index 00000000..44e1a61e --- /dev/null +++ b/infra/scripts/upload_sample_data.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="${ORIGINAL_SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +tmp_file="" +cleanup() { + if [[ -n "$tmp_file" ]]; then + rm -f "$tmp_file" || true + fi +} +trap cleanup EXIT + +if ! tmp_file="$(mktemp "${TMPDIR:-/tmp}/cpsa-sample.XXXXXX" 2>/dev/null)"; then + tmp_file="$(mktemp -t cpsa-sample.XXXXXX 2>/dev/null)" || { + echo "Failed to create temp file" >&2 + exit 1 + } +fi +if ! tr -d '\r' < "$SCRIPT_DIR/post_deployment.sh" > "$tmp_file"; then + rm -f "$tmp_file" + echo "Failed to normalize line endings for: $SCRIPT_DIR/post_deployment.sh" >&2 + exit 1 +fi +chmod +x "$tmp_file" +ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR" POST_DEPLOYMENT_MODE=sample-data bash "$tmp_file" diff --git a/src/ContentProcessorAPI/samples/claim_date_of_loss/bundle_info.json b/src/ContentProcessorAPI/samples/claim_date_of_loss/bundle_info.json new file mode 100644 index 00000000..4ec4f4ac --- /dev/null +++ b/src/ContentProcessorAPI/samples/claim_date_of_loss/bundle_info.json @@ -0,0 +1,8 @@ +{ + "files": [ + { "file_name": "claim_form.pdf", "schema_class": "AutoInsuranceClaimForm" }, + { "file_name": "damage_photo.png", "schema_class": "DamagedVehicleImageAssessment" }, + { "file_name": "police_report.pdf", "schema_class": "PoliceReportDocument" }, + { "file_name": "repair_estimate.pdf", "schema_class": "RepairEstimateDocument" } + ] +} diff --git a/src/ContentProcessorAPI/samples/claim_hail/bundle_info.json b/src/ContentProcessorAPI/samples/claim_hail/bundle_info.json new file mode 100644 index 00000000..dc1dd272 --- /dev/null +++ b/src/ContentProcessorAPI/samples/claim_hail/bundle_info.json @@ -0,0 +1,7 @@ +{ + "files": [ + { "file_name": "claim_form.pdf", "schema_class": "AutoInsuranceClaimForm" }, + { "file_name": "damage_photo.png", "schema_class": "DamagedVehicleImageAssessment" }, + { "file_name": "repair_estimate.pdf", "schema_class": "RepairEstimateDocument" } + ] +}