From 4e4f10565ce88be696beff04219cbc65791cbff3 Mon Sep 17 00:00:00 2001 From: "Chen, Vivien" Date: Fri, 2 Jan 2026 10:29:22 -0500 Subject: [PATCH 01/25] Add custom subdomain support for OpenAI and Speech Service in Terraform - Added custom_subdomain_name to OpenAI resource for managed identity authentication - Created Speech Service resource with custom subdomain configuration - Added RBAC role assignments for Speech Service (Managed Identity and App Service MI) - Includes Cognitive Services Speech User and Speech Contributor roles - Documentation: Azure Speech managed identity setup guide --- deployers/terraform/main.tf | 71 ++++- ...ure_speech_managed_identity_manul_setup.md | 261 ++++++++++++++++++ 2 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 docs/how-to/azure_speech_managed_identity_manul_setup.md diff --git a/deployers/terraform/main.tf b/deployers/terraform/main.tf index 77b486df..12029506 100644 --- a/deployers/terraform/main.tf +++ b/deployers/terraform/main.tf @@ -172,6 +172,7 @@ locals { cosmos_db_name = "${var.param_base_name}-${var.param_environment}-cosmos" open_ai_name = "${var.param_base_name}-${var.param_environment}-oai" doc_intel_name = "${var.param_base_name}-${var.param_environment}-docintel" + speech_service_name = "${var.param_base_name}-${var.param_environment}-speech" key_vault_name = "${var.param_base_name}-${var.param_environment}-kv" log_analytics_name = "${var.param_base_name}-${var.param_environment}-la" managed_identity_name = "${var.param_base_name}-${var.param_environment}-id" @@ -625,13 +626,14 @@ resource "azurerm_cosmosdb_account" "cosmos" { # --- Azure OpenAI Service (Cognitive Services) --- resource "azurerm_cognitive_account" "openai" { - count = var.param_use_existing_openai_instance ? 0 : 1 # Only create if not using existing - name = local.open_ai_name - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - kind = "OpenAI" - sku_name = "S0" # Standard tier - tags = local.common_tags + count = var.param_use_existing_openai_instance ? 0 : 1 # Only create if not using existing + name = local.open_ai_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + kind = "OpenAI" + sku_name = "S0" # Standard tier + custom_subdomain_name = local.open_ai_name # Required for managed identity authentication + tags = local.common_tags } # Data source for existing OpenAI instance @@ -643,13 +645,24 @@ data "azurerm_cognitive_account" "existing_openai" { # --- Document Intelligence Service (Cognitive Services) --- resource "azurerm_cognitive_account" "docintel" { - name = local.doc_intel_name - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - kind = "FormRecognizer" - sku_name = "S0" # Standard tier - custom_subdomain_name = local.doc_intel_name # Maps to --custom-domain - tags = local.common_tags + name = local.doc_intel_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + kind = "FormRecognizer" + sku_name = "S0" # Standard tier + custom_subdomain_name = local.doc_intel_name # Required for managed identity authentication + tags = local.common_tags +} + +# --- Speech Service (Cognitive Services) --- +resource "azurerm_cognitive_account" "speech" { + name = local.speech_service_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + kind = "SpeechServices" + sku_name = "S0" # Standard tier + custom_subdomain_name = local.speech_service_name # Required for managed identity authentication + tags = local.common_tags } # https://medium.com/expert-thinking/mastering-azure-search-with-terraform-a-how-to-guide-7edc3a6b1ee3 @@ -702,6 +715,20 @@ resource "azurerm_role_assignment" "managed_identity_storage_contributor" { principal_id = azurerm_user_assigned_identity.id.principal_id } +# Cognitive Services Speech User on Speech Service +resource "azurerm_role_assignment" "managed_identity_speech_user" { + scope = azurerm_cognitive_account.speech.id + role_definition_name = "Cognitive Services Speech User" + principal_id = azurerm_user_assigned_identity.id.principal_id +} + +# Cognitive Services Speech Contributor on Speech Service +resource "azurerm_role_assignment" "managed_identity_speech_contributor" { + scope = azurerm_cognitive_account.speech.id + role_definition_name = "Cognitive Services Speech Contributor" + principal_id = azurerm_user_assigned_identity.id.principal_id +} + # App Registration Service Principal RBAC # Cognitive Services OpenAI Contributor on OpenAI resource "azurerm_role_assignment" "app_reg_sp_openai_contributor" { @@ -732,13 +759,27 @@ resource "azurerm_role_assignment" "app_service_smi_storage_contributor" { principal_id = azurerm_linux_web_app.app.identity[0].principal_id } -# Storage Blob Data Contributor on Storage Account +# AcrPull on Container Registry resource "azurerm_role_assignment" "acr_pull" { scope = data.azurerm_container_registry.acrregistry.id role_definition_name = "AcrPull" principal_id = azurerm_linux_web_app.app.identity[0].principal_id } +# Cognitive Services Speech User on Speech Service +resource "azurerm_role_assignment" "app_service_smi_speech_user" { + scope = azurerm_cognitive_account.speech.id + role_definition_name = "Cognitive Services Speech User" + principal_id = azurerm_linux_web_app.app.identity[0].principal_id +} + +# Cognitive Services Speech Contributor on Speech Service +resource "azurerm_role_assignment" "app_service_smi_speech_contributor" { + scope = azurerm_cognitive_account.speech.id + role_definition_name = "Cognitive Services Speech Contributor" + principal_id = azurerm_linux_web_app.app.identity[0].principal_id +} + ################################################## # diff --git a/docs/how-to/azure_speech_managed_identity_manul_setup.md b/docs/how-to/azure_speech_managed_identity_manul_setup.md new file mode 100644 index 00000000..7941542d --- /dev/null +++ b/docs/how-to/azure_speech_managed_identity_manul_setup.md @@ -0,0 +1,261 @@ +# Azure Speech Service with Managed Identity Setup + +## Overview + +This guide explains the critical difference between key-based and managed identity authentication when configuring Azure Speech Service, and the required steps to enable managed identity properly. + +## Authentication Methods: Regional vs. Resource-Specific Endpoints + +### Regional Endpoint (Shared Gateway) + +**Endpoint format**: `https://.api.cognitive.microsoft.com` +- Example: `https://eastus2.api.cognitive.microsoft.com` +- This is a **shared endpoint** for all Speech resources in that Azure region +- Acts as a gateway that routes requests to individual Speech resources + +### Resource-Specific Endpoint (Custom Subdomain) + +**Endpoint format**: `https://.cognitiveservices.azure.com` +- Example: `https://simplechat6-dev-speech.cognitiveservices.azure.com` +- This is a **unique endpoint** dedicated to your specific Speech resource +- Requires custom subdomain to be enabled on the resource + +--- + +## Why Regional Endpoint Works with Key but NOT Managed Identity + +### Key-Based Authentication ✅ Works with Regional Endpoint + +When using subscription key authentication: + +```http +POST https://eastus2.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe +Headers: + Ocp-Apim-Subscription-Key: abc123def456... +``` + +**Why it works:** +1. The subscription key **directly identifies** your specific Speech resource +2. The regional gateway uses the key to look up which resource it belongs to +3. The request is automatically routed to your resource +4. Authorization succeeds because the key proves ownership + +### Managed Identity (AAD Token) ❌ Fails with Regional Endpoint + +When using managed identity authentication: + +```http +POST https://eastus2.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe +Headers: + Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc... +``` + +**Why it fails (returns 400 BadRequest):** +1. The Bearer token proves your App Service identity to Azure AD +2. The token does NOT specify which Speech resource you want to access +3. The regional gateway cannot determine: + - Which specific Speech resource you're authorized for + - Whether your managed identity has RBAC roles on that resource +4. **Result**: The gateway rejects the request with 400 BadRequest + +### Managed Identity ✅ Works with Resource-Specific Endpoint + +When using managed identity with custom subdomain: + +```http +POST https://simplechat6-dev-speech.cognitiveservices.azure.com/speechtotext/transcriptions:transcribe +Headers: + Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc... +``` + +**Why it works:** +1. The hostname **itself identifies** your specific Speech resource +2. Azure validates your managed identity Bearer token against that resource's RBAC +3. If your App Service MI has `Cognitive Services Speech User` role → authorized +4. The request proceeds to your dedicated Speech resource instance + +--- + +## Required Setup for Managed Identity + +### Prerequisites + +1. **Azure Speech Service resource** created in your subscription +2. **System-assigned or user-assigned managed identity** on your App Service +3. **RBAC role assignments** on the Speech resource + +### Step 1: Enable Custom Subdomain on Speech Resource + +**Why needed**: By default, Speech resources use the regional endpoint and do NOT have custom subdomains. Managed identity requires the resource-specific endpoint. + +**How to enable**: + +```bash +az cognitiveservices account update \ + --name \ + --resource-group \ + --custom-domain +``` + +**Example**: + +```bash +az cognitiveservices account update \ + --name simplechat6-dev-speech \ + --resource-group sc-simplechat6-dev-rg \ + --custom-domain simplechat6-dev-speech +``` + +**Important notes**: +- Custom subdomain name must be **globally unique** across Azure +- Usually use the same name as your resource: `` +- **One-way operation**: Cannot be disabled once enabled +- After enabling, the resource's endpoint property changes from regional to resource-specific + +**Verify custom subdomain is enabled**: + +```bash +az cognitiveservices account show \ + --name \ + --resource-group \ + --query "{customSubDomainName:properties.customSubDomainName, endpoint:properties.endpoint}" +``` + +Expected output: +```json +{ + "customSubDomainName": "simplechat6-dev-speech", + "endpoint": "https://simplechat6-dev-speech.cognitiveservices.azure.com/" +} +``` + +### Step 2: Assign RBAC Roles to Managed Identity + +Grant your App Service managed identity the necessary roles on the Speech resource: + +```bash +# Get the Speech resource ID +SPEECH_RESOURCE_ID=$(az cognitiveservices account show \ + --name \ + --resource-group \ + --query id -o tsv) + +# Get the App Service managed identity principal ID +MI_PRINCIPAL_ID=$(az webapp identity show \ + --name \ + --resource-group \ + --query principalId -o tsv) + +# Assign Cognitive Services Speech User role (data-plane read access) +az role assignment create \ + --assignee $MI_PRINCIPAL_ID \ + --role "Cognitive Services Speech User" \ + --scope $SPEECH_RESOURCE_ID + +# Assign Cognitive Services Speech Contributor role (if needed for write operations) +az role assignment create \ + --assignee $MI_PRINCIPAL_ID \ + --role "Cognitive Services Speech Contributor" \ + --scope $SPEECH_RESOURCE_ID +``` + +**Verify role assignments**: + +```bash +az role assignment list \ + --assignee $MI_PRINCIPAL_ID \ + --scope $SPEECH_RESOURCE_ID \ + -o table +``` + +### Step 3: Configure Admin Settings + +In the Admin Settings → Search & Extract → Multimedia Support section: + +| Setting | Value | Example | +|---------|-------|---------| +| **Enable Audio File Support** | ✅ Checked | | +| **Speech Service Endpoint** | Resource-specific endpoint (with custom subdomain) | `https://simplechat6-dev-speech.cognitiveservices.azure.com` | +| **Speech Service Location** | Azure region | `eastus2` | +| **Speech Service Locale** | Language locale for transcription | `en-US` | +| **Authentication Type** | Managed Identity | | +| **Speech Service Key** | (Leave empty when using MI) | | + +**Critical**: +- Endpoint must be the resource-specific URL (custom subdomain) +- Do NOT use the regional endpoint for managed identity +- Remove trailing slash from endpoint: ✅ `https://..azure.com` ❌ `https://..azure.com/` + +### Step 4: Test Audio Upload + +1. Upload a short WAV or MP3 file +2. Monitor application logs for transcription progress +3. Expected log output: + ``` + File size: 1677804 bytes + Produced 1 WAV chunks: ['/tmp/tmp_chunk_000.wav'] + [Debug] Transcribing WAV chunk: /tmp/tmp_chunk_000.wav + [Debug] Speech config obtained successfully + [Debug] Received 5 phrases + Creating 3 transcript pages + ``` + +--- + +## Troubleshooting + +### Error: NameResolutionError - Failed to resolve hostname + +**Symptom**: `Failed to resolve 'simplechat6-dev-speech.cognitiveservices.azure.com'` + +**Cause**: Custom subdomain not enabled on Speech resource + +**Solution**: Enable custom subdomain using Step 1 above + +### Error: 400 BadRequest when using MI with regional endpoint + +**Symptom**: `400 Client Error: BadRequest for url: https://eastus2.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe` + +**Cause**: Managed identity requires resource-specific endpoint, not regional + +**Solution**: Update Admin Settings endpoint to use `https://.cognitiveservices.azure.com` + +### Error: 401 Authentication error with MI + +**Symptom**: `WebSocket upgrade failed: Authentication error (401)` + +**Cause**: Missing RBAC role assignments + +**Solution**: Assign required roles using Step 2 above + +### Key auth works but MI fails + +**Diagnosis checklist**: +- [ ] Custom subdomain enabled on Speech resource? +- [ ] Admin Settings endpoint is resource-specific (not regional)? +- [ ] Managed identity has RBAC roles on Speech resource? +- [ ] Authentication Type set to "Managed Identity" in Admin Settings? + +--- + +## Summary + +| Authentication Method | Endpoint Type | Example | Works? | +|----------------------|---------------|---------|--------| +| **Key** | Regional | `https://eastus2.api.cognitive.microsoft.com` | ✅ Yes | +| **Key** | Resource-specific | `https://simplechat6-dev-speech.cognitiveservices.azure.com` | ✅ Yes | +| **Managed Identity** | Regional | `https://eastus2.api.cognitive.microsoft.com` | ❌ No (400 BadRequest) | +| **Managed Identity** | Resource-specific | `https://simplechat6-dev-speech.cognitiveservices.azure.com` | ✅ Yes (with custom subdomain) | + +**Key takeaway**: Managed identity for Azure Cognitive Services data-plane operations requires: +1. Custom subdomain enabled on the resource +2. Resource-specific endpoint configured in your application +3. RBAC roles assigned to the managed identity at the resource scope + +--- + +## References + +- [Azure Cognitive Services custom subdomain documentation](https://learn.microsoft.com/azure/cognitive-services/cognitive-services-custom-subdomains) +- [Authenticate with Azure AD using managed identity](https://learn.microsoft.com/azure/cognitive-services/authentication?tabs=powershell#authenticate-with-azure-active-directory) +- [Azure Speech Service authentication](https://learn.microsoft.com/azure/ai-services/speech-service/rest-speech-to-text-short) From 7e0c6883922d467125ad463292f9cb64ae059320 Mon Sep 17 00:00:00 2001 From: "Chen, Vivien" Date: Sat, 24 Jan 2026 09:34:37 -0500 Subject: [PATCH 02/25] Fix Azure AI Search test connection with managed identity Replaced REST API approach with SearchIndexClient SDK to properly handle managed identity authentication in Azure public cloud. The SDK automatically handles token acquisition and endpoint construction, eliminating the 'search_resource_manager is not defined' error that occurred with the REST API approach. --- application/single_app/config.py | 2 +- .../single_app/route_backend_settings.py | 71 +++--- .../AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md | 227 ++++++++++++++++++ 3 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md diff --git a/application/single_app/config.py b/application/single_app/config.py index 0596e3ca..c43f2d0c 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.236.011" +VERSION = "0.236.013" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index be182e93..30e10cb2 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -761,42 +761,45 @@ def _test_azure_ai_search_connection(payload): """Attempt to connect to Azure Cognitive Search (or APIM-wrapped).""" enable_apim = payload.get('enable_apim', False) - if enable_apim: - apim_data = payload.get('apim', {}) - endpoint = apim_data.get('endpoint') # e.g. https://my-apim.azure-api.net/search - subscription_key = apim_data.get('subscription_key') - url = f"{endpoint.rstrip('/')}/indexes?api-version=2023-11-01" - headers = { - 'api-key': subscription_key, - 'Content-Type': 'application/json' - } - else: - direct_data = payload.get('direct', {}) - endpoint = direct_data.get('endpoint') # e.g. https://.search.windows.net - key = direct_data.get('key') - url = f"{endpoint.rstrip('/')}/indexes?api-version=2023-11-01" - - if direct_data.get('auth_type') == 'managed_identity': - credential_scopes=search_resource_manager + "/.default" - arm_scope = credential_scopes - credential = DefaultAzureCredential() - arm_token = credential.get_token(arm_scope).token - headers = { - 'Authorization': f'Bearer {arm_token}', - 'Content-Type': 'application/json' - } + try: + if enable_apim: + apim_data = payload.get('apim', {}) + endpoint = apim_data.get('endpoint') + subscription_key = apim_data.get('subscription_key') + + # Use SearchIndexClient for APIM + credential = AzureKeyCredential(subscription_key) + client = SearchIndexClient(endpoint=endpoint, credential=credential) else: - headers = { - 'api-key': key, - 'Content-Type': 'application/json' - } - - # A small GET to /indexes to verify we have connectivity - resp = requests.get(url, headers=headers, timeout=10) - if resp.status_code == 200: + direct_data = payload.get('direct', {}) + endpoint = direct_data.get('endpoint') + key = direct_data.get('key') + + if direct_data.get('auth_type') == 'managed_identity': + credential = DefaultAzureCredential() + # For managed identity, use the SDK which handles authentication properly + if AZURE_ENVIRONMENT in ("usgovernment", "custom"): + client = SearchIndexClient( + endpoint=endpoint, + credential=credential, + audience=search_resource_manager + ) + else: + # For public cloud, don't use audience parameter + client = SearchIndexClient( + endpoint=endpoint, + credential=credential + ) + else: + credential = AzureKeyCredential(key) + client = SearchIndexClient(endpoint=endpoint, credential=credential) + + # Test by listing indexes (simple operation to verify connectivity) + _ = list(client.list_indexes()) return jsonify({'message': 'Azure AI search connection successful'}), 200 - else: - raise Exception(f"Azure AI search connection error: {resp.status_code} - {resp.text}") + + except Exception as e: + return jsonify({'error': f'Azure AI search connection error: {str(e)}'}), 500 def _test_azure_doc_intelligence_connection(payload): diff --git a/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md b/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md new file mode 100644 index 00000000..7ccdf8bb --- /dev/null +++ b/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md @@ -0,0 +1,227 @@ +# Azure AI Search Test Connection Fix + +## Issue Description + +When clicking the "Test Azure AI Search Connection" button on the App Settings "Search & Extract" page with **managed identity authentication** enabled, the test connection failed with the following error: + +**Original Error Message:** +``` +NameError: name 'search_resource_manager' is not defined +``` + +**Environment Configuration:** +- Authentication Type: Managed Identity +- Azure Environment: `public` (set in .env file) +- Error occurred because the code tried to reference `search_resource_manager` which wasn't defined for public cloud + +**Root Cause:** The old implementation used a REST API approach that required the `search_resource_manager` variable to construct authentication tokens. This variable wasn't defined for the public cloud environment, causing the initial error. Even if defined, the REST API approach with bearer tokens doesn't work properly with Azure AI Search's managed identity authentication. + +## Root Cause Analysis + +The old implementation used a **REST API approach with manually acquired bearer tokens**, which is fundamentally incompatible with how Azure AI Search handles managed identity authentication on the data plane. + +### Why the Old Approach Failed + +Azure AI Search's data plane operations don't properly accept bearer tokens acquired through standard `DefaultAzureCredential.get_token()` flows and passed as HTTP Authorization headers. The authentication mechanism works differently: + +```python +# OLD IMPLEMENTATION - FAILED ❌ +credential = DefaultAzureCredential() +arm_scope = f"{search_resource_manager}/.default" +token = credential.get_token(arm_scope).token + +headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" +} +response = requests.get(f"{endpoint}/indexes?api-version=2024-07-01", headers=headers) +# Returns: 403 Forbidden +``` + +**Problems with this approach:** +1. Azure AI Search requires SDK-specific authentication handling +2. Bearer tokens from `get_token()` are rejected by the Search service +3. Token scope and refresh logic need specialized handling +4. This issue occurs in **all Azure environments** (public, government, custom) + +### Why Other Services Work with REST API + Bearer Tokens + +Some Azure services accept bearer tokens in REST API calls, but Azure AI Search requires the SDK to: +1. Acquire tokens using the correct scope and flow +2. Handle token refresh automatically +3. Use Search-specific authentication headers +4. Properly negotiate with the Search service's auth layer + +## Technical Details + +### Files Modified + +**File:** `route_backend_settings.py` +**Function:** `_test_azure_ai_search_connection(payload)` +**Lines:** 760-796 + +### The Solution + +Instead of trying to define `search_resource_manager` for public cloud, the fix was to **replace the REST API approach entirely with the SearchIndexClient SDK**, which handles authentication correctly without needing the `search_resource_manager` variable. + +### Code Changes Summary + +**Before (REST API approach):** +```python +def _test_azure_ai_search_connection(payload): + # ... setup code ... + + if direct_data.get('auth_type') == 'managed_identity': + credential = DefaultAzureCredential() + arm_scope = f"{search_resource_manager}/.default" + token = credential.get_token(arm_scope).token + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + response = requests.get(f"{endpoint}/indexes?api-version=2024-07-01", headers=headers) + # ❌ Returns 403 Forbidden +``` + +**After (SDK approach):** +```python +def _test_azure_ai_search_connection(payload): + # ... setup code ... + + if direct_data.get('auth_type') == 'managed_identity': + credential = DefaultAzureCredential() + + # Use SDK which handles authentication properly + if AZURE_ENVIRONMENT in ("usgovernment", "custom"): + client = SearchIndexClient( + endpoint=endpoint, + credential=credential, + audience=search_resource_manager + ) + else: + # For public cloud, don't use audience parameter + client = SearchIndexClient( + endpoint=endpoint, + credential=credential + ) + + # Test by listing indexes (simple operation to verify connectivity) + indexes = list(client.list_indexes()) + # ✅ Works correctly +``` + +### Key Implementation Details + +1. **Replaced REST API with SearchIndexClient SDK** + - Uses `SearchIndexClient` from `azure.search.documents` + - SDK handles authentication internally + - Properly manages token acquisition and refresh + +2. **Environment-Specific Configuration** + - **Azure Government/Custom:** Requires `audience` parameter + - **Azure Public Cloud:** Omits `audience` parameter + - Matches pattern used throughout codebase + +3. **Consistent with Other Functions** + - Aligns with `get_index_client()` implementation (line 484) + - Matches SearchClient initialization in `config.py` (lines 584-619) + - All other search operations already use SDK approach + +## Testing Approach + +### Prerequisites +- Service principal must have **"Search Index Data Contributor"** RBAC role +- Permissions must propagate (5-10 minutes after assignment) + +### RBAC Role Assignment Command +```bash +az role assignment create \ + --assignee \ + --role "Search Index Data Contributor" \ + --scope /subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/ +``` + +### Verification +```bash +az role assignment list \ + --assignee \ + --scope /subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/ \ + --output table +``` + +## Impact Analysis + +### What Changed +- **Only the test connection function** was affected +- No changes needed to actual search operations (indexing, querying, etc.) +- All other search functionality already used correct SDK approach + +### Why Other Search Operations Weren't Affected +All production search operations throughout the codebase already use the SDK: +- `SearchClient` for querying indexes +- `SearchIndexClient` for managing indexes +- `get_index_client()` helper function +- Index initialization in `config.py` + +**Only the test connection function used the failed REST API approach.** + +## Validation + +### Before Fix +- ✅ Authentication succeeded (no credential errors) +- ✅ Token acquisition worked +- ❌ Azure AI Search rejected bearer token (403 Forbidden) +- ❌ Test connection failed + +### After Fix +- ✅ Authentication succeeds +- ✅ SDK handles token acquisition properly +- ✅ Azure AI Search accepts SDK authentication +- ✅ Test connection succeeds (with proper RBAC permissions) + +## Configuration Requirements + +### Public Cloud (.env) +```ini +AZURE_ENVIRONMENT=public +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +AZURE_TENANT_ID= +AZURE_AI_SEARCH_AUTHENTICATION_TYPE=managed_identity +AZURE_AI_SEARCH_ENDPOINT=https://.search.windows.net +``` + +### Azure Government (.env) +```ini +AZURE_ENVIRONMENT=usgovernment +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +AZURE_TENANT_ID= +AZURE_AI_SEARCH_AUTHENTICATION_TYPE=managed_identity +AZURE_AI_SEARCH_ENDPOINT=https://.search.windows.us +``` + +## Related Changes + +**No config.py changes were made.** The fix was entirely in the route_backend_settings.py file by switching from REST API to SDK approach. + +The SDK approach eliminates the need for the `search_resource_manager` variable in public cloud because: +- The SearchIndexClient handles authentication internally +- No manual token acquisition is needed +- The SDK knows the correct endpoints and scopes automatically + +## Version Information + +- Application version (`config.py` `app.config['VERSION']`): **0.236.013** +- Fixed in version: **0.236.013** + +## References + +- Azure AI Search SDK Documentation: https://learn.microsoft.com/python/api/azure-search-documents +- Azure RBAC for Search: https://learn.microsoft.com/azure/search/search-security-rbac +- DefaultAzureCredential: https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential + +## Summary + +The fix replaces manual REST API calls with the proper Azure Search SDK (`SearchIndexClient`), which correctly handles managed identity authentication for Azure AI Search. This aligns the test function with all other search operations in the codebase that already use the SDK approach successfully. From 6b0164a8621b75d08d16bf2a9efcb83d2da24da9 Mon Sep 17 00:00:00 2001 From: "Chen, Vivien" Date: Sat, 24 Jan 2026 09:34:37 -0500 Subject: [PATCH 03/25] Fix Azure AI Search test connection with managed identity Replaced REST API approach with SearchIndexClient SDK to properly handle managed identity authentication in Azure public cloud. The SDK automatically handles token acquisition and endpoint construction, eliminating the 'search_resource_manager is not defined' error that occurred with the REST API approach. --- application/single_app/config.py | 2 +- .../single_app/route_backend_settings.py | 71 +++--- .../AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md | 227 ++++++++++++++++++ 3 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md diff --git a/application/single_app/config.py b/application/single_app/config.py index 0596e3ca..c43f2d0c 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.236.011" +VERSION = "0.236.013" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index be182e93..30e10cb2 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -761,42 +761,45 @@ def _test_azure_ai_search_connection(payload): """Attempt to connect to Azure Cognitive Search (or APIM-wrapped).""" enable_apim = payload.get('enable_apim', False) - if enable_apim: - apim_data = payload.get('apim', {}) - endpoint = apim_data.get('endpoint') # e.g. https://my-apim.azure-api.net/search - subscription_key = apim_data.get('subscription_key') - url = f"{endpoint.rstrip('/')}/indexes?api-version=2023-11-01" - headers = { - 'api-key': subscription_key, - 'Content-Type': 'application/json' - } - else: - direct_data = payload.get('direct', {}) - endpoint = direct_data.get('endpoint') # e.g. https://.search.windows.net - key = direct_data.get('key') - url = f"{endpoint.rstrip('/')}/indexes?api-version=2023-11-01" - - if direct_data.get('auth_type') == 'managed_identity': - credential_scopes=search_resource_manager + "/.default" - arm_scope = credential_scopes - credential = DefaultAzureCredential() - arm_token = credential.get_token(arm_scope).token - headers = { - 'Authorization': f'Bearer {arm_token}', - 'Content-Type': 'application/json' - } + try: + if enable_apim: + apim_data = payload.get('apim', {}) + endpoint = apim_data.get('endpoint') + subscription_key = apim_data.get('subscription_key') + + # Use SearchIndexClient for APIM + credential = AzureKeyCredential(subscription_key) + client = SearchIndexClient(endpoint=endpoint, credential=credential) else: - headers = { - 'api-key': key, - 'Content-Type': 'application/json' - } - - # A small GET to /indexes to verify we have connectivity - resp = requests.get(url, headers=headers, timeout=10) - if resp.status_code == 200: + direct_data = payload.get('direct', {}) + endpoint = direct_data.get('endpoint') + key = direct_data.get('key') + + if direct_data.get('auth_type') == 'managed_identity': + credential = DefaultAzureCredential() + # For managed identity, use the SDK which handles authentication properly + if AZURE_ENVIRONMENT in ("usgovernment", "custom"): + client = SearchIndexClient( + endpoint=endpoint, + credential=credential, + audience=search_resource_manager + ) + else: + # For public cloud, don't use audience parameter + client = SearchIndexClient( + endpoint=endpoint, + credential=credential + ) + else: + credential = AzureKeyCredential(key) + client = SearchIndexClient(endpoint=endpoint, credential=credential) + + # Test by listing indexes (simple operation to verify connectivity) + _ = list(client.list_indexes()) return jsonify({'message': 'Azure AI search connection successful'}), 200 - else: - raise Exception(f"Azure AI search connection error: {resp.status_code} - {resp.text}") + + except Exception as e: + return jsonify({'error': f'Azure AI search connection error: {str(e)}'}), 500 def _test_azure_doc_intelligence_connection(payload): diff --git a/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md b/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md new file mode 100644 index 00000000..7ccdf8bb --- /dev/null +++ b/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md @@ -0,0 +1,227 @@ +# Azure AI Search Test Connection Fix + +## Issue Description + +When clicking the "Test Azure AI Search Connection" button on the App Settings "Search & Extract" page with **managed identity authentication** enabled, the test connection failed with the following error: + +**Original Error Message:** +``` +NameError: name 'search_resource_manager' is not defined +``` + +**Environment Configuration:** +- Authentication Type: Managed Identity +- Azure Environment: `public` (set in .env file) +- Error occurred because the code tried to reference `search_resource_manager` which wasn't defined for public cloud + +**Root Cause:** The old implementation used a REST API approach that required the `search_resource_manager` variable to construct authentication tokens. This variable wasn't defined for the public cloud environment, causing the initial error. Even if defined, the REST API approach with bearer tokens doesn't work properly with Azure AI Search's managed identity authentication. + +## Root Cause Analysis + +The old implementation used a **REST API approach with manually acquired bearer tokens**, which is fundamentally incompatible with how Azure AI Search handles managed identity authentication on the data plane. + +### Why the Old Approach Failed + +Azure AI Search's data plane operations don't properly accept bearer tokens acquired through standard `DefaultAzureCredential.get_token()` flows and passed as HTTP Authorization headers. The authentication mechanism works differently: + +```python +# OLD IMPLEMENTATION - FAILED ❌ +credential = DefaultAzureCredential() +arm_scope = f"{search_resource_manager}/.default" +token = credential.get_token(arm_scope).token + +headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" +} +response = requests.get(f"{endpoint}/indexes?api-version=2024-07-01", headers=headers) +# Returns: 403 Forbidden +``` + +**Problems with this approach:** +1. Azure AI Search requires SDK-specific authentication handling +2. Bearer tokens from `get_token()` are rejected by the Search service +3. Token scope and refresh logic need specialized handling +4. This issue occurs in **all Azure environments** (public, government, custom) + +### Why Other Services Work with REST API + Bearer Tokens + +Some Azure services accept bearer tokens in REST API calls, but Azure AI Search requires the SDK to: +1. Acquire tokens using the correct scope and flow +2. Handle token refresh automatically +3. Use Search-specific authentication headers +4. Properly negotiate with the Search service's auth layer + +## Technical Details + +### Files Modified + +**File:** `route_backend_settings.py` +**Function:** `_test_azure_ai_search_connection(payload)` +**Lines:** 760-796 + +### The Solution + +Instead of trying to define `search_resource_manager` for public cloud, the fix was to **replace the REST API approach entirely with the SearchIndexClient SDK**, which handles authentication correctly without needing the `search_resource_manager` variable. + +### Code Changes Summary + +**Before (REST API approach):** +```python +def _test_azure_ai_search_connection(payload): + # ... setup code ... + + if direct_data.get('auth_type') == 'managed_identity': + credential = DefaultAzureCredential() + arm_scope = f"{search_resource_manager}/.default" + token = credential.get_token(arm_scope).token + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + response = requests.get(f"{endpoint}/indexes?api-version=2024-07-01", headers=headers) + # ❌ Returns 403 Forbidden +``` + +**After (SDK approach):** +```python +def _test_azure_ai_search_connection(payload): + # ... setup code ... + + if direct_data.get('auth_type') == 'managed_identity': + credential = DefaultAzureCredential() + + # Use SDK which handles authentication properly + if AZURE_ENVIRONMENT in ("usgovernment", "custom"): + client = SearchIndexClient( + endpoint=endpoint, + credential=credential, + audience=search_resource_manager + ) + else: + # For public cloud, don't use audience parameter + client = SearchIndexClient( + endpoint=endpoint, + credential=credential + ) + + # Test by listing indexes (simple operation to verify connectivity) + indexes = list(client.list_indexes()) + # ✅ Works correctly +``` + +### Key Implementation Details + +1. **Replaced REST API with SearchIndexClient SDK** + - Uses `SearchIndexClient` from `azure.search.documents` + - SDK handles authentication internally + - Properly manages token acquisition and refresh + +2. **Environment-Specific Configuration** + - **Azure Government/Custom:** Requires `audience` parameter + - **Azure Public Cloud:** Omits `audience` parameter + - Matches pattern used throughout codebase + +3. **Consistent with Other Functions** + - Aligns with `get_index_client()` implementation (line 484) + - Matches SearchClient initialization in `config.py` (lines 584-619) + - All other search operations already use SDK approach + +## Testing Approach + +### Prerequisites +- Service principal must have **"Search Index Data Contributor"** RBAC role +- Permissions must propagate (5-10 minutes after assignment) + +### RBAC Role Assignment Command +```bash +az role assignment create \ + --assignee \ + --role "Search Index Data Contributor" \ + --scope /subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/ +``` + +### Verification +```bash +az role assignment list \ + --assignee \ + --scope /subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/ \ + --output table +``` + +## Impact Analysis + +### What Changed +- **Only the test connection function** was affected +- No changes needed to actual search operations (indexing, querying, etc.) +- All other search functionality already used correct SDK approach + +### Why Other Search Operations Weren't Affected +All production search operations throughout the codebase already use the SDK: +- `SearchClient` for querying indexes +- `SearchIndexClient` for managing indexes +- `get_index_client()` helper function +- Index initialization in `config.py` + +**Only the test connection function used the failed REST API approach.** + +## Validation + +### Before Fix +- ✅ Authentication succeeded (no credential errors) +- ✅ Token acquisition worked +- ❌ Azure AI Search rejected bearer token (403 Forbidden) +- ❌ Test connection failed + +### After Fix +- ✅ Authentication succeeds +- ✅ SDK handles token acquisition properly +- ✅ Azure AI Search accepts SDK authentication +- ✅ Test connection succeeds (with proper RBAC permissions) + +## Configuration Requirements + +### Public Cloud (.env) +```ini +AZURE_ENVIRONMENT=public +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +AZURE_TENANT_ID= +AZURE_AI_SEARCH_AUTHENTICATION_TYPE=managed_identity +AZURE_AI_SEARCH_ENDPOINT=https://.search.windows.net +``` + +### Azure Government (.env) +```ini +AZURE_ENVIRONMENT=usgovernment +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +AZURE_TENANT_ID= +AZURE_AI_SEARCH_AUTHENTICATION_TYPE=managed_identity +AZURE_AI_SEARCH_ENDPOINT=https://.search.windows.us +``` + +## Related Changes + +**No config.py changes were made.** The fix was entirely in the route_backend_settings.py file by switching from REST API to SDK approach. + +The SDK approach eliminates the need for the `search_resource_manager` variable in public cloud because: +- The SearchIndexClient handles authentication internally +- No manual token acquisition is needed +- The SDK knows the correct endpoints and scopes automatically + +## Version Information + +- Application version (`config.py` `app.config['VERSION']`): **0.236.013** +- Fixed in version: **0.236.013** + +## References + +- Azure AI Search SDK Documentation: https://learn.microsoft.com/python/api/azure-search-documents +- Azure RBAC for Search: https://learn.microsoft.com/azure/search/search-security-rbac +- DefaultAzureCredential: https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential + +## Summary + +The fix replaces manual REST API calls with the proper Azure Search SDK (`SearchIndexClient`), which correctly handles managed identity authentication for Azure AI Search. This aligns the test function with all other search operations in the codebase that already use the SDK approach successfully. From c910ede36c8003a0af1347bb3c737e5560c6da3a Mon Sep 17 00:00:00 2001 From: "Chen, Vivien" Date: Sat, 24 Jan 2026 10:10:58 -0500 Subject: [PATCH 04/25] Corrected file folder name --- .../AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/explanation/fixes/{v.0.236.013 => v0.236.013}/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md (100%) diff --git a/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md b/docs/explanation/fixes/v0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md similarity index 100% rename from docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md rename to docs/explanation/fixes/v0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md From 8ae851884f87ab125486a70de8f6360754e5722f Mon Sep 17 00:00:00 2001 From: "Chen, Vivien" Date: Sat, 24 Jan 2026 10:14:51 -0500 Subject: [PATCH 05/25] Corrected the version number to reference 0.236.012 --- application/single_app/config.py | 2 +- .../AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename docs/explanation/fixes/{v0.236.013 => v0.236.012}/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md (99%) diff --git a/application/single_app/config.py b/application/single_app/config.py index c43f2d0c..caf09fc8 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.236.013" +VERSION = "0.236.012" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/docs/explanation/fixes/v0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md b/docs/explanation/fixes/v0.236.012/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md similarity index 99% rename from docs/explanation/fixes/v0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md rename to docs/explanation/fixes/v0.236.012/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md index 7ccdf8bb..ea981e7a 100644 --- a/docs/explanation/fixes/v0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md +++ b/docs/explanation/fixes/v0.236.012/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md @@ -213,8 +213,8 @@ The SDK approach eliminates the need for the `search_resource_manager` variable ## Version Information -- Application version (`config.py` `app.config['VERSION']`): **0.236.013** -- Fixed in version: **0.236.013** +- Application version (`config.py` `app.config['VERSION']`): **0.236.012** +- Fixed in version: **0.236.012** ## References From a82ecb70acdf7047697ecde6329e5300e9ed6df8 Mon Sep 17 00:00:00 2001 From: "Chen, Vivien" Date: Sat, 24 Jan 2026 10:17:02 -0500 Subject: [PATCH 06/25] Removed unneeded folder and document --- .../AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md | 227 --------------- ...ure_speech_managed_identity_manul_setup.md | 261 ------------------ 2 files changed, 488 deletions(-) delete mode 100644 docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md delete mode 100644 docs/how-to/azure_speech_managed_identity_manul_setup.md diff --git a/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md b/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md deleted file mode 100644 index 7ccdf8bb..00000000 --- a/docs/explanation/fixes/v.0.236.013/AZURE_AI_SEARCH_TEST_CONNECTION_FIX.md +++ /dev/null @@ -1,227 +0,0 @@ -# Azure AI Search Test Connection Fix - -## Issue Description - -When clicking the "Test Azure AI Search Connection" button on the App Settings "Search & Extract" page with **managed identity authentication** enabled, the test connection failed with the following error: - -**Original Error Message:** -``` -NameError: name 'search_resource_manager' is not defined -``` - -**Environment Configuration:** -- Authentication Type: Managed Identity -- Azure Environment: `public` (set in .env file) -- Error occurred because the code tried to reference `search_resource_manager` which wasn't defined for public cloud - -**Root Cause:** The old implementation used a REST API approach that required the `search_resource_manager` variable to construct authentication tokens. This variable wasn't defined for the public cloud environment, causing the initial error. Even if defined, the REST API approach with bearer tokens doesn't work properly with Azure AI Search's managed identity authentication. - -## Root Cause Analysis - -The old implementation used a **REST API approach with manually acquired bearer tokens**, which is fundamentally incompatible with how Azure AI Search handles managed identity authentication on the data plane. - -### Why the Old Approach Failed - -Azure AI Search's data plane operations don't properly accept bearer tokens acquired through standard `DefaultAzureCredential.get_token()` flows and passed as HTTP Authorization headers. The authentication mechanism works differently: - -```python -# OLD IMPLEMENTATION - FAILED ❌ -credential = DefaultAzureCredential() -arm_scope = f"{search_resource_manager}/.default" -token = credential.get_token(arm_scope).token - -headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" -} -response = requests.get(f"{endpoint}/indexes?api-version=2024-07-01", headers=headers) -# Returns: 403 Forbidden -``` - -**Problems with this approach:** -1. Azure AI Search requires SDK-specific authentication handling -2. Bearer tokens from `get_token()` are rejected by the Search service -3. Token scope and refresh logic need specialized handling -4. This issue occurs in **all Azure environments** (public, government, custom) - -### Why Other Services Work with REST API + Bearer Tokens - -Some Azure services accept bearer tokens in REST API calls, but Azure AI Search requires the SDK to: -1. Acquire tokens using the correct scope and flow -2. Handle token refresh automatically -3. Use Search-specific authentication headers -4. Properly negotiate with the Search service's auth layer - -## Technical Details - -### Files Modified - -**File:** `route_backend_settings.py` -**Function:** `_test_azure_ai_search_connection(payload)` -**Lines:** 760-796 - -### The Solution - -Instead of trying to define `search_resource_manager` for public cloud, the fix was to **replace the REST API approach entirely with the SearchIndexClient SDK**, which handles authentication correctly without needing the `search_resource_manager` variable. - -### Code Changes Summary - -**Before (REST API approach):** -```python -def _test_azure_ai_search_connection(payload): - # ... setup code ... - - if direct_data.get('auth_type') == 'managed_identity': - credential = DefaultAzureCredential() - arm_scope = f"{search_resource_manager}/.default" - token = credential.get_token(arm_scope).token - - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - } - response = requests.get(f"{endpoint}/indexes?api-version=2024-07-01", headers=headers) - # ❌ Returns 403 Forbidden -``` - -**After (SDK approach):** -```python -def _test_azure_ai_search_connection(payload): - # ... setup code ... - - if direct_data.get('auth_type') == 'managed_identity': - credential = DefaultAzureCredential() - - # Use SDK which handles authentication properly - if AZURE_ENVIRONMENT in ("usgovernment", "custom"): - client = SearchIndexClient( - endpoint=endpoint, - credential=credential, - audience=search_resource_manager - ) - else: - # For public cloud, don't use audience parameter - client = SearchIndexClient( - endpoint=endpoint, - credential=credential - ) - - # Test by listing indexes (simple operation to verify connectivity) - indexes = list(client.list_indexes()) - # ✅ Works correctly -``` - -### Key Implementation Details - -1. **Replaced REST API with SearchIndexClient SDK** - - Uses `SearchIndexClient` from `azure.search.documents` - - SDK handles authentication internally - - Properly manages token acquisition and refresh - -2. **Environment-Specific Configuration** - - **Azure Government/Custom:** Requires `audience` parameter - - **Azure Public Cloud:** Omits `audience` parameter - - Matches pattern used throughout codebase - -3. **Consistent with Other Functions** - - Aligns with `get_index_client()` implementation (line 484) - - Matches SearchClient initialization in `config.py` (lines 584-619) - - All other search operations already use SDK approach - -## Testing Approach - -### Prerequisites -- Service principal must have **"Search Index Data Contributor"** RBAC role -- Permissions must propagate (5-10 minutes after assignment) - -### RBAC Role Assignment Command -```bash -az role assignment create \ - --assignee \ - --role "Search Index Data Contributor" \ - --scope /subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/ -``` - -### Verification -```bash -az role assignment list \ - --assignee \ - --scope /subscriptions//resourceGroups//providers/Microsoft.Search/searchServices/ \ - --output table -``` - -## Impact Analysis - -### What Changed -- **Only the test connection function** was affected -- No changes needed to actual search operations (indexing, querying, etc.) -- All other search functionality already used correct SDK approach - -### Why Other Search Operations Weren't Affected -All production search operations throughout the codebase already use the SDK: -- `SearchClient` for querying indexes -- `SearchIndexClient` for managing indexes -- `get_index_client()` helper function -- Index initialization in `config.py` - -**Only the test connection function used the failed REST API approach.** - -## Validation - -### Before Fix -- ✅ Authentication succeeded (no credential errors) -- ✅ Token acquisition worked -- ❌ Azure AI Search rejected bearer token (403 Forbidden) -- ❌ Test connection failed - -### After Fix -- ✅ Authentication succeeds -- ✅ SDK handles token acquisition properly -- ✅ Azure AI Search accepts SDK authentication -- ✅ Test connection succeeds (with proper RBAC permissions) - -## Configuration Requirements - -### Public Cloud (.env) -```ini -AZURE_ENVIRONMENT=public -AZURE_CLIENT_ID= -AZURE_CLIENT_SECRET= -AZURE_TENANT_ID= -AZURE_AI_SEARCH_AUTHENTICATION_TYPE=managed_identity -AZURE_AI_SEARCH_ENDPOINT=https://.search.windows.net -``` - -### Azure Government (.env) -```ini -AZURE_ENVIRONMENT=usgovernment -AZURE_CLIENT_ID= -AZURE_CLIENT_SECRET= -AZURE_TENANT_ID= -AZURE_AI_SEARCH_AUTHENTICATION_TYPE=managed_identity -AZURE_AI_SEARCH_ENDPOINT=https://.search.windows.us -``` - -## Related Changes - -**No config.py changes were made.** The fix was entirely in the route_backend_settings.py file by switching from REST API to SDK approach. - -The SDK approach eliminates the need for the `search_resource_manager` variable in public cloud because: -- The SearchIndexClient handles authentication internally -- No manual token acquisition is needed -- The SDK knows the correct endpoints and scopes automatically - -## Version Information - -- Application version (`config.py` `app.config['VERSION']`): **0.236.013** -- Fixed in version: **0.236.013** - -## References - -- Azure AI Search SDK Documentation: https://learn.microsoft.com/python/api/azure-search-documents -- Azure RBAC for Search: https://learn.microsoft.com/azure/search/search-security-rbac -- DefaultAzureCredential: https://learn.microsoft.com/python/api/azure-identity/azure.identity.defaultazurecredential - -## Summary - -The fix replaces manual REST API calls with the proper Azure Search SDK (`SearchIndexClient`), which correctly handles managed identity authentication for Azure AI Search. This aligns the test function with all other search operations in the codebase that already use the SDK approach successfully. diff --git a/docs/how-to/azure_speech_managed_identity_manul_setup.md b/docs/how-to/azure_speech_managed_identity_manul_setup.md deleted file mode 100644 index 7941542d..00000000 --- a/docs/how-to/azure_speech_managed_identity_manul_setup.md +++ /dev/null @@ -1,261 +0,0 @@ -# Azure Speech Service with Managed Identity Setup - -## Overview - -This guide explains the critical difference between key-based and managed identity authentication when configuring Azure Speech Service, and the required steps to enable managed identity properly. - -## Authentication Methods: Regional vs. Resource-Specific Endpoints - -### Regional Endpoint (Shared Gateway) - -**Endpoint format**: `https://.api.cognitive.microsoft.com` -- Example: `https://eastus2.api.cognitive.microsoft.com` -- This is a **shared endpoint** for all Speech resources in that Azure region -- Acts as a gateway that routes requests to individual Speech resources - -### Resource-Specific Endpoint (Custom Subdomain) - -**Endpoint format**: `https://.cognitiveservices.azure.com` -- Example: `https://simplechat6-dev-speech.cognitiveservices.azure.com` -- This is a **unique endpoint** dedicated to your specific Speech resource -- Requires custom subdomain to be enabled on the resource - ---- - -## Why Regional Endpoint Works with Key but NOT Managed Identity - -### Key-Based Authentication ✅ Works with Regional Endpoint - -When using subscription key authentication: - -```http -POST https://eastus2.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe -Headers: - Ocp-Apim-Subscription-Key: abc123def456... -``` - -**Why it works:** -1. The subscription key **directly identifies** your specific Speech resource -2. The regional gateway uses the key to look up which resource it belongs to -3. The request is automatically routed to your resource -4. Authorization succeeds because the key proves ownership - -### Managed Identity (AAD Token) ❌ Fails with Regional Endpoint - -When using managed identity authentication: - -```http -POST https://eastus2.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe -Headers: - Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc... -``` - -**Why it fails (returns 400 BadRequest):** -1. The Bearer token proves your App Service identity to Azure AD -2. The token does NOT specify which Speech resource you want to access -3. The regional gateway cannot determine: - - Which specific Speech resource you're authorized for - - Whether your managed identity has RBAC roles on that resource -4. **Result**: The gateway rejects the request with 400 BadRequest - -### Managed Identity ✅ Works with Resource-Specific Endpoint - -When using managed identity with custom subdomain: - -```http -POST https://simplechat6-dev-speech.cognitiveservices.azure.com/speechtotext/transcriptions:transcribe -Headers: - Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc... -``` - -**Why it works:** -1. The hostname **itself identifies** your specific Speech resource -2. Azure validates your managed identity Bearer token against that resource's RBAC -3. If your App Service MI has `Cognitive Services Speech User` role → authorized -4. The request proceeds to your dedicated Speech resource instance - ---- - -## Required Setup for Managed Identity - -### Prerequisites - -1. **Azure Speech Service resource** created in your subscription -2. **System-assigned or user-assigned managed identity** on your App Service -3. **RBAC role assignments** on the Speech resource - -### Step 1: Enable Custom Subdomain on Speech Resource - -**Why needed**: By default, Speech resources use the regional endpoint and do NOT have custom subdomains. Managed identity requires the resource-specific endpoint. - -**How to enable**: - -```bash -az cognitiveservices account update \ - --name \ - --resource-group \ - --custom-domain -``` - -**Example**: - -```bash -az cognitiveservices account update \ - --name simplechat6-dev-speech \ - --resource-group sc-simplechat6-dev-rg \ - --custom-domain simplechat6-dev-speech -``` - -**Important notes**: -- Custom subdomain name must be **globally unique** across Azure -- Usually use the same name as your resource: `` -- **One-way operation**: Cannot be disabled once enabled -- After enabling, the resource's endpoint property changes from regional to resource-specific - -**Verify custom subdomain is enabled**: - -```bash -az cognitiveservices account show \ - --name \ - --resource-group \ - --query "{customSubDomainName:properties.customSubDomainName, endpoint:properties.endpoint}" -``` - -Expected output: -```json -{ - "customSubDomainName": "simplechat6-dev-speech", - "endpoint": "https://simplechat6-dev-speech.cognitiveservices.azure.com/" -} -``` - -### Step 2: Assign RBAC Roles to Managed Identity - -Grant your App Service managed identity the necessary roles on the Speech resource: - -```bash -# Get the Speech resource ID -SPEECH_RESOURCE_ID=$(az cognitiveservices account show \ - --name \ - --resource-group \ - --query id -o tsv) - -# Get the App Service managed identity principal ID -MI_PRINCIPAL_ID=$(az webapp identity show \ - --name \ - --resource-group \ - --query principalId -o tsv) - -# Assign Cognitive Services Speech User role (data-plane read access) -az role assignment create \ - --assignee $MI_PRINCIPAL_ID \ - --role "Cognitive Services Speech User" \ - --scope $SPEECH_RESOURCE_ID - -# Assign Cognitive Services Speech Contributor role (if needed for write operations) -az role assignment create \ - --assignee $MI_PRINCIPAL_ID \ - --role "Cognitive Services Speech Contributor" \ - --scope $SPEECH_RESOURCE_ID -``` - -**Verify role assignments**: - -```bash -az role assignment list \ - --assignee $MI_PRINCIPAL_ID \ - --scope $SPEECH_RESOURCE_ID \ - -o table -``` - -### Step 3: Configure Admin Settings - -In the Admin Settings → Search & Extract → Multimedia Support section: - -| Setting | Value | Example | -|---------|-------|---------| -| **Enable Audio File Support** | ✅ Checked | | -| **Speech Service Endpoint** | Resource-specific endpoint (with custom subdomain) | `https://simplechat6-dev-speech.cognitiveservices.azure.com` | -| **Speech Service Location** | Azure region | `eastus2` | -| **Speech Service Locale** | Language locale for transcription | `en-US` | -| **Authentication Type** | Managed Identity | | -| **Speech Service Key** | (Leave empty when using MI) | | - -**Critical**: -- Endpoint must be the resource-specific URL (custom subdomain) -- Do NOT use the regional endpoint for managed identity -- Remove trailing slash from endpoint: ✅ `https://..azure.com` ❌ `https://..azure.com/` - -### Step 4: Test Audio Upload - -1. Upload a short WAV or MP3 file -2. Monitor application logs for transcription progress -3. Expected log output: - ``` - File size: 1677804 bytes - Produced 1 WAV chunks: ['/tmp/tmp_chunk_000.wav'] - [Debug] Transcribing WAV chunk: /tmp/tmp_chunk_000.wav - [Debug] Speech config obtained successfully - [Debug] Received 5 phrases - Creating 3 transcript pages - ``` - ---- - -## Troubleshooting - -### Error: NameResolutionError - Failed to resolve hostname - -**Symptom**: `Failed to resolve 'simplechat6-dev-speech.cognitiveservices.azure.com'` - -**Cause**: Custom subdomain not enabled on Speech resource - -**Solution**: Enable custom subdomain using Step 1 above - -### Error: 400 BadRequest when using MI with regional endpoint - -**Symptom**: `400 Client Error: BadRequest for url: https://eastus2.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe` - -**Cause**: Managed identity requires resource-specific endpoint, not regional - -**Solution**: Update Admin Settings endpoint to use `https://.cognitiveservices.azure.com` - -### Error: 401 Authentication error with MI - -**Symptom**: `WebSocket upgrade failed: Authentication error (401)` - -**Cause**: Missing RBAC role assignments - -**Solution**: Assign required roles using Step 2 above - -### Key auth works but MI fails - -**Diagnosis checklist**: -- [ ] Custom subdomain enabled on Speech resource? -- [ ] Admin Settings endpoint is resource-specific (not regional)? -- [ ] Managed identity has RBAC roles on Speech resource? -- [ ] Authentication Type set to "Managed Identity" in Admin Settings? - ---- - -## Summary - -| Authentication Method | Endpoint Type | Example | Works? | -|----------------------|---------------|---------|--------| -| **Key** | Regional | `https://eastus2.api.cognitive.microsoft.com` | ✅ Yes | -| **Key** | Resource-specific | `https://simplechat6-dev-speech.cognitiveservices.azure.com` | ✅ Yes | -| **Managed Identity** | Regional | `https://eastus2.api.cognitive.microsoft.com` | ❌ No (400 BadRequest) | -| **Managed Identity** | Resource-specific | `https://simplechat6-dev-speech.cognitiveservices.azure.com` | ✅ Yes (with custom subdomain) | - -**Key takeaway**: Managed identity for Azure Cognitive Services data-plane operations requires: -1. Custom subdomain enabled on the resource -2. Resource-specific endpoint configured in your application -3. RBAC roles assigned to the managed identity at the resource scope - ---- - -## References - -- [Azure Cognitive Services custom subdomain documentation](https://learn.microsoft.com/azure/cognitive-services/cognitive-services-custom-subdomains) -- [Authenticate with Azure AD using managed identity](https://learn.microsoft.com/azure/cognitive-services/authentication?tabs=powershell#authenticate-with-azure-active-directory) -- [Azure Speech Service authentication](https://learn.microsoft.com/azure/ai-services/speech-service/rest-speech-to-text-short) From 589291bba30a382333a640b9576e7acd4902e002 Mon Sep 17 00:00:00 2001 From: "Chen, Vivien" Date: Sat, 24 Jan 2026 10:32:55 -0500 Subject: [PATCH 07/25] Revert terraform main.tf to upstream/Development version --- deployers/terraform/main.tf | 71 ++++++++----------------------------- 1 file changed, 15 insertions(+), 56 deletions(-) diff --git a/deployers/terraform/main.tf b/deployers/terraform/main.tf index 12029506..77b486df 100644 --- a/deployers/terraform/main.tf +++ b/deployers/terraform/main.tf @@ -172,7 +172,6 @@ locals { cosmos_db_name = "${var.param_base_name}-${var.param_environment}-cosmos" open_ai_name = "${var.param_base_name}-${var.param_environment}-oai" doc_intel_name = "${var.param_base_name}-${var.param_environment}-docintel" - speech_service_name = "${var.param_base_name}-${var.param_environment}-speech" key_vault_name = "${var.param_base_name}-${var.param_environment}-kv" log_analytics_name = "${var.param_base_name}-${var.param_environment}-la" managed_identity_name = "${var.param_base_name}-${var.param_environment}-id" @@ -626,14 +625,13 @@ resource "azurerm_cosmosdb_account" "cosmos" { # --- Azure OpenAI Service (Cognitive Services) --- resource "azurerm_cognitive_account" "openai" { - count = var.param_use_existing_openai_instance ? 0 : 1 # Only create if not using existing - name = local.open_ai_name - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - kind = "OpenAI" - sku_name = "S0" # Standard tier - custom_subdomain_name = local.open_ai_name # Required for managed identity authentication - tags = local.common_tags + count = var.param_use_existing_openai_instance ? 0 : 1 # Only create if not using existing + name = local.open_ai_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + kind = "OpenAI" + sku_name = "S0" # Standard tier + tags = local.common_tags } # Data source for existing OpenAI instance @@ -645,24 +643,13 @@ data "azurerm_cognitive_account" "existing_openai" { # --- Document Intelligence Service (Cognitive Services) --- resource "azurerm_cognitive_account" "docintel" { - name = local.doc_intel_name - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - kind = "FormRecognizer" - sku_name = "S0" # Standard tier - custom_subdomain_name = local.doc_intel_name # Required for managed identity authentication - tags = local.common_tags -} - -# --- Speech Service (Cognitive Services) --- -resource "azurerm_cognitive_account" "speech" { - name = local.speech_service_name - location = azurerm_resource_group.rg.location - resource_group_name = azurerm_resource_group.rg.name - kind = "SpeechServices" - sku_name = "S0" # Standard tier - custom_subdomain_name = local.speech_service_name # Required for managed identity authentication - tags = local.common_tags + name = local.doc_intel_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + kind = "FormRecognizer" + sku_name = "S0" # Standard tier + custom_subdomain_name = local.doc_intel_name # Maps to --custom-domain + tags = local.common_tags } # https://medium.com/expert-thinking/mastering-azure-search-with-terraform-a-how-to-guide-7edc3a6b1ee3 @@ -715,20 +702,6 @@ resource "azurerm_role_assignment" "managed_identity_storage_contributor" { principal_id = azurerm_user_assigned_identity.id.principal_id } -# Cognitive Services Speech User on Speech Service -resource "azurerm_role_assignment" "managed_identity_speech_user" { - scope = azurerm_cognitive_account.speech.id - role_definition_name = "Cognitive Services Speech User" - principal_id = azurerm_user_assigned_identity.id.principal_id -} - -# Cognitive Services Speech Contributor on Speech Service -resource "azurerm_role_assignment" "managed_identity_speech_contributor" { - scope = azurerm_cognitive_account.speech.id - role_definition_name = "Cognitive Services Speech Contributor" - principal_id = azurerm_user_assigned_identity.id.principal_id -} - # App Registration Service Principal RBAC # Cognitive Services OpenAI Contributor on OpenAI resource "azurerm_role_assignment" "app_reg_sp_openai_contributor" { @@ -759,27 +732,13 @@ resource "azurerm_role_assignment" "app_service_smi_storage_contributor" { principal_id = azurerm_linux_web_app.app.identity[0].principal_id } -# AcrPull on Container Registry +# Storage Blob Data Contributor on Storage Account resource "azurerm_role_assignment" "acr_pull" { scope = data.azurerm_container_registry.acrregistry.id role_definition_name = "AcrPull" principal_id = azurerm_linux_web_app.app.identity[0].principal_id } -# Cognitive Services Speech User on Speech Service -resource "azurerm_role_assignment" "app_service_smi_speech_user" { - scope = azurerm_cognitive_account.speech.id - role_definition_name = "Cognitive Services Speech User" - principal_id = azurerm_linux_web_app.app.identity[0].principal_id -} - -# Cognitive Services Speech Contributor on Speech Service -resource "azurerm_role_assignment" "app_service_smi_speech_contributor" { - scope = azurerm_cognitive_account.speech.id - role_definition_name = "Cognitive Services Speech Contributor" - principal_id = azurerm_linux_web_app.app.identity[0].principal_id -} - ################################################## # From d017028719d8e8ea0b1397ba797cbb2106e5f92f Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sat, 24 Jan 2026 18:02:49 -0500 Subject: [PATCH 08/25] updated the logging logic when running retention delete with archiving enabled (#642) --- application/single_app/config.py | 2 +- .../single_app/functions_retention_policy.py | 66 +++++-- .../RETENTION_POLICY_NOTFOUND_FIX.md | 95 +++++++++ ...test_retention_policy_notfound_handling.py | 180 ++++++++++++++++++ 4 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md create mode 100644 functional_tests/test_retention_policy_notfound_handling.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 0596e3ca..caf09fc8 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.236.011" +VERSION = "0.236.012" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_retention_policy.py b/application/single_app/functions_retention_policy.py index 6ce6dee0..56167fa1 100644 --- a/application/single_app/functions_retention_policy.py +++ b/application/single_app/functions_retention_policy.py @@ -6,8 +6,9 @@ This module handles automated deletion of aged conversations and documents based on configurable retention policies for personal, group, and public workspaces. -Version: 0.234.067 +Version: 0.236.012 Implemented in: 0.234.067 +Updated in: 0.236.012 - Fixed race condition handling for NotFound errors during deletion """ from config import * @@ -565,10 +566,21 @@ def delete_aged_conversations(retention_days, workspace_type='personal', user_id conversation_title = conv.get('title', 'Untitled') # Read full conversation for archiving/logging - conversation_item = container.read_item( - item=conversation_id, - partition_key=conversation_id - ) + try: + conversation_item = container.read_item( + item=conversation_id, + partition_key=conversation_id + ) + except CosmosResourceNotFoundError: + # Conversation was already deleted (race condition) - this is fine, skip to next + debug_print(f"Conversation {conversation_id} already deleted (not found during read), skipping") + deleted_details.append({ + 'id': conversation_id, + 'title': conversation_title, + 'last_activity_at': conv.get('last_activity_at'), + 'already_deleted': True + }) + continue # Archive if enabled if archiving_enabled: @@ -613,7 +625,11 @@ def delete_aged_conversations(retention_days, workspace_type='personal', user_id archived_msg["archived_by_retention_policy"] = True cosmos_archived_messages_container.upsert_item(archived_msg) - messages_container.delete_item(msg['id'], partition_key=conversation_id) + try: + messages_container.delete_item(msg['id'], partition_key=conversation_id) + except CosmosResourceNotFoundError: + # Message was already deleted - this is fine, continue + debug_print(f"Message {msg['id']} already deleted (not found), skipping") # Log deletion log_conversation_deletion( @@ -631,10 +647,14 @@ def delete_aged_conversations(retention_days, workspace_type='personal', user_id ) # Delete conversation - container.delete_item( - item=conversation_id, - partition_key=conversation_id - ) + try: + container.delete_item( + item=conversation_id, + partition_key=conversation_id + ) + except CosmosResourceNotFoundError: + # Conversation was already deleted after we read it (race condition) - this is fine + debug_print(f"Conversation {conversation_id} already deleted (not found during delete)") deleted_details.append({ 'id': conversation_id, @@ -730,10 +750,21 @@ def delete_aged_documents(retention_days, workspace_type='personal', user_id=Non doc_user_id = doc.get('user_id') or deletion_user_id # Delete document chunks from search index - delete_document_chunks(document_id, group_id, public_workspace_id) + try: + delete_document_chunks(document_id, group_id, public_workspace_id) + except CosmosResourceNotFoundError: + # Document chunks already deleted - this is fine + debug_print(f"Document chunks for {document_id} already deleted (not found)") + except Exception as chunk_error: + # Log chunk deletion errors but continue with document deletion + debug_print(f"Error deleting chunks for document {document_id}: {chunk_error}") # Delete document from Cosmos DB and blob storage - delete_document(doc_user_id, document_id, group_id, public_workspace_id) + try: + delete_document(doc_user_id, document_id, group_id, public_workspace_id) + except CosmosResourceNotFoundError: + # Document was already deleted (race condition) - this is fine + debug_print(f"Document {document_id} already deleted (not found)") deleted_details.append({ 'id': document_id, @@ -744,6 +775,17 @@ def delete_aged_documents(retention_days, workspace_type='personal', user_id=Non debug_print(f"Deleted document {document_id} ({file_name}) due to retention policy") + except CosmosResourceNotFoundError: + # Document was already deleted - count as success + doc_id = doc.get('id', 'unknown') if doc else 'unknown' + debug_print(f"Document {doc_id} already deleted (not found)") + deleted_details.append({ + 'id': doc_id, + 'file_name': doc.get('file_name', 'Unknown'), + 'title': doc.get('title', doc.get('file_name', 'Unknown')), + 'last_updated': doc.get('last_updated'), + 'already_deleted': True + }) except Exception as e: doc_id = doc.get('id', 'unknown') if doc else 'unknown' log_event("delete_aged_documents_deletion_error", {"error": str(e), "document_id": doc_id, "workspace_type": workspace_type}) diff --git a/docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md b/docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md new file mode 100644 index 00000000..82a0ec15 --- /dev/null +++ b/docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md @@ -0,0 +1,95 @@ +# Retention Policy NotFound Error Fix + +## Issue Description + +The retention policy deletion process was logging errors when attempting to delete conversations or documents that had already been deleted (e.g., by another process or user action between the query and delete operations). + +### Error Observed +``` +DEBUG: [Log] delete_aged_conversations_deletion_error -- {'error': '(NotFound) Entity with the specified id does not exist in the system. +``` + +### Root Cause + +This is a **race condition** scenario where: +1. The retention policy queries for aged conversations/documents +2. Between the query and the delete operation, the item is deleted by another process (user action, concurrent retention execution, etc.) +3. The delete operation fails with `CosmosResourceNotFoundError` (404 NotFound) + +## Fix Applied + +**Version: 0.236.012** + +The fix adds specific handling for `CosmosResourceNotFoundError` in both conversation and document deletion loops: + +### Conversations +- When reading a conversation before archiving: If not found, log debug message and count as already deleted +- When deleting messages: Catch NotFound and continue (message already gone) +- When deleting conversation: Catch NotFound and continue (conversation already gone) + +### Documents +- When deleting document chunks: Catch NotFound and continue +- When deleting document: Catch NotFound and continue +- Outer try/catch also handles NotFound to count as successful deletion + +## Files Modified + +- [functions_retention_policy.py](../../../application/single_app/functions_retention_policy.py) + - `delete_aged_conversations()` - Added CosmosResourceNotFoundError handling + - `delete_aged_documents()` - Added CosmosResourceNotFoundError handling + +## Technical Details + +### Before Fix +```python +# Read would throw exception if item was deleted between query and read +conversation_item = container.read_item( + item=conversation_id, + partition_key=conversation_id +) +# Delete would throw exception if item was deleted +container.delete_item( + item=conversation_id, + partition_key=conversation_id +) +``` + +### After Fix +```python +try: + conversation_item = container.read_item( + item=conversation_id, + partition_key=conversation_id + ) +except CosmosResourceNotFoundError: + # Already deleted - this is fine, count as success + debug_print(f"Conversation {conversation_id} already deleted (not found during read), skipping") + deleted_details.append({...}) + continue + +# ... archiving and message deletion ... + +try: + container.delete_item( + item=conversation_id, + partition_key=conversation_id + ) +except CosmosResourceNotFoundError: + # Already deleted between read and delete - this is fine + debug_print(f"Conversation {conversation_id} already deleted (not found during delete)") +``` + +## Benefits + +1. **No false error logs**: Items that are already deleted no longer generate error entries +2. **Accurate counts**: Already-deleted items are properly counted as successful deletions +3. **Graceful handling**: Race conditions are handled without disrupting the overall retention process +4. **Better debugging**: Debug messages clearly indicate when items were already deleted + +## Testing + +Test by: +1. Enabling retention policy with a short retention period +2. Running the retention policy execution +3. Verify no NotFound errors are logged +4. Verify deletion counts accurately reflect processed items diff --git a/functional_tests/test_retention_policy_notfound_handling.py b/functional_tests/test_retention_policy_notfound_handling.py new file mode 100644 index 00000000..ee417335 --- /dev/null +++ b/functional_tests/test_retention_policy_notfound_handling.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +""" +Functional test for Retention Policy NotFound Error Handling. +Version: 0.236.012 +Implemented in: 0.236.012 + +This test ensures that the retention policy correctly handles CosmosResourceNotFoundError +when attempting to delete conversations or documents that have already been deleted. +This prevents false error logging for race condition scenarios. +""" + +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + +def test_notfound_exception_import(): + """Test that CosmosResourceNotFoundError is properly imported.""" + print("🔍 Testing CosmosResourceNotFoundError import...") + + try: + from config import CosmosResourceNotFoundError + print("✅ CosmosResourceNotFoundError imported successfully from config") + return True + except ImportError as e: + print(f"❌ Failed to import CosmosResourceNotFoundError: {e}") + return False + + +def test_retention_policy_function_definitions(): + """Test that retention policy functions have proper exception handling.""" + print("\n🔍 Testing retention policy function definitions...") + + try: + import inspect + from functions_retention_policy import delete_aged_conversations, delete_aged_documents + + # Get source code of delete_aged_conversations + conversations_source = inspect.getsource(delete_aged_conversations) + + # Check for CosmosResourceNotFoundError handling in conversations function + if 'CosmosResourceNotFoundError' in conversations_source: + print("✅ delete_aged_conversations handles CosmosResourceNotFoundError") + else: + print("❌ delete_aged_conversations does not handle CosmosResourceNotFoundError") + return False + + # Check for 'already deleted' debug message pattern + if 'already deleted' in conversations_source: + print("✅ delete_aged_conversations has 'already deleted' debug messaging") + else: + print("❌ delete_aged_conversations missing 'already deleted' debug messaging") + return False + + # Get source code of delete_aged_documents + documents_source = inspect.getsource(delete_aged_documents) + + # Check for CosmosResourceNotFoundError handling in documents function + if 'CosmosResourceNotFoundError' in documents_source: + print("✅ delete_aged_documents handles CosmosResourceNotFoundError") + else: + print("❌ delete_aged_documents does not handle CosmosResourceNotFoundError") + return False + + # Check for 'already deleted' debug message pattern + if 'already deleted' in documents_source: + print("✅ delete_aged_documents has 'already deleted' debug messaging") + else: + print("❌ delete_aged_documents missing 'already deleted' debug messaging") + return False + + return True + + except Exception as e: + print(f"❌ Failed to verify function definitions: {e}") + import traceback + traceback.print_exc() + return False + + +def test_already_deleted_flag_in_details(): + """Test that already_deleted flag is used in the response details.""" + print("\n🔍 Testing 'already_deleted' flag in response details...") + + try: + import inspect + from functions_retention_policy import delete_aged_conversations, delete_aged_documents + + # Get source code + conversations_source = inspect.getsource(delete_aged_conversations) + documents_source = inspect.getsource(delete_aged_documents) + + # Check for 'already_deleted': True pattern in conversations + if "'already_deleted': True" in conversations_source or '"already_deleted": True' in conversations_source: + print("✅ delete_aged_conversations includes 'already_deleted' flag in details") + else: + print("❌ delete_aged_conversations missing 'already_deleted' flag in details") + return False + + # Check for 'already_deleted': True pattern in documents + if "'already_deleted': True" in documents_source or '"already_deleted": True' in documents_source: + print("✅ delete_aged_documents includes 'already_deleted' flag in details") + else: + print("❌ delete_aged_documents missing 'already_deleted' flag in details") + return False + + return True + + except Exception as e: + print(f"❌ Failed to verify already_deleted flag: {e}") + import traceback + traceback.print_exc() + return False + + +def test_version_number(): + """Test that the version is updated correctly.""" + print("\n🔍 Testing version number...") + + try: + from config import VERSION + + # Version should be at least 0.236.012 + version_parts = VERSION.split('.') + major = int(version_parts[0]) + minor = int(version_parts[1]) + patch = int(version_parts[2]) + + if major == 0 and minor >= 236 and patch >= 12: + print(f"✅ Version {VERSION} is correct (>= 0.236.012)") + return True + elif major > 0 or minor > 236: + print(f"✅ Version {VERSION} is correct (later version)") + return True + else: + print(f"❌ Version {VERSION} is lower than expected 0.236.012") + return False + + except Exception as e: + print(f"❌ Failed to verify version: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + print("=" * 60) + print("Retention Policy NotFound Error Handling Test") + print("=" * 60) + + tests = [ + test_notfound_exception_import, + test_retention_policy_function_definitions, + test_already_deleted_flag_in_details, + test_version_number + ] + + results = [] + for test in tests: + try: + result = test() + results.append(result) + except Exception as e: + print(f"❌ Test {test.__name__} failed with exception: {e}") + import traceback + traceback.print_exc() + results.append(False) + + print("\n" + "=" * 60) + print(f"📊 Results: {sum(results)}/{len(results)} tests passed") + print("=" * 60) + + if all(results): + print("\n✅ All tests passed! NotFound error handling is correctly implemented.") + sys.exit(0) + else: + print("\n❌ Some tests failed. Please review the implementation.") + sys.exit(1) From 2e8e87a43b40c0d397a6cb8252408c65b0a2824d Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 26 Jan 2026 09:30:34 -0500 Subject: [PATCH 09/25] Corrected version to 0.236.011 (#645) --- application/single_app/config.py | 2 +- .../{v0.236.012 => v0.236.011}/RETENTION_POLICY_NOTFOUND_FIX.md | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/explanation/fixes/{v0.236.012 => v0.236.011}/RETENTION_POLICY_NOTFOUND_FIX.md (100%) diff --git a/application/single_app/config.py b/application/single_app/config.py index caf09fc8..0596e3ca 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.236.012" +VERSION = "0.236.011" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md b/docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md similarity index 100% rename from docs/explanation/fixes/v0.236.012/RETENTION_POLICY_NOTFOUND_FIX.md rename to docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md From 604246126ddc931e300798ff2b7e5b7307f8c5a5 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 26 Jan 2026 09:44:27 -0500 Subject: [PATCH 10/25] v0.237.001 (#649) --- application/single_app/config.py | 2 +- .../CONTROL_CENTER_APPLICATION_ROLES.md | 2 +- .../{v0.236.011 => v0.237.001}/CONVERSATION_DEEP_LINKING.md | 2 +- .../{v0.236.011 => v0.237.001}/PLUGIN_AUTH_TYPE_CONSTRAINTS.md | 2 +- .../{v0.236.011 => v0.237.001}/PRIVATE_NETWORKING_SUPPORT.md | 2 +- .../{v0.236.011 => v0.237.001}/RETENTION_POLICY_DEFAULTS.md | 3 +-- .../features/{v0.236.011 => v0.237.001}/USER_AGREEMENT.md | 2 +- .../{v0.236.011 => v0.237.001}/WEB_SEARCH_AZURE_AI_FOUNDRY.md | 2 +- .../AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md | 2 +- .../AGENT_TEMPLATE_MAX_LENGTHS_FIX.md | 2 +- .../CONTROL_CENTER_DATE_LABELS_FIX.md | 2 +- .../RETENTION_POLICY_NOTFOUND_FIX.md | 2 +- .../SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md | 2 +- .../USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md | 2 +- .../WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md | 2 +- docs/explanation/release_notes.md | 2 +- 16 files changed, 16 insertions(+), 17 deletions(-) rename docs/explanation/features/{v0.236.011 => v0.237.001}/CONTROL_CENTER_APPLICATION_ROLES.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/CONVERSATION_DEEP_LINKING.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/PLUGIN_AUTH_TYPE_CONSTRAINTS.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/PRIVATE_NETWORKING_SUPPORT.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/RETENTION_POLICY_DEFAULTS.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/USER_AGREEMENT.md (99%) rename docs/explanation/features/{v0.236.011 => v0.237.001}/WEB_SEARCH_AZURE_AI_FOUNDRY.md (99%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md (95%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md (95%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/CONTROL_CENTER_DATE_LABELS_FIX.md (96%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/RETENTION_POLICY_NOTFOUND_FIX.md (99%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md (98%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md (98%) rename docs/explanation/fixes/{v0.236.011 => v0.237.001}/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md (99%) diff --git a/application/single_app/config.py b/application/single_app/config.py index 0596e3ca..12906ce8 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.236.011" +VERSION = "0.237.001" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/docs/explanation/features/v0.236.011/CONTROL_CENTER_APPLICATION_ROLES.md b/docs/explanation/features/v0.237.001/CONTROL_CENTER_APPLICATION_ROLES.md similarity index 99% rename from docs/explanation/features/v0.236.011/CONTROL_CENTER_APPLICATION_ROLES.md rename to docs/explanation/features/v0.237.001/CONTROL_CENTER_APPLICATION_ROLES.md index 29ffc1fc..3d61f752 100644 --- a/docs/explanation/features/v0.236.011/CONTROL_CENTER_APPLICATION_ROLES.md +++ b/docs/explanation/features/v0.237.001/CONTROL_CENTER_APPLICATION_ROLES.md @@ -4,7 +4,7 @@ Added two new application roles for finer-grained access control to the Control Center, enabling organizations to delegate administrative functions while maintaining security boundaries. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## New Roles diff --git a/docs/explanation/features/v0.236.011/CONVERSATION_DEEP_LINKING.md b/docs/explanation/features/v0.237.001/CONVERSATION_DEEP_LINKING.md similarity index 99% rename from docs/explanation/features/v0.236.011/CONVERSATION_DEEP_LINKING.md rename to docs/explanation/features/v0.237.001/CONVERSATION_DEEP_LINKING.md index d3c6e53e..cebf392b 100644 --- a/docs/explanation/features/v0.236.011/CONVERSATION_DEEP_LINKING.md +++ b/docs/explanation/features/v0.237.001/CONVERSATION_DEEP_LINKING.md @@ -4,7 +4,7 @@ SimpleChat now supports conversation deep linking through URL query parameters. Users can share direct links to specific conversations, and the application will automatically navigate to and load the referenced conversation when the link is accessed. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/features/v0.236.011/PLUGIN_AUTH_TYPE_CONSTRAINTS.md b/docs/explanation/features/v0.237.001/PLUGIN_AUTH_TYPE_CONSTRAINTS.md similarity index 99% rename from docs/explanation/features/v0.236.011/PLUGIN_AUTH_TYPE_CONSTRAINTS.md rename to docs/explanation/features/v0.237.001/PLUGIN_AUTH_TYPE_CONSTRAINTS.md index 093923c6..9d2ea6e6 100644 --- a/docs/explanation/features/v0.236.011/PLUGIN_AUTH_TYPE_CONSTRAINTS.md +++ b/docs/explanation/features/v0.237.001/PLUGIN_AUTH_TYPE_CONSTRAINTS.md @@ -4,7 +4,7 @@ SimpleChat now enforces authentication type constraints per plugin type. Different plugin types may support different authentication methods based on their requirements and the APIs they integrate with. This feature provides a structured way to define and retrieve allowed authentication types for each plugin type. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/features/v0.236.011/PRIVATE_NETWORKING_SUPPORT.md b/docs/explanation/features/v0.237.001/PRIVATE_NETWORKING_SUPPORT.md similarity index 99% rename from docs/explanation/features/v0.236.011/PRIVATE_NETWORKING_SUPPORT.md rename to docs/explanation/features/v0.237.001/PRIVATE_NETWORKING_SUPPORT.md index de2ae92f..5379b73d 100644 --- a/docs/explanation/features/v0.236.011/PRIVATE_NETWORKING_SUPPORT.md +++ b/docs/explanation/features/v0.237.001/PRIVATE_NETWORKING_SUPPORT.md @@ -4,7 +4,7 @@ Comprehensive private networking support for SimpleChat deployments via Azure Developer CLI (AZD) and Bicep infrastructure-as-code. This feature enables secure, isolated deployments with private endpoints, virtual networks, and private DNS zones. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/features/v0.236.011/RETENTION_POLICY_DEFAULTS.md b/docs/explanation/features/v0.237.001/RETENTION_POLICY_DEFAULTS.md similarity index 99% rename from docs/explanation/features/v0.236.011/RETENTION_POLICY_DEFAULTS.md rename to docs/explanation/features/v0.237.001/RETENTION_POLICY_DEFAULTS.md index e3fe426d..cdca4ce5 100644 --- a/docs/explanation/features/v0.236.011/RETENTION_POLICY_DEFAULTS.md +++ b/docs/explanation/features/v0.237.001/RETENTION_POLICY_DEFAULTS.md @@ -1,8 +1,7 @@ # RETENTION_POLICY_DEFAULTS.md **Feature**: Admin-Configurable Default Retention Policies -**Version**: 0.236.011 -**Implemented in**: 0.236.011 +**Version**: v0.237.001 ## Overview and Purpose diff --git a/docs/explanation/features/v0.236.011/USER_AGREEMENT.md b/docs/explanation/features/v0.237.001/USER_AGREEMENT.md similarity index 99% rename from docs/explanation/features/v0.236.011/USER_AGREEMENT.md rename to docs/explanation/features/v0.237.001/USER_AGREEMENT.md index e87589d7..d72d6533 100644 --- a/docs/explanation/features/v0.236.011/USER_AGREEMENT.md +++ b/docs/explanation/features/v0.237.001/USER_AGREEMENT.md @@ -4,7 +4,7 @@ The User Agreement feature allows administrators to configure a global agreement that users must accept before uploading files to workspaces. This provides organizations with a mechanism to ensure users acknowledge terms, policies, or guidelines before contributing documents to the system. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/features/v0.236.011/WEB_SEARCH_AZURE_AI_FOUNDRY.md b/docs/explanation/features/v0.237.001/WEB_SEARCH_AZURE_AI_FOUNDRY.md similarity index 99% rename from docs/explanation/features/v0.236.011/WEB_SEARCH_AZURE_AI_FOUNDRY.md rename to docs/explanation/features/v0.237.001/WEB_SEARCH_AZURE_AI_FOUNDRY.md index 7107017f..2e8e3b7c 100644 --- a/docs/explanation/features/v0.236.011/WEB_SEARCH_AZURE_AI_FOUNDRY.md +++ b/docs/explanation/features/v0.237.001/WEB_SEARCH_AZURE_AI_FOUNDRY.md @@ -4,7 +4,7 @@ SimpleChat now supports web search capability through Azure AI Foundry agents using the Grounding with Bing Search service. This feature enables AI responses to be augmented with real-time web search results, providing users with up-to-date information beyond the model's training data. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 ## Key Features diff --git a/docs/explanation/fixes/v0.236.011/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md b/docs/explanation/fixes/v0.237.001/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md similarity index 95% rename from docs/explanation/fixes/v0.236.011/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md rename to docs/explanation/fixes/v0.237.001/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md index e7f38582..e3119d5c 100644 --- a/docs/explanation/fixes/v0.236.011/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md +++ b/docs/explanation/fixes/v0.237.001/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md @@ -1,4 +1,4 @@ -# Agent Payload Field Lengths Fix (Version 0.237.009) +# Agent Payload Field Lengths Fix (Version v0.237.001) ## Header Information - **Fix Title:** Agent payload field length validation diff --git a/docs/explanation/fixes/v0.236.011/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md b/docs/explanation/fixes/v0.237.001/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md similarity index 95% rename from docs/explanation/fixes/v0.236.011/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md rename to docs/explanation/fixes/v0.237.001/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md index 71e1f0de..7748a093 100644 --- a/docs/explanation/fixes/v0.236.011/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md +++ b/docs/explanation/fixes/v0.237.001/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md @@ -1,4 +1,4 @@ -# Agent Template Max Lengths Fix (Version 0.237.010) +# Agent Template Max Lengths Fix (v0.237.001) ## Header Information - **Fix Title:** Agent template max length validation diff --git a/docs/explanation/fixes/v0.236.011/CONTROL_CENTER_DATE_LABELS_FIX.md b/docs/explanation/fixes/v0.237.001/CONTROL_CENTER_DATE_LABELS_FIX.md similarity index 96% rename from docs/explanation/fixes/v0.236.011/CONTROL_CENTER_DATE_LABELS_FIX.md rename to docs/explanation/fixes/v0.237.001/CONTROL_CENTER_DATE_LABELS_FIX.md index a7d1bf34..1add8e46 100644 --- a/docs/explanation/fixes/v0.236.011/CONTROL_CENTER_DATE_LABELS_FIX.md +++ b/docs/explanation/fixes/v0.237.001/CONTROL_CENTER_DATE_LABELS_FIX.md @@ -1,4 +1,4 @@ -# Control Center Date Labels Fix (Version 0.235.074) +# Control Center Date Labels Fix (v0.237.001) ## Header Information - **Fix Title:** Control Center Date Labels Fix diff --git a/docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md b/docs/explanation/fixes/v0.237.001/RETENTION_POLICY_NOTFOUND_FIX.md similarity index 99% rename from docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md rename to docs/explanation/fixes/v0.237.001/RETENTION_POLICY_NOTFOUND_FIX.md index 82a0ec15..e264920f 100644 --- a/docs/explanation/fixes/v0.236.011/RETENTION_POLICY_NOTFOUND_FIX.md +++ b/docs/explanation/fixes/v0.237.001/RETENTION_POLICY_NOTFOUND_FIX.md @@ -18,7 +18,7 @@ This is a **race condition** scenario where: ## Fix Applied -**Version: 0.236.012** +**Version:v0.237.001** The fix adds specific handling for `CosmosResourceNotFoundError` in both conversation and document deletion loops: diff --git a/docs/explanation/fixes/v0.236.011/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md b/docs/explanation/fixes/v0.237.001/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md similarity index 98% rename from docs/explanation/fixes/v0.236.011/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md rename to docs/explanation/fixes/v0.237.001/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md index f76993ba..4316dfcb 100644 --- a/docs/explanation/fixes/v0.236.011/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md +++ b/docs/explanation/fixes/v0.237.001/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md @@ -4,7 +4,7 @@ Fixed hardcoded commercial Azure cognitive services scope references in chat streaming and Smart HTTP Plugin that prevented proper authentication in Azure Government (MAG) and custom cloud environments. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 **Related Issue:** [#616](https://github.com/microsoft/simplechat/issues/616#issue-3835164022) diff --git a/docs/explanation/fixes/v0.236.011/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md b/docs/explanation/fixes/v0.237.001/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md similarity index 98% rename from docs/explanation/fixes/v0.236.011/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md rename to docs/explanation/fixes/v0.237.001/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md index f93a7871..7ba7ed08 100644 --- a/docs/explanation/fixes/v0.236.011/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md +++ b/docs/explanation/fixes/v0.237.001/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md @@ -4,7 +4,7 @@ Updated the `searchUsers()` function to use inline and toast messages instead of browser alert pop-ups, improving user experience and aligning with modern UI patterns. -**Version Implemented:** 0.236.011 +**Version Implemented:** v0.237.001 **Related PR:** [#608](https://github.com/microsoft/simplechat/pull/608#discussion_r2701900020) diff --git a/docs/explanation/fixes/v0.236.011/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md b/docs/explanation/fixes/v0.237.001/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md similarity index 99% rename from docs/explanation/fixes/v0.236.011/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md rename to docs/explanation/fixes/v0.237.001/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md index 233324c9..ad18d356 100644 --- a/docs/explanation/fixes/v0.236.011/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md +++ b/docs/explanation/fixes/v0.237.001/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md @@ -4,7 +4,7 @@ Fixed an issue where Azure AI Foundry web search agent failures would cause the AI model to answer questions using outdated training data instead of informing the user that the web search failed. -**Version Implemented:** 0.236.014 +**Version Implemented:** v0.237.001 ## Problem diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 2d1e0e94..df88ebcd 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -1,7 +1,7 @@ # Feature Release -### **(v0.236.011)** +### **(v0.237.001)** #### New Features From 84e00cba645fc8dd800c17fbd3611527a003d0a1 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 10:41:16 -0500 Subject: [PATCH 11/25] Use Microsoft python base image --- application/single_app/Dockerfile | 130 +++++++++++------------------- 1 file changed, 48 insertions(+), 82 deletions(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index c6209334..c3434c8e 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -1,98 +1,64 @@ -# Stage 1: System dependencies and ODBC driver install -ARG PYTHON_MAJOR_VERSION_ARG="3" -ARG PYTHON_MINOR_VERSION_ARG="13" -ARG PYTHON_PATCH_VERSION_ARG="11" -FROM debian:12-slim AS builder - -ARG PYTHON_MAJOR_VERSION_ARG -ARG PYTHON_MINOR_VERSION_ARG -ARG PYTHON_PATCH_VERSION_ARG - -ENV DEBIAN_FRONTEND=noninteractive \ - PYTHONIOENCODING=utf-8 \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 - -# Build deps for CPython and pip stdlib modules -WORKDIR /deps -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - wget ca-certificates \ - libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \ - libncursesw5-dev libffi-dev liblzma-dev uuid-dev tk-dev && \ - rm -rf /var/lib/apt/lists/* - -# Build and install Python from source -# Example: https://www.python.org/ftp/python/3.13.11/Python-3.13.11.tgz -WORKDIR /tmp -RUN wget https://www.python.org/ftp/python/${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG}/Python-${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG}.tgz && \ - tar -xzf Python-${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG}.tgz && \ - cd Python-${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG} && \ - LDFLAGS="-Wl,-rpath,/usr/local/lib" ./configure --enable-optimizations --enable-shared --with-ensurepip=install --prefix=/usr/local && \ - make -j"$(nproc)" && \ - make altinstall - -USER root -WORKDIR /app -RUN groupadd -g 65532 nonroot && useradd -m -u 65532 -g nonroot nonroot +# Create nonroot user/group with a stable UID/GID (choose values consistent with your org) +ARG UID=10001 +ARG GID=10001 + +FROM mcr.microsoft.com/azurelinux/base/python:3.12 AS builder + +ARG UID +ARG GID + +# CA +# copy certs to /etc/pki/ca-trust/source/anchors +#COPY caroots /etc/ssl/certs +RUN mkdir -p /etc/pki/ca-trust/source/anchors/ \ + && update-ca-trust enable \ + && cp /etc/ssl/certs/*.crt /etc/pki/ca-trust/source/anchors/ \ + && update-ca-trust extract + +ENV PYTHONUNBUFFERED=1 + +RUN set -eux; \ + echo "nonroot:x:${GID}:" >> /etc/group; \ + echo "nonroot:x:${UID}:${GID}:nonroot:/home/nonroot:/bin/bash" >> /etc/passwd; \ + mkdir -p /home/nonroot; \ + chown ${UID}:${GID} /home/nonroot; \ + mkdir -p /app; \ + chown ${UID}:${GID} /app; \ + chmod 744 /app -RUN python${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG} -m venv /app/venv -RUN python${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG} -m pip install wheel +WORKDIR /app -# Copy requirements and install them into the virtualenv -ENV PATH="/app/venv/bin:$PATH" -COPY application/single_app/requirements.txt /app/requirements.txt -RUN python${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG} -m pip install --no-cache-dir -r /app/requirements.txt +USER ${UID}:${GID} -# Fix permissions so nonroot can use everything -RUN chown -R 65532:65532 /app +# Copy requirements and install them to system +COPY application/single_app/requirements.txt . +RUN python3 -m pip install --no-cache-dir -r requirements.txt --user -RUN mkdir -p /app/flask_session && chown -R 65532:65532 /app/flask_session -RUN mkdir /sc-temp-files && chown -R 65532:65532 /sc-temp-files -USER 65532:65532 +FROM mcr.microsoft.com/azurelinux/distroless/python:3.12 -#Stage 2: Final containter -FROM gcr.io/distroless/base-debian12:latest -ARG PYTHON_MAJOR_VERSION_ARG -ARG PYTHON_MINOR_VERSION_ARG -ARG PYTHON_PATCH_VERSION_ARG +# Setup pip.conf if has content +#COPY pip.conf /etc/pip.conf -ENV PYTHONIOENCODING=utf-8 \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 \ - PYTHONUNBUFFERED=1 \ - PATH="/app/venv/bin:/usr/local/bin:$PATH" \ - LD_LIBRARY_PATH="/usr/local/lib:${LD_LIBRARY_PATH}" +COPY --from=builder /etc/pki /etc/pki +COPY --from=builder /home/nonroot /home/nonroot +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group +COPY --from=builder /home/nonroot/.local /home/nonroot/.local +COPY --from=builder /app /app -WORKDIR /app +# RUN mkdir -p /.local/bin && chown -R ${UID}:${GID} /.local +ENV PATH="/.local/bin:$PATH" -USER root +# RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app -# Copy only the built Python interpreter (venv entrypoint handles python/python3) -# Copy the full CPython installation so stdlib modules (e.g., encodings) are available -COPY --from=builder /usr/local/ /usr/local/ +WORKDIR /app -# Copy system libraries for x86_64 -COPY --from=builder /lib/x86_64-linux-gnu/ \ - /lib64/ld-linux-x86-64.so.2 \ - /usr/lib/x86_64-linux-gnu/ - #/usr/share/ca-certificates \ - #/etc/ssl/certs \ - #/usr/bin/ffmpeg \ - #/usr/share/zoneinfo /usr/share/ +USER ${UID}:${GID} # Copy application code and set ownership -COPY --chown=65532:65532 application/single_app/ /app/ - -# Copy the virtualenv from the builder stage -COPY --from=builder --chown=65532:65532 /app/venv /app/venv -COPY --from=builder --chown=65532:65532 /app/flask_session /app/flask_session -COPY --from=builder --chown=65532:65532 /sc-temp-files /sc-temp-files +COPY --chown=${UID}:${GID} application/single_app ./ # Expose port EXPOSE 5000 -USER 65532:65532 - - -ENTRYPOINT ["/app/venv/bin/python", "-c", "import runpy; runpy.run_path('/app/app.py', run_name='__main__')"] \ No newline at end of file +ENTRYPOINT [ "python3", "/app/app.py" ] From 317c6eec6684b96f77d776cd0fd7e50f9cc5370a Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 10:44:49 -0500 Subject: [PATCH 12/25] Add python ENV vars --- application/single_app/Dockerfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index c3434c8e..ec647884 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -46,10 +46,11 @@ COPY --from=builder /etc/group /etc/group COPY --from=builder /home/nonroot/.local /home/nonroot/.local COPY --from=builder /app /app -# RUN mkdir -p /.local/bin && chown -R ${UID}:${GID} /.local -ENV PATH="/.local/bin:$PATH" - -# RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app +ENV PATH="/.local/bin:$PATH" \ + PYTHONIOENCODING=utf-8 \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + PYTHONUNBUFFERED=1 \ WORKDIR /app From 25f41fb89dd688d3551796d64d82474a33c3c61b Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 10:45:43 -0500 Subject: [PATCH 13/25] Add python ENV vars --- application/single_app/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index ec647884..228904a4 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -50,7 +50,7 @@ ENV PATH="/.local/bin:$PATH" \ PYTHONIOENCODING=utf-8 \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ - PYTHONUNBUFFERED=1 \ + PYTHONUNBUFFERED=1 WORKDIR /app From 0753f529aa42c7b5eb2e3948e0d9e5fda95bd7f7 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 12:01:25 -0500 Subject: [PATCH 14/25] Install deps to systme --- application/single_app/Dockerfile | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 228904a4..bdb54f1e 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -28,25 +28,30 @@ RUN set -eux; \ WORKDIR /app -USER ${UID}:${GID} - # Copy requirements and install them to system -COPY application/single_app/requirements.txt . -RUN python3 -m pip install --no-cache-dir -r requirements.txt --user +COPY --chown=${UID}:${GID} application/single_app/requirements.txt . +RUN python3 -m pip install --no-cache-dir -r requirements.txt FROM mcr.microsoft.com/azurelinux/distroless/python:3.12 -# Setup pip.conf if has content -#COPY pip.conf /etc/pip.conf +ARG UID +ARG GID COPY --from=builder /etc/pki /etc/pki COPY --from=builder /home/nonroot /home/nonroot COPY --from=builder /etc/passwd /etc/passwd COPY --from=builder /etc/group /etc/group -COPY --from=builder /home/nonroot/.local /home/nonroot/.local -COPY --from=builder /app /app +COPY --from=builder /usr/lib/python3.12 /usr/lib/python3.12 + +USER ${UID}:${GID} + +# Setup pip.conf if has content +#COPY pip.conf /etc/pip.conf -ENV PATH="/.local/bin:$PATH" \ +COPY --from=builder --chown=${UID}:${GID} /app /app + +ENV HOME=/home/nonroot \ + PATH="/home/nonroot/.local/bin:$PATH" \ PYTHONIOENCODING=utf-8 \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ @@ -54,8 +59,6 @@ ENV PATH="/.local/bin:$PATH" \ WORKDIR /app -USER ${UID}:${GID} - # Copy application code and set ownership COPY --chown=${UID}:${GID} application/single_app ./ From f2958f05ae1df6f2eb5ba70ff04d523a3d15a5b8 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 12:59:58 -0500 Subject: [PATCH 15/25] Add temp dir to image and pip conf support --- application/single_app/Dockerfile | 14 +++++++++----- pip.conf.d/.gitkeep | 0 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 pip.conf.d/.gitkeep diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index bdb54f1e..018ce81a 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -1,12 +1,15 @@ # Create nonroot user/group with a stable UID/GID (choose values consistent with your org) -ARG UID=10001 -ARG GID=10001 +ARG UID=65532 +ARG GID=65532 FROM mcr.microsoft.com/azurelinux/base/python:3.12 AS builder ARG UID ARG GID +# Setup pip.conf if has content +COPY pip.conf.d/ /etc/pip.conf.d + # CA # copy certs to /etc/pki/ca-trust/source/anchors #COPY caroots /etc/ssl/certs @@ -26,6 +29,9 @@ RUN set -eux; \ chown ${UID}:${GID} /app; \ chmod 744 /app +RUN mkdir -p /app/flask_session && chown -R ${UID}:${GID} /app/flask_session +RUN mkdir /sc-temp-files && chown -R ${UID}:${GID} /sc-temp-files + WORKDIR /app # Copy requirements and install them to system @@ -45,10 +51,8 @@ COPY --from=builder /usr/lib/python3.12 /usr/lib/python3.12 USER ${UID}:${GID} -# Setup pip.conf if has content -#COPY pip.conf /etc/pip.conf - COPY --from=builder --chown=${UID}:${GID} /app /app +COPY --from=builder --chown=${UID}:${GID} /sc-temp-files /sc-temp-files ENV HOME=/home/nonroot \ PATH="/home/nonroot/.local/bin:$PATH" \ diff --git a/pip.conf.d/.gitkeep b/pip.conf.d/.gitkeep new file mode 100644 index 00000000..e69de29b From efd6fe7a7a8d8720f8dadfe8997568935c674267 Mon Sep 17 00:00:00 2001 From: Ed Clark Date: Mon, 26 Jan 2026 13:04:02 -0500 Subject: [PATCH 16/25] Add custom-ca-certificates dir --- application/single_app/Dockerfile | 2 +- custom-ca-certificates/.gitkeep | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 custom-ca-certificates/.gitkeep diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 018ce81a..65483ac6 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -12,7 +12,7 @@ COPY pip.conf.d/ /etc/pip.conf.d # CA # copy certs to /etc/pki/ca-trust/source/anchors -#COPY caroots /etc/ssl/certs +COPY custom-ca-certificates/ /etc/ssl/certs RUN mkdir -p /etc/pki/ca-trust/source/anchors/ \ && update-ca-trust enable \ && cp /etc/ssl/certs/*.crt /etc/pki/ca-trust/source/anchors/ \ diff --git a/custom-ca-certificates/.gitkeep b/custom-ca-certificates/.gitkeep new file mode 100644 index 00000000..e69de29b From 7d0a792428dd8ecc74a9a2dd30a0d4109c2e53f0 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 26 Jan 2026 15:50:05 -0500 Subject: [PATCH 17/25] Logo bug fix (#654) * release note updating for github coplilot * fixed logo bug issue * added 2,3,4,5,6,14 days to rentention policy * added retention policy time updates --- .../update_release_notes.instructions.md | 90 +++++++++ application/single_app/config.py | 2 +- application/single_app/functions_settings.py | 9 + .../single_app/static/images/custom_logo.png | Bin 11705 -> 11877 bytes .../static/images/custom_logo_dark.png | Bin 13770 -> 13468 bytes .../single_app/static/images/favicon.ico | Bin 2237 -> 2237 bytes .../single_app/templates/admin_settings.html | 36 ++++ .../single_app/templates/control_center.html | 24 +++ application/single_app/templates/profile.html | 12 ++ .../CUSTOM_LOGO_NOT_DISPLAYING_FIX.md | 102 +++++++++++ docs/explanation/release_notes.md | 22 +++ .../test_custom_logo_sanitization_fix.py | 172 ++++++++++++++++++ 12 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 .github/instructions/update_release_notes.instructions.md create mode 100644 docs/explanation/fixes/v0.237.003/CUSTOM_LOGO_NOT_DISPLAYING_FIX.md create mode 100644 functional_tests/test_custom_logo_sanitization_fix.py diff --git a/.github/instructions/update_release_notes.instructions.md b/.github/instructions/update_release_notes.instructions.md new file mode 100644 index 00000000..353cea48 --- /dev/null +++ b/.github/instructions/update_release_notes.instructions.md @@ -0,0 +1,90 @@ +--- +applyTo: '**' +--- + +# Release Notes Update Instructions + +## When to Update Release Notes + +After completing a code change (bug fix, new feature, enhancement, or breaking change), always ask the user: + +**"Would you like me to update the release notes in `docs/explanation/release_notes.md`?"** + +## If the User Confirms Yes + +Update the release notes file following these guidelines: + +### 1. Location +Release notes are located at: `docs/explanation/release_notes.md` + +### 2. Version Placement +- Add new entries under the **current version** from `config.py` +- If the version has changed, create a new version section at the TOP of the file +- Format: `### **(vX.XXX.XXX)**` + +### 3. Entry Categories + +Organize entries under the appropriate category: + +#### New Features +```markdown +#### New Features + +* **Feature Name** + * Brief description of what the feature does and its benefits. + * Additional details about functionality or configuration. + * (Ref: relevant files, components, or concepts) +``` + +#### Bug Fixes +```markdown +#### Bug Fixes + +* **Fix Name** + * Description of what was broken and how it was fixed. + * Impact or affected areas. + * (Ref: relevant files, functions, or components) +``` + +#### User Interface Enhancements +```markdown +#### User Interface Enhancements + +* **Enhancement Name** + * Description of UI/UX improvements. + * (Ref: relevant templates, CSS, or JavaScript files) +``` + +#### Breaking Changes +```markdown +#### Breaking Changes + +* **Change Name** + * Description of what changed and why. + * **Migration**: Steps users need to take (if any). +``` + +### 4. Entry Format Guidelines + +- **Bold the title** of each entry +- Use bullet points for details +- Include a `(Ref: ...)` line with relevant file names, functions, or concepts +- Keep descriptions concise but informative +- Focus on user-facing impact, not implementation details + +### 5. Example Entry + +```markdown +* **Custom Logo Display Fix** + * Fixed issue where custom logos uploaded via Admin Settings would only display on the admin page but not on other pages (chat, sidebar, landing page). + * Root cause was overly aggressive sanitization removing logo URLs from public settings. + * (Ref: logo display, settings sanitization, template conditionals) +``` + +### 6. Checklist Before Updating + +- [ ] Confirm the current version in `config.py` +- [ ] Determine the correct category (New Feature, Bug Fix, Enhancement, Breaking Change) +- [ ] Write a clear, user-focused description +- [ ] Include relevant file/component references +- [ ] Place entry under the correct version section diff --git a/application/single_app/config.py b/application/single_app/config.py index 12906ce8..9a5c892f 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.237.001" +VERSION = "0.237.003" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 7a411064..5fa59f12 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -794,6 +794,15 @@ def sanitize_settings_for_user(full_settings: dict) -> dict: else: sanitized[k] = v + # Add boolean flags for logo/favicon existence so templates can check without exposing base64 data + # These fields are stripped by the base64 filter above, but templates need to know if logos exist + if 'custom_logo_base64' in full_settings: + sanitized['custom_logo_base64'] = bool(full_settings.get('custom_logo_base64')) + if 'custom_logo_dark_base64' in full_settings: + sanitized['custom_logo_dark_base64'] = bool(full_settings.get('custom_logo_dark_base64')) + if 'custom_favicon_base64' in full_settings: + sanitized['custom_favicon_base64'] = bool(full_settings.get('custom_favicon_base64')) + return sanitized def sanitize_settings_for_logging(full_settings: dict) -> dict: diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png index 45a99fd35f8834db8920ea29bd2bfee10fe754d2..ecf6e6521a737af56bcc82321caff1acefb63494 100644 GIT binary patch literal 11877 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 F{sTGwC=37q literal 11705 zcmbVSRa6~Iv>XoZ2Pe23+%-UOcXyW%G`Ks#NpK79?(P!Yf`s56+}-VQKi=p2d27w= z{@T5I?Vg^ls$CJvic+XZ1V{h?pvp*#tG>xcAr>>W>TnOaU_DBI+I)C%)bp z)DoVjSDG5`{x84BCr;~*>b_9INPx|uba42cuEGbiKVs_SN6>^?j;s7*^5Ty()1Si6 zmsAq{v1{tYzgx`qFKQ!1j-o=65^h`CMS^@d4aZIAmc5?i%Al=0>q<()^-kaY3%
txT=*o8&jKB+g$Gzp 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 Kc$KK}xBmg`z(eK$ diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png index b3beb694201dc8b371e45c973895a95e211eab8e..4f28194576a32f4463ff13ac96521f979e739bb0 100644 GIT binary patch literal 13468 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 aj13{1xX1-4^7hveoPvysbhV^u=>Gr|kZNTB literal 13770 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 diff --git a/application/single_app/static/images/favicon.ico b/application/single_app/static/images/favicon.ico index d8f058f6866570b29f44c682b05a6a010bdedf60..3dc7742a5aa3422badbf8b10bf9d9a21db43f205 100644 GIT binary patch delta 2147 zcmV-p2%Pu55xo&M000310{{>Z00000AON%h001@s000;m00000AOP9|008O%001B$ z00000AOQIT006`U005K01QU@-5r2D0L_t(|oZXT=YgADXhMzg--n+TG!4Q;X!S$m^ zAdLutz!rjqm5qfz#M0iTa6}pyLj(TN8CT)v@OVyWBq9HCt}BrZ@b9zDDp|&mx43YKp8AzT{ysO6$xEn z?7vxfomD64T`^tE>rook&uomI^l}9mtq%>Xu zPW05gyWeK-9q-IN4Hvf1>2$gys>h1$wyJQ)q8-#*5Tuv#w8kNI{+FyVvk_Fa6|>M5 z)pq4R9g(tb;P@A&`1Qw?=6thgE|OneI?H7rsn+V==WEx@`=9&>>ixw;Z?Zl+xyMNDb1=my90x&(kH{Ld2Xs8Cj_vH0_%@Arf zPk*sl@{dpbPcF+g+bsF5ll(H!<>`5wrrV#1FTU?wksJZA(+Iv1!|&C|5CSHV;UmDU zvc?R!$Q!}x1{*L_mgTJP^J@hhrpR1qhQFygqM$>b4;u|z#8wy4!2Nb{y3B0>?0;!c zrvZCbMf#L-)DknI+(~ejMpvrn9g-`dI-$S>pchDf85O@;#cnmc4At#ww1LPqF3rDf z{$IasLNi=5q6bKGNfFM0vX9h9svIQnyH%V75JR)t51=8$rvUy6&2Uh_A1ruGj5Yxs z1+k8Tt1zrsuMd+@XLEl_fUiX21b@MXfkKrv5J92+6188+{K?gLHz)g_-#+zDet~E9B-Pc$^`d4~G_C2hLcjM(IzL}tPin~mfPo}Wc8HVNFc<#qwrt0AY-|kM zwq06M-irzN0L#*PdQf3z)r4CTamWhWt@lT?IAf?Mtk{iWQsoC z5h^7Vl?h1&LE=+kghHtl^iUB-Vh=$?1sPF65cCw$gG4V8g;`MKKo3PoFM{SnFM=ZK z%sAuBJ@=dy`_7qr@7$b8rH3Bw2b;V1zH6_aZ~Yu#fB^;=pbM(jLg#CNyMIz0?msqs zO-Qi>%Q~kzu7~PPeNNFLZ1vxB2;l)V8bb@b>B0xu>s8Kpl?$vg8Vz&c_kEC~eFiSa z*5&pN_4*C0VoqdhUn-J$vYhco&Qjx)cQf+_uX5Tczp2rQjPHv6RV$gtPy=(3MTDL^ ztr(0B?KY}+iPk#iI3Dy)o{|1v-1z@_F;%y=tb}C;H?0^|fQWI+^;9v=C zOmeI<#h0y1&6j+TpN%k1IaZTG4drXCi>*(g@RXDjE|YC!_AbgMA@H%*biNC~c-IR} zpKsG2naJcf5xLC;{@AIEa^NT8y$^n~5ArhCQgbSgap0Zpd-eCQ%73I+IV2)$eUSa} z-lgtAuX2t9KOJ>D<#&AW_p?fiL;8VzvE8lP4vF?_0IqANqsDi5m6KlOuv31|t9-|S zcSHrVj{5spr5X2GmaX6{&3*7&yvkXx{_aj%RmOafSH1F|PWgaWneu^u@PQAr%J)e5 z^*Pyuvvdb%>9|vQJby3Tc>tGSHVuQb*cr106!V%`Y4xZ-X1nEDjjA?cMG=tfgX)c5 z)8|0cFr!}qTAq|R5i<K5U_U(HG^|sgbR}e0}T9&+d^=x^>WKU`SO*`YJoEqHHrzMW0ps$V?4Ou(Vw} zfuBT{tw0UKJ?)mwu@v_qgjJpXJASrdHrtL=3HRb^CSl6TZ^af~M5cFk%sF?(-vWvU zAHpUd!sht6Vtzus8g}f2#1XSJ$;L#qVz52>-c+|G#7on}Az>!-D!}-$a#4-bo z8!3oZ+U)-|5Y~gNo@Lp}obsQ@k};fX4;nYm=t2hm1zc?!ISV@@8Tr)A=gho5&-3HV zd`m9*8E*2hb8ez6OKD2;0+&3JPvw79?|WJO|ADSTcCM0(s&+ax((~J^cTwsG7+?TY Z_!}p`4s?)md<*~p002ovPDHLkV1oEUB}o7P delta 2140 zcmV-i2&4DC5xo&J000310{{>Z00000AONfZ001@s000;m00000AOPP20080v001B$ z00000AOQRW006;}Gbev@NklmfY+^R8-~&_4nfII--Z}7J zAV2|V8)u=d{@CTyL<&Tk$3kxHSphiUQdQNuD_i00AU3zTy;y(NKGoHIGv1K%yFU|@ z-RfxsJ&AZu&~=r)z=z4xPsI0wcpoC0N#wOf+}2=hO0AyWHnaJ*6m>l!?4A$v)Ji!zgS)^x4;%0` zu%qyyX_|9a2UmZ_!$&tB7R^C^Qmt0&wwK31`faQo`IXC+GKupe=lM(Ez>dtj^zFjs z{ZH5L>R(&v^?Lmo%`L+}cN$&uO?ITJ1_Q~2A{r)At0CfeI^VEa#Qvj3S0WW{1NklE|Ni8Oau}^y_q+^_kO>7&uKAl-vA9=Va|0bb0zmr0?*)I{8PH|{cg|EtjC#Tmb80+H zaGqitP3>or8(}tMzzkrQNbXLCZ=B&R6W)f|K@;mBaf{E(_q%_#A4=@TyQ20kDK`!A zJg9Gz_J~R;Y*dqt8d)yCHeR4|5wz`+hYtxJ1#yGModoWK9F-ug-a_x% z%*TiJkL~~Mh)-2BosDD*04FW^FB)e*W+}Zvro~=jyU6ojtGb#7;FH?4(AEJI3SKhk zome$rg&@ZoueTku*Qj)5Oid{->sH^Hb@BL;n z>oqj8)NwA`<|5{5sq2zus;cUj$Avuu`QO<7>8JSA1jO>_uY4c!gzY*1Vg3T2y9jiB zlr?CR-T@zz*8~xN!%0LzRCt{2l})HsRTRhn>+H4G*=yh1RUi48A!bJCqlBU|A*mop z92G_=luAK^iZBuz1Q8WvL2TcS;^!e21d-vS4SA%!o zyVvVSDh(RkA8gKE`>eCq$6jk6V91amLwZnmTu#0o!qx775&mPt*F}jrm^UjmaU;x@ z4md>vGWz7fb%`-PfL3edGT-#zs5{v7IGc`%=>eE6l_>9X)Pn;CfQz$rsd+%NIbTdY zsBXZ8M^Rj$D@oQ!DqTw1CL*J3dMuc{X=Y0$$z1@rJBkZ3)WQgQ#J)8H05maQOslQ~ z0AOe|7G4y8yt^|IkPED+x%vj@unfRtyTsc>v>=#%MeyD3bdK6sK@od0u%@W6$_-z3 z&bMFUs6RVloGL6QgE{hRowJ=!VPGoqnUHiNxxb6tCPewOGg)l~Fy8aR%IDd1Oj7BR z^fy(#EkyY^m>!TQPe>}ABd_DAFH58JVEULu*&?ZbbT+<6OwY0DUR7PmQFloyJ(S@= zHa#g(o@UdrVDb(}zF$l`63Y+#i%ntGCMW30%kGnx$!<2C;3z+Elzn3QJxY0fMz@eu zZkAMkjtA4nt2&$la31cHa0Ca+ZUGs9!m~F42!K{6bXwio009IPJ7IPcTlpM_TJH2K zKnshE!vti2_6qU#n)3dK3j6l=ohv`u_2cauxtf^T!=`Hs;Io2qcF++<@j~VGGekH> zL}%UoZvdHyCfxlffVv39RHw>E0n`qR13t=ss1Fb@;*K5lRC_;1Ipc*NEI?bmz=s{= z7rD--k1pBp%h$Kw{Oor9q*n*67Dmi$$v|b7-6*ZwJ^~uG?OI&DaN>Lw*L;DXt__fBXyPgxt5!-6qC0^Wb_I;VL%?VH*54#{{jL4j&Uu2 z$G8qa(ajWD@1`u!z(N28$GE;sJ;Uxa=!thnzf1s}jqfdr{4fP9#|WC0 zS5HYP+iI`3O*^NrNUd)G$e?^DfJgg`_V1T_;gJKq^SK-Z0D;0>I8J4!!}A<8?qncZ z?9=ZXAgl&?Go`e+qUtB|;xtZT9Mh>^&;M%RDx4rmaSPyFO-y|*BCD&aIwT^uR9YRE zwk!xCoXI(BTiaujWl1$r{m~cs|Cej2_L)kqFeHPZL&-}j2CA2MXf4F3QpzYcVu Sxg3iC0000Default Retention Policies + + + + + + @@ -2479,8 +2485,14 @@
Default Retention Policies + + + + + + @@ -2502,8 +2514,14 @@
Default Retention Policies + + + + + + @@ -2518,8 +2536,14 @@
Default Retention Policies + + + + + + @@ -2541,8 +2565,14 @@
Default Retention Policies + + + + + + @@ -2557,8 +2587,14 @@
Default Retention Policies + + + + + + diff --git a/application/single_app/templates/control_center.html b/application/single_app/templates/control_center.html index 853b4631..7a86d961 100644 --- a/application/single_app/templates/control_center.html +++ b/application/single_app/templates/control_center.html @@ -1670,8 +1670,14 @@
Retention PolicyUsing organization default + + + + + + @@ -1687,8 +1693,14 @@
Retention PolicyUsing organization default + + + + + + @@ -2287,8 +2299,14 @@
Retention PolicyUsing organization default + + + + + + @@ -2304,8 +2322,14 @@
Retention PolicyUsing organization default + + + + + + diff --git a/application/single_app/templates/profile.html b/application/single_app/templates/profile.html index e5a62887..2ab543f7 100644 --- a/application/single_app/templates/profile.html +++ b/application/single_app/templates/profile.html @@ -319,8 +319,14 @@
Retention Policy Sett + + + + + + @@ -338,8 +344,14 @@
Retention Policy Sett + + + + + + diff --git a/docs/explanation/fixes/v0.237.003/CUSTOM_LOGO_NOT_DISPLAYING_FIX.md b/docs/explanation/fixes/v0.237.003/CUSTOM_LOGO_NOT_DISPLAYING_FIX.md new file mode 100644 index 00000000..166dc7c9 --- /dev/null +++ b/docs/explanation/fixes/v0.237.003/CUSTOM_LOGO_NOT_DISPLAYING_FIX.md @@ -0,0 +1,102 @@ +# Custom Logo Not Displaying Across App Fix + +## Issue Description +When an admin uploaded a custom logo via Admin Settings, the logo would display correctly on the admin settings page but **not appear elsewhere in the application** (e.g., chat page, sidebar navigation). + +### Symptoms +- Logo visible in Admin Settings preview +- Logo not appearing in sidebar navigation +- Logo not appearing on chat/chats pages +- Logo not appearing on index/landing page + +## Root Cause Analysis +The issue was in the `sanitize_settings_for_user()` function in [functions_settings.py](../../application/single_app/functions_settings.py). + +This function is designed to strip sensitive data before sending settings to the frontend. It filters out any keys containing terms like: +- `key` +- `secret` +- `password` +- `connection` +- **`base64`** +- `storage_account_url` + +The logo settings are stored with keys: +- `custom_logo_base64` +- `custom_logo_dark_base64` +- `custom_favicon_base64` + +Because these keys contain `base64`, they were being **completely removed** from the sanitized settings. + +### Template Logic Impact +Templates check for custom logos using conditions like: +```jinja2 +{% if app_settings.custom_logo_base64 %} + +{% else %} + +{% endif %} +``` + +When `custom_logo_base64` was stripped entirely, this condition always evaluated to `False`, causing the default logo to display instead of the custom uploaded logo. + +## Solution +Modified `sanitize_settings_for_user()` to add boolean flags for logo/favicon existence **after** the main sanitization loop. This allows templates to check if logos exist without exposing the actual base64 data. + +### Code Change +```python +def sanitize_settings_for_user(full_settings: dict) -> dict: + # ... existing sanitization logic ... + + # Add boolean flags for logo/favicon existence so templates can check without exposing base64 data + # These fields are stripped by the base64 filter above, but templates need to know if logos exist + if 'custom_logo_base64' in full_settings: + sanitized['custom_logo_base64'] = bool(full_settings.get('custom_logo_base64')) + if 'custom_logo_dark_base64' in full_settings: + sanitized['custom_logo_dark_base64'] = bool(full_settings.get('custom_logo_dark_base64')) + if 'custom_favicon_base64' in full_settings: + sanitized['custom_favicon_base64'] = bool(full_settings.get('custom_favicon_base64')) + + return sanitized +``` + +### How It Works +1. The sensitive base64 data is still stripped during the main loop +2. After sanitization, boolean flags are added: + - `True` if the logo exists (base64 string is non-empty) + - `False` if no logo is set (base64 string is empty) +3. Templates can still use `{% if app_settings.custom_logo_base64 %}` and it will correctly evaluate to `True` or `False` +4. The actual base64 data is never exposed to the frontend + +## Files Modified +- [functions_settings.py](../../application/single_app/functions_settings.py) - Modified `sanitize_settings_for_user()` function + +## Version +**Fixed in version:** 0.237.002 + +## Testing +A functional test was created: [test_custom_logo_sanitization_fix.py](../../functional_tests/test_custom_logo_sanitization_fix.py) + +### Test Cases +1. **Logo flags preserved as True** - When logos exist, boolean flags are `True` +2. **Logo flags preserved as False** - When logos are empty, boolean flags are `False` +3. **No spurious flags added** - If logo keys don't exist in settings, they're not added +4. **Template compatibility** - Boolean flags work correctly in Jinja2-style conditionals + +### Running the Test +```bash +cd functional_tests +python test_custom_logo_sanitization_fix.py +``` + +## Impact +This fix affects all pages that display the application logo: +- Landing/Index page +- Chat page +- Sidebar navigation (when left nav is enabled) +- Any other page using `base.html` that references logo settings + +## Security Considerations +- ✅ Actual base64 data is still never exposed to the frontend +- ✅ Only boolean True/False values are sent +- ✅ No sensitive data leakage +- ✅ Maintains the security intent of the original sanitization function diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index df88ebcd..3b3de6e6 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -1,6 +1,28 @@ # Feature Release +### **(v0.237.003)** + +#### New Features + +* **Extended Retention Policy Timeline Options** + * Added additional granular retention period options for conversations and documents across all workspace types. + * **New Options**: 2 days, 3 days, 4 days, 6 days, 7 days (1 week), and 14 days (2 weeks). + * **Full Option Set**: 1, 2, 3, 4, 5, 6, 7 (1 week), 10, 14 (2 weeks), 21 (3 weeks), 30, 60, 90 (3 months), 180 (6 months), 365 (1 year), 730 (2 years) days. + * **Scope**: Available in Admin Settings (organization defaults), Profile page (personal settings), and Control Center (group/public workspace management). + * **Files Modified**: `admin_settings.html`, `profile.html`, `control_center.html`. + * (Ref: retention policy configuration, workspace retention settings, granular time periods) + +#### Bug Fixes + +* **Custom Logo Not Displaying Across App Fix** + * Fixed issue where custom logos uploaded via Admin Settings would only display on the admin page but not on other pages (chat, sidebar, landing page). + * **Root Cause**: The `sanitize_settings_for_user()` function was stripping `custom_logo_base64`, `custom_logo_dark_base64`, and `custom_favicon_base64` keys entirely because they contained "base64" (a sensitive term filter), preventing templates from detecting logo existence. + * **Solution**: Modified sanitization to add boolean flags for logo/favicon existence after filtering, allowing templates to check if logos exist without exposing actual base64 data. + * **Security**: Actual base64 data remains hidden from frontend; only True/False boolean values are exposed. + * **Files Modified**: `functions_settings.py` (`sanitize_settings_for_user()` function). + * (Ref: logo display, settings sanitization, template conditionals) + ### **(v0.237.001)** #### New Features diff --git a/functional_tests/test_custom_logo_sanitization_fix.py b/functional_tests/test_custom_logo_sanitization_fix.py new file mode 100644 index 00000000..419a7a1f --- /dev/null +++ b/functional_tests/test_custom_logo_sanitization_fix.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Functional test for custom logo sanitization fix. +Version: 0.237.002 +Implemented in: 0.237.002 + +This test ensures that custom logo boolean flags are preserved in sanitized settings +so templates can detect if custom logos exist without exposing the actual base64 data. + +Issue: When a logo was uploaded via admin settings, it was visible on the admin page +but not on other pages (like the chat page) because the `sanitize_settings_for_user` +function was stripping `custom_logo_base64`, `custom_logo_dark_base64`, and +`custom_favicon_base64` keys entirely, which templates use to conditionally display logos. + +Fix: Modified `sanitize_settings_for_user` to add boolean flags for logo/favicon +existence after sanitization, allowing templates to check `app_settings.custom_logo_base64` +(which will be True/False) without exposing the actual base64 data. +""" + +import sys +import os + +# Add the application directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + + +def test_sanitize_settings_preserves_logo_flags(): + """ + Test that sanitize_settings_for_user preserves boolean flags for logo existence. + """ + print("🔍 Testing sanitize_settings_for_user preserves logo flags...") + + try: + from functions_settings import sanitize_settings_for_user + + # Test case 1: Settings with custom logos present + settings_with_logos = { + 'app_title': 'Test App', + 'show_logo': True, + 'custom_logo_base64': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'custom_logo_dark_base64': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'custom_favicon_base64': 'AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAA==', + 'logo_version': 5, + 'some_api_key': 'secret-key-123', + 'azure_openai_key': 'another-secret-key', + } + + sanitized = sanitize_settings_for_user(settings_with_logos) + + # Verify non-sensitive fields are preserved + assert sanitized.get('app_title') == 'Test App', "app_title should be preserved" + assert sanitized.get('show_logo') == True, "show_logo should be preserved" + assert sanitized.get('logo_version') == 5, "logo_version should be preserved" + + # Verify sensitive keys are removed (api keys, secrets) + assert 'some_api_key' not in sanitized, "API keys should be removed" + assert 'azure_openai_key' not in sanitized, "Azure OpenAI key should be removed" + + # Verify logo flags are boolean True (not the actual base64 data) + assert sanitized.get('custom_logo_base64') == True, "custom_logo_base64 should be True (boolean flag)" + assert sanitized.get('custom_logo_dark_base64') == True, "custom_logo_dark_base64 should be True (boolean flag)" + assert sanitized.get('custom_favicon_base64') == True, "custom_favicon_base64 should be True (boolean flag)" + + # Verify the actual base64 data is NOT exposed + assert isinstance(sanitized.get('custom_logo_base64'), bool), "custom_logo_base64 should be a boolean, not a string" + + print("✅ Test 1 passed: Logo flags are preserved as boolean True when logos exist") + + # Test case 2: Settings without custom logos + settings_without_logos = { + 'app_title': 'Test App', + 'show_logo': True, + 'custom_logo_base64': '', + 'custom_logo_dark_base64': '', + 'custom_favicon_base64': '', + } + + sanitized2 = sanitize_settings_for_user(settings_without_logos) + + # Verify logo flags are boolean False when logos are empty + assert sanitized2.get('custom_logo_base64') == False, "custom_logo_base64 should be False when empty" + assert sanitized2.get('custom_logo_dark_base64') == False, "custom_logo_dark_base64 should be False when empty" + assert sanitized2.get('custom_favicon_base64') == False, "custom_favicon_base64 should be False when empty" + + print("✅ Test 2 passed: Logo flags are False when logos are empty/not set") + + # Test case 3: Settings without logo keys at all + settings_no_logo_keys = { + 'app_title': 'Test App', + 'show_logo': False, + } + + sanitized3 = sanitize_settings_for_user(settings_no_logo_keys) + + # Verify logo keys are not added if they didn't exist + assert 'custom_logo_base64' not in sanitized3, "custom_logo_base64 should not be added if not in original settings" + + print("✅ Test 3 passed: Logo flags are not added if keys not in original settings") + + print("\n✅ All tests passed!") + return True + + except AssertionError as e: + print(f"❌ Assertion failed: {e}") + import traceback + traceback.print_exc() + return False + except Exception as e: + print(f"❌ Test failed with exception: {e}") + import traceback + traceback.print_exc() + return False + + +def test_template_compatibility(): + """ + Test that the boolean flags work correctly in Jinja2-style conditionals. + """ + print("\n🔍 Testing template compatibility with boolean flags...") + + try: + from functions_settings import sanitize_settings_for_user + + settings = { + 'custom_logo_base64': 'some-base64-data', + 'custom_logo_dark_base64': '', + } + + sanitized = sanitize_settings_for_user(settings) + + # Simulate Jinja2 conditional: {% if app_settings.custom_logo_base64 %} + if sanitized.get('custom_logo_base64'): + light_logo_condition = "show custom light logo" + else: + light_logo_condition = "show default light logo" + + assert light_logo_condition == "show custom light logo", "Light logo should use custom" + + # Simulate Jinja2 conditional: {% if app_settings.custom_logo_dark_base64 %} + if sanitized.get('custom_logo_dark_base64'): + dark_logo_condition = "show custom dark logo" + else: + dark_logo_condition = "show default dark logo" + + assert dark_logo_condition == "show default dark logo", "Dark logo should use default (empty base64)" + + print("✅ Template compatibility test passed!") + return True + + except Exception as e: + print(f"❌ Template compatibility test failed: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + results = [] + + print("=" * 60) + print("Custom Logo Sanitization Fix - Functional Tests") + print("=" * 60) + + results.append(test_sanitize_settings_preserves_logo_flags()) + results.append(test_template_compatibility()) + + print("\n" + "=" * 60) + success = all(results) + print(f"📊 Results: {sum(results)}/{len(results)} tests passed") + print("=" * 60) + + sys.exit(0 if success else 1) From 823e6fa8045939146b9bed0fed14b2eb2415a900 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 26 Jan 2026 16:31:34 -0500 Subject: [PATCH 18/25] Rentention policy (#657) * Critical Retention Policy Deletion Fix * Create RETENTION_POLICY_NULL_LAST_ACTIVITY_FIX.md --- application/single_app/config.py | 2 +- .../single_app/functions_control_center.py | 138 ++++++++++++++++++ .../single_app/functions_retention_policy.py | 32 ++-- .../single_app/static/js/control-center.js | 26 ++++ .../single_app/templates/control_center.html | 8 +- ...RETENTION_POLICY_NULL_LAST_ACTIVITY_FIX.md | 133 +++++++++++++++++ docs/explanation/release_notes.md | 17 +++ 7 files changed, 335 insertions(+), 21 deletions(-) create mode 100644 application/single_app/functions_control_center.py create mode 100644 docs/explanation/fixes/v0.237.004/RETENTION_POLICY_NULL_LAST_ACTIVITY_FIX.md diff --git a/application/single_app/config.py b/application/single_app/config.py index 9a5c892f..9aa4c4b8 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.237.003" +VERSION = "0.237.004" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_control_center.py b/application/single_app/functions_control_center.py new file mode 100644 index 00000000..9337f408 --- /dev/null +++ b/application/single_app/functions_control_center.py @@ -0,0 +1,138 @@ +# functions_control_center.py +""" +Functions for Control Center operations including scheduled auto-refresh. +Version: 0.237.004 +""" + +from datetime import datetime, timezone, timedelta +from config import debug_print, cosmos_user_settings_container, cosmos_groups_container +from functions_settings import get_settings, update_settings +from functions_appinsights import log_event + + +def execute_control_center_refresh(manual_execution=False): + """ + Execute Control Center data refresh operation. + Refreshes user and group metrics data. + + Args: + manual_execution: True if triggered manually, False if scheduled + + Returns: + dict: Results containing success status and refresh counts + """ + results = { + 'success': True, + 'refreshed_users': 0, + 'failed_users': 0, + 'refreshed_groups': 0, + 'failed_groups': 0, + 'error': None, + 'manual_execution': manual_execution + } + + try: + debug_print(f"🔄 [AUTO-REFRESH] Starting Control Center {'manual' if manual_execution else 'scheduled'} refresh...") + + # Import enhance functions from route module + from route_backend_control_center import enhance_user_with_activity, enhance_group_with_activity + + # Get all users to refresh their metrics + debug_print("🔄 [AUTO-REFRESH] Querying all users...") + users_query = "SELECT c.id, c.email, c.display_name, c.lastUpdated, c.settings FROM c" + all_users = list(cosmos_user_settings_container.query_items( + query=users_query, + enable_cross_partition_query=True + )) + debug_print(f"🔄 [AUTO-REFRESH] Found {len(all_users)} users to process") + + # Refresh metrics for each user + for user in all_users: + try: + user_id = user.get('id') + debug_print(f"🔄 [AUTO-REFRESH] Processing user {user_id}") + + # Force refresh of metrics for this user + enhanced_user = enhance_user_with_activity(user, force_refresh=True) + results['refreshed_users'] += 1 + + except Exception as user_error: + results['failed_users'] += 1 + debug_print(f"❌ [AUTO-REFRESH] Failed to refresh user {user.get('id')}: {user_error}") + + debug_print(f"🔄 [AUTO-REFRESH] User refresh completed. Refreshed: {results['refreshed_users']}, Failed: {results['failed_users']}") + + # Refresh metrics for all groups + debug_print("🔄 [AUTO-REFRESH] Starting group refresh...") + + try: + groups_query = "SELECT * FROM c" + all_groups = list(cosmos_groups_container.query_items( + query=groups_query, + enable_cross_partition_query=True + )) + debug_print(f"🔄 [AUTO-REFRESH] Found {len(all_groups)} groups to process") + + # Refresh metrics for each group + for group in all_groups: + try: + group_id = group.get('id') + debug_print(f"🔄 [AUTO-REFRESH] Processing group {group_id}") + + # Force refresh of metrics for this group + enhanced_group = enhance_group_with_activity(group, force_refresh=True) + results['refreshed_groups'] += 1 + + except Exception as group_error: + results['failed_groups'] += 1 + debug_print(f"❌ [AUTO-REFRESH] Failed to refresh group {group.get('id')}: {group_error}") + + except Exception as groups_error: + debug_print(f"❌ [AUTO-REFRESH] Error querying groups: {groups_error}") + + debug_print(f"🔄 [AUTO-REFRESH] Group refresh completed. Refreshed: {results['refreshed_groups']}, Failed: {results['failed_groups']}") + + # Update admin settings with refresh timestamp and calculate next run time + try: + settings = get_settings() + if settings: + current_time = datetime.now(timezone.utc) + settings['control_center_last_refresh'] = current_time.isoformat() + + # Calculate next scheduled auto-refresh time if enabled + if settings.get('control_center_auto_refresh_enabled', False): + execution_hour = settings.get('control_center_auto_refresh_hour', 2) + next_run = current_time.replace(hour=execution_hour, minute=0, second=0, microsecond=0) + if next_run <= current_time: + next_run += timedelta(days=1) + settings['control_center_auto_refresh_next_run'] = next_run.isoformat() + + update_success = update_settings(settings) + + if update_success: + debug_print("✅ [AUTO-REFRESH] Admin settings updated with refresh timestamp") + else: + debug_print("⚠️ [AUTO-REFRESH] Failed to update admin settings") + + except Exception as settings_error: + debug_print(f"❌ [AUTO-REFRESH] Admin settings update failed: {settings_error}") + + # Log the activity + log_event("control_center_refresh", { + "manual_execution": manual_execution, + "refreshed_users": results['refreshed_users'], + "failed_users": results['failed_users'], + "refreshed_groups": results['refreshed_groups'], + "failed_groups": results['failed_groups'] + }) + + debug_print(f"🎉 [AUTO-REFRESH] Refresh completed! Users: {results['refreshed_users']} refreshed, {results['failed_users']} failed. " + f"Groups: {results['refreshed_groups']} refreshed, {results['failed_groups']} failed") + + return results + + except Exception as e: + debug_print(f"💥 [AUTO-REFRESH] Error executing Control Center refresh: {e}") + results['success'] = False + results['error'] = str(e) + return results diff --git a/application/single_app/functions_retention_policy.py b/application/single_app/functions_retention_policy.py index 56167fa1..07f391a0 100644 --- a/application/single_app/functions_retention_policy.py +++ b/application/single_app/functions_retention_policy.py @@ -6,9 +6,10 @@ This module handles automated deletion of aged conversations and documents based on configurable retention policies for personal, group, and public workspaces. -Version: 0.236.012 +Version: 0.237.004 Implemented in: 0.234.067 Updated in: 0.236.012 - Fixed race condition handling for NotFound errors during deletion +Updated in: 0.237.004 - Fixed critical bug where conversations with null/undefined last_activity_at were deleted regardless of age """ from config import * @@ -449,20 +450,11 @@ def process_public_retention(): 'document_details': [] } - # Process conversations - if conversation_retention_days != 'none': - try: - conv_results = delete_aged_conversations( - public_workspace_id=workspace_id, - retention_days=int(conversation_retention_days), - workspace_type='public' - ) - workspace_deletion_summary['conversations_deleted'] = conv_results['count'] - workspace_deletion_summary['conversation_details'] = conv_results['details'] - results['conversations'] += conv_results['count'] - except Exception as e: - log_event("process_public_retention_conversations_error", {"error": str(e), "public_workspace_id": workspace_id}) - debug_print(f"Error processing conversations for public workspace {workspace_id}: {e}") + # Note: Public workspaces do not have a separate conversations container. + # Conversations are only stored in personal (cosmos_conversations_container) or + # group (cosmos_group_conversations_container) workspaces. + # Therefore, we skip conversation processing for public workspaces. + # Only documents are processed for public workspace retention. # Process documents if document_retention_days != 'none': @@ -529,14 +521,16 @@ def delete_aged_conversations(retention_days, workspace_type='personal', user_id cutoff_iso = cutoff_date.isoformat() # Query for aged conversations - # Check for null/undefined FIRST to avoid comparing null values with dates + # ONLY delete conversations that have a valid last_activity_at that is older than the cutoff + # Conversations with null/undefined last_activity_at should be SKIPPED (not deleted) + # This prevents accidentally deleting new conversations that haven't had activity tracked yet query = f""" SELECT c.id, c.title, c.last_activity_at, c.{partition_field} FROM c WHERE c.{partition_field} = @partition_value - AND (NOT IS_DEFINED(c.last_activity_at) - OR IS_NULL(c.last_activity_at) - OR (IS_DEFINED(c.last_activity_at) AND NOT IS_NULL(c.last_activity_at) AND c.last_activity_at < @cutoff_date)) + AND IS_DEFINED(c.last_activity_at) + AND NOT IS_NULL(c.last_activity_at) + AND c.last_activity_at < @cutoff_date """ parameters = [ diff --git a/application/single_app/static/js/control-center.js b/application/single_app/static/js/control-center.js index bf155fe7..7e79d22a 100644 --- a/application/single_app/static/js/control-center.js +++ b/application/single_app/static/js/control-center.js @@ -3639,6 +3639,32 @@ async function loadRefreshStatus() { lastRefreshElement.textContent = 'Error loading'; } } + + // Load and display auto-refresh schedule info + try { + const response = await fetch('/api/admin/control-center/refresh-status'); + if (response.ok) { + const result = await response.json(); + const autoRefreshInfoElement = document.getElementById('autoRefreshInfo'); + const autoRefreshStatusElement = document.getElementById('autoRefreshStatus'); + + if (autoRefreshInfoElement && autoRefreshStatusElement) { + if (result.auto_refresh_enabled) { + // Build status text + let statusText = `Auto-refresh: daily at ${result.auto_refresh_hour_formatted || result.auto_refresh_hour + ':00 UTC'}`; + if (result.auto_refresh_next_run_formatted) { + statusText += ` (next: ${result.auto_refresh_next_run_formatted})`; + } + autoRefreshStatusElement.textContent = statusText; + autoRefreshInfoElement.classList.remove('d-none'); + } else { + autoRefreshInfoElement.classList.add('d-none'); + } + } + } + } catch (autoRefreshError) { + console.error('Error loading auto-refresh status:', autoRefreshError); + } } async function refreshActiveTabContent() { diff --git a/application/single_app/templates/control_center.html b/application/single_app/templates/control_center.html index 7a86d961..dbdce4d7 100644 --- a/application/single_app/templates/control_center.html +++ b/application/single_app/templates/control_center.html @@ -443,12 +443,18 @@

Control Center

Manage users and their workspaces, groups and their workspaces, and public workspaces.

-
+
Data last refreshed: Loading...
+
+ + + Auto-refresh scheduled + +