diff --git a/.github/instructions/javascript-lang.instructions.md b/.github/instructions/javascript-lang.instructions.md new file mode 100644 index 000000000..43d672405 --- /dev/null +++ b/.github/instructions/javascript-lang.instructions.md @@ -0,0 +1,21 @@ +--- +applyTo: '**/*.js' +--- + +# JavaScript Language Guide + +- Files should start with a comment of the file name. Ex: `// functions_personal_agents.js` + +- Imports should be grouped at the top of the document after the module docstring, unless otherwise indicated by the user or for performance reasons in which case the import should be as close as possible to the usage with a documented note as to why the import is not at the top of the file. + +- Use 4 spaces per indentation level. No tabs. + +- Code and definitions should occur after the imports block. + +- Use camelCase for variable and function names. Ex: `myVariable`, `getUserData()` + +- Use PascalCase for class names. Ex: `MyClass` + +- Do not use display:none. Instead add and remove the d-none class when hiding or showing elements. + +- Prefer inline html notifications or toast messages using Bootstrap alert classes over browser alert() calls. \ No newline at end of file diff --git a/.github/instructions/python-lang.instructions.md b/.github/instructions/python-lang.instructions.md index c37b99c72..eff15aefc 100644 --- a/.github/instructions/python-lang.instructions.md +++ b/.github/instructions/python-lang.instructions.md @@ -1,5 +1,5 @@ --- -applyTo: '**' +applyTo: '**/*.py' --- # Python Language Guide @@ -10,4 +10,6 @@ applyTo: '**' - Use 4 spaces per indentation level. No tabs. -- Code and definitions should occur after the imports block. \ No newline at end of file +- Code and definitions should occur after the imports block. + +- Prefer log_event from functions_appinsights.py for logging activites. \ No newline at end of file diff --git a/.github/instructions/santize_settings_for_frontend_routes.instructions.md b/.github/instructions/santize_settings_for_frontend_routes.instructions.md index d21d469bc..bb10fcf05 100644 --- a/.github/instructions/santize_settings_for_frontend_routes.instructions.md +++ b/.github/instructions/santize_settings_for_frontend_routes.instructions.md @@ -20,6 +20,8 @@ When building or working with Python frontend routes (Flask routes that render t ## Required Pattern +### Exception: Admin Routes should NEVER be sanitized as it breaks many admin features. + ### āœ… CORRECT - Sanitize Before Sending ```python from functions_settings import get_settings, sanitize_settings_for_user diff --git a/.github/workflows/docker_image_publish.yml b/.github/workflows/docker_image_publish.yml index 94255a6e3..ef8732c3e 100644 --- a/.github/workflows/docker_image_publish.yml +++ b/.github/workflows/docker_image_publish.yml @@ -1,4 +1,3 @@ - name: SimpleChat Docker Image Publish on: @@ -8,9 +7,7 @@ on: workflow_dispatch: jobs: - build: - runs-on: ubuntu-latest steps: @@ -18,16 +15,25 @@ jobs: uses: Azure/docker-login@v2 with: # Container registry username - username: ${{ secrets.ACR_USERNAME }} + username: ${{ secrets.MAIN_ACR_USERNAME }} # Container registry password - password: ${{ secrets.ACR_PASSWORD }} + password: ${{ secrets.MAIN_ACR_PASSWORD }} # Container registry server url - login-server: ${{ secrets.ACR_LOGIN_SERVER }} + login-server: ${{ secrets.MAIN_ACR_LOGIN_SERVER }} + - name: Normalize branch name for tag + run: | + REF="${GITHUB_REF_NAME}" + SAFE=$(echo "$REF" \ + | tr '[:upper:]' '[:lower:]' \ + | sed 's#[^a-z0-9._-]#-#g' \ + | sed 's/^-*//;s/-*$//' \ + | cut -c1-128) + echo "BRANCH_TAG=$SAFE" >> "$GITHUB_ENV" - uses: actions/checkout@v3 - name: Build the Docker image run: - docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; - docker tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:latest; - docker push ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; - docker push ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:latest; + docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER; + docker tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:latest; + docker push ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER; + docker push ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat:latest; \ No newline at end of file diff --git a/.github/workflows/docker_image_publish_dev.yml b/.github/workflows/docker_image_publish_dev.yml index e5fb31a05..33d208ed2 100644 --- a/.github/workflows/docker_image_publish_dev.yml +++ b/.github/workflows/docker_image_publish_dev.yml @@ -1,17 +1,16 @@ -name: SimpleChat Docker Image Publish (dev branch) +name: SimpleChat Docker Image Publish (development/staging branch) on: push: branches: - Development + - staging workflow_dispatch: jobs: - - build: - + build-tomain: runs-on: ubuntu-latest steps: @@ -25,10 +24,53 @@ jobs: # Container registry server url login-server: ${{ secrets.ACR_LOGIN_SERVER }} + - name: Normalize branch name for tag + run: | + REF="${GITHUB_REF_NAME}" + SAFE=$(echo "$REF" \ + | tr '[:upper:]' '[:lower:]' \ + | sed 's#[^a-z0-9._-]#-#g' \ + | sed 's/^-*//;s/-*$//' \ + | cut -c1-128) + echo "BRANCH_TAG=$SAFE" >> "$GITHUB_ENV" + - uses: actions/checkout@v3 - name: Build the Docker image run: - docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; - docker tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:latest; - docker push ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; + docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER; + docker tag ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:latest; + docker push ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER; docker push ${{ secrets.ACR_LOGIN_SERVER }}/simple-chat-dev:latest; + + build-nadoyle: + runs-on: ubuntu-latest + + steps: + - name: Azure Container Registry Login + uses: Azure/docker-login@v2 + with: + # Container registry username + username: ${{ secrets.ACR_USERNAME_NADOYLE }} + # Container registry password + password: ${{ secrets.ACR_PASSWORD_NADOYLE }} + # Container registry server url + login-server: ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }} + + - name: Normalize branch name for tag + run: | + REF="${GITHUB_REF_NAME}" + SAFE=$(echo "$REF" \ + | tr '[:upper:]' '[:lower:]' \ + | sed 's#[^a-z0-9._-]#-#g' \ + | sed 's/^-*//;s/-*$//' \ + | cut -c1-128) + echo "BRANCH_TAG=$SAFE" >> "$GITHUB_ENV" + + - uses: actions/checkout@v3 + - name: Build the Docker image + run: + docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER; + docker tag ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:latest; + docker push ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER; + docker push ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:latest; + diff --git a/.github/workflows/docker_image_publish_nadoyle.yml b/.github/workflows/docker_image_publish_nadoyle.yml index 4aa90f7b3..0dd56e099 100644 --- a/.github/workflows/docker_image_publish_nadoyle.yml +++ b/.github/workflows/docker_image_publish_nadoyle.yml @@ -5,10 +5,7 @@ on: push: branches: - nadoyle - - feature/group-agents-actions - - security/containerBuild - feature/aifoundryagents - - azureBillingPlugin workflow_dispatch: diff --git a/.github/workflows/release-notes-check.yml b/.github/workflows/release-notes-check.yml new file mode 100644 index 000000000..2eb7cee1d --- /dev/null +++ b/.github/workflows/release-notes-check.yml @@ -0,0 +1,205 @@ +name: Release Notes Check + +on: + pull_request: + branches: + - Development + types: + - opened + - reopened + - synchronize + - edited + +jobs: + check-release-notes: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v44 + with: + files_yaml: | + code: + - 'application/single_app/**/*.py' + - 'application/single_app/**/*.js' + - 'application/single_app/**/*.html' + - 'application/single_app/**/*.css' + release_notes: + - 'docs/explanation/release_notes.md' + config: + - 'application/single_app/config.py' + + - name: Check for feature/fix keywords in PR + id: check-keywords + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + echo "šŸ” Analyzing PR title and body for feature/fix indicators..." + + # Convert to lowercase for case-insensitive matching + title_lower=$(echo "$PR_TITLE" | tr '[:upper:]' '[:lower:]') + body_lower=$(echo "$PR_BODY" | tr '[:upper:]' '[:lower:]') + + # Check for feature indicators + if echo "$title_lower $body_lower" | grep -qE "(feat|feature|add|new|implement|introduce|enhancement|improve)"; then + echo "has_feature=true" >> $GITHUB_OUTPUT + echo "šŸ“¦ Feature-related keywords detected" + else + echo "has_feature=false" >> $GITHUB_OUTPUT + fi + + # Check for fix indicators + if echo "$title_lower $body_lower" | grep -qE "(fix|bug|patch|resolve|correct|repair|hotfix|issue)"; then + echo "has_fix=true" >> $GITHUB_OUTPUT + echo "šŸ› Fix-related keywords detected" + else + echo "has_fix=false" >> $GITHUB_OUTPUT + fi + + - name: Determine if release notes update is required + id: require-notes + env: + CODE_CHANGED: ${{ steps.changed-files.outputs.code_any_changed }} + CONFIG_CHANGED: ${{ steps.changed-files.outputs.config_any_changed }} + RELEASE_NOTES_CHANGED: ${{ steps.changed-files.outputs.release_notes_any_changed }} + HAS_FEATURE: ${{ steps.check-keywords.outputs.has_feature }} + HAS_FIX: ${{ steps.check-keywords.outputs.has_fix }} + run: | + echo "" + echo "================================" + echo "šŸ“‹ PR Analysis Summary" + echo "================================" + echo "Code files changed: $CODE_CHANGED" + echo "Config changed: $CONFIG_CHANGED" + echo "Release notes updated: $RELEASE_NOTES_CHANGED" + echo "Feature keywords found: $HAS_FEATURE" + echo "Fix keywords found: $HAS_FIX" + echo "================================" + echo "" + + # Determine if this PR likely needs release notes + needs_notes="false" + reason="" + + if [[ "$HAS_FEATURE" == "true" ]]; then + needs_notes="true" + reason="Feature-related keywords detected in PR title/body" + elif [[ "$HAS_FIX" == "true" ]]; then + needs_notes="true" + reason="Fix-related keywords detected in PR title/body" + elif [[ "$CODE_CHANGED" == "true" && "$CONFIG_CHANGED" == "true" ]]; then + needs_notes="true" + reason="Both code and config.py were modified" + fi + + echo "needs_notes=$needs_notes" >> $GITHUB_OUTPUT + echo "reason=$reason" >> $GITHUB_OUTPUT + + - name: Validate release notes update + env: + CODE_CHANGED: ${{ steps.changed-files.outputs.code_any_changed }} + RELEASE_NOTES_CHANGED: ${{ steps.changed-files.outputs.release_notes_any_changed }} + NEEDS_NOTES: ${{ steps.require-notes.outputs.needs_notes }} + REASON: ${{ steps.require-notes.outputs.reason }} + CODE_FILES: ${{ steps.changed-files.outputs.code_all_changed_files }} + run: | + echo "" + + if [[ "$NEEDS_NOTES" == "true" && "$RELEASE_NOTES_CHANGED" != "true" ]]; then + echo "āš ļø ==============================================" + echo "āš ļø RELEASE NOTES UPDATE RECOMMENDED" + echo "āš ļø ==============================================" + echo "" + echo "šŸ“ Reason: $REASON" + echo "" + echo "This PR appears to contain changes that should be documented" + echo "in the release notes (docs/explanation/release_notes.md)." + echo "" + echo "šŸ“ Code files changed:" + echo "$CODE_FILES" | tr ' ' '\n' | sed 's/^/ - /' + echo "" + echo "šŸ’” Please consider adding an entry to release_notes.md describing:" + echo " • New features added" + echo " • Bug fixes implemented" + echo " • Breaking changes (if any)" + echo " • Files modified" + echo "" + echo "šŸ“– Follow the existing format in release_notes.md" + echo "" + # Exit with warning (non-zero) to flag the PR but not block it + # Change 'exit 0' to 'exit 1' below to make this a hard requirement + exit 0 + elif [[ "$RELEASE_NOTES_CHANGED" == "true" ]]; then + echo "āœ… Release notes have been updated - great job!" + elif [[ "$CODE_CHANGED" != "true" ]]; then + echo "ā„¹ļø No significant code changes detected - release notes update not required." + else + echo "ā„¹ļø Changes appear to be minor - release notes update optional." + fi + + echo "" + echo "āœ… Release notes check completed successfully." + + - name: Post PR comment (when notes needed but missing) + if: steps.require-notes.outputs.needs_notes == 'true' && steps.changed-files.outputs.release_notes_any_changed != 'true' + uses: actions/github-script@v7 + with: + script: | + const reason = '${{ steps.require-notes.outputs.reason }}'; + + // Check if we already commented + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('šŸ“‹ Release Notes Reminder') + ); + + if (!botComment) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `## šŸ“‹ Release Notes Reminder + + This PR appears to contain changes that should be documented in the release notes. + + **Reason:** ${reason} + + ### šŸ“ Please consider updating: + \`docs/explanation/release_notes.md\` + + ### Template for new features: + \`\`\`markdown + * **Feature Name** + * Brief description of the feature. + * **Key Details**: Important implementation notes. + * **Files Modified**: \`file1.py\`, \`file2.js\`. + * (Ref: related components, patterns) + \`\`\` + + ### Template for bug fixes: + \`\`\`markdown + * **Bug Fix Title** + * Description of what was fixed. + * **Root Cause**: What caused the issue. + * **Solution**: How it was resolved. + * **Files Modified**: \`file.py\`. + * (Ref: related issue numbers, components) + \`\`\` + + --- + *This is an automated reminder. If this PR doesn't require release notes (e.g., internal refactoring, documentation-only changes), you can ignore this message.*` + }); + } diff --git a/application/single_app/static/json/schemas/azure_billing_plugin.additional_settings.schema.json b/application/community_customizations/actions/azure_billing_retriever/azure_billing_plugin.additional_settings.schema.json similarity index 100% rename from application/single_app/static/json/schemas/azure_billing_plugin.additional_settings.schema.json rename to application/community_customizations/actions/azure_billing_retriever/azure_billing_plugin.additional_settings.schema.json diff --git a/application/single_app/static/json/schemas/azure_billing_plugin.definition.json b/application/community_customizations/actions/azure_billing_retriever/azure_billing_plugin.definition.json similarity index 100% rename from application/single_app/static/json/schemas/azure_billing_plugin.definition.json rename to application/community_customizations/actions/azure_billing_retriever/azure_billing_plugin.definition.json diff --git a/application/community_customizations/actions/azure_billing_retriever/readme.md b/application/community_customizations/actions/azure_billing_retriever/readme.md index 0c9b365ea..982ec8abb 100644 --- a/application/community_customizations/actions/azure_billing_retriever/readme.md +++ b/application/community_customizations/actions/azure_billing_retriever/readme.md @@ -3,7 +3,7 @@ # Azure Billing Action Instructions ## Overview -The Azure Billing action is an experimental Semantic Kernel plugin that helps agents explore Azure Cost Management data, generate CSV outputs, and render server-side charts for conversational reporting. It stitches together Azure REST APIs, matplotlib rendering, and Cosmos DB persistence so prototype agents can investigate subscriptions, budgets, alerts, and forecasts without touching the production portal. It leverages message injection (direct cosmos_messages_container access) to store chart images as conversation artifacts in lieu of embedding binary data in chat responses. +The Azure Billing action is an experimental Semantic Kernel plugin that helps agents explore Azure Cost Management data, generate CSV outputs, and render server-side charts for conversational reporting. It stitches together Azure REST APIs, matplotlib rendering, and Cosmos DB persistence so prototype agents can investigate subscriptions, budgets, alerts, and forecasts without touching the production portal. It leverages message injection (direct cosmos_messages_container access) to store chart images as conversation artifacts in lieu of embedding binary data in chat responses. You will need to move the ```azure_billing_plugin.py``` to the [semantic-kernel-plugins](../../../single_app/semantic_kernel_plugins/) folder, and move the ```schema.json``` and ```definition.json``` to the [schemas](../../../single_app/static/json/schemas) folder. ## Core capabilities - Enumerate subscriptions and resource groups via `list_subscriptions*` helpers for quick scope discovery. @@ -48,6 +48,5 @@ The Azure Billing action is an experimental Semantic Kernel plugin that helps ag ## Additional resources - Review `instructions.md` in the same directory for the autonomous agent persona tailored to this action. -- Inspect `abd_proto.py` for prompt experimentation tied to Azure Billing dialogues. - Leverage the sample CSV files to validate plotting offline before wiring the plugin into a notebook or agent loop. diff --git a/application/single_app/app.py b/application/single_app/app.py index 54336100d..53f2ff5cd 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -55,9 +55,11 @@ from route_backend_retention_policy import * from route_backend_plugins import bpap as admin_plugins_bp, bpdp as dynamic_plugins_bp from route_backend_agents import bpa as admin_agents_bp +from route_backend_agent_templates import bp_agent_templates from route_backend_public_workspaces import * from route_backend_public_documents import * from route_backend_public_prompts import * +from route_backend_user_agreement import register_route_backend_user_agreement from route_backend_speech import register_route_backend_speech from route_backend_tts import register_route_backend_tts from route_enhanced_citations import register_enhanced_citations_routes @@ -97,6 +99,7 @@ app.register_blueprint(admin_plugins_bp) app.register_blueprint(dynamic_plugins_bp) app.register_blueprint(admin_agents_bp) +app.register_blueprint(bp_agent_templates) app.register_blueprint(plugin_validation_bp) app.register_blueprint(bp_migration) app.register_blueprint(plugin_logging_bp) @@ -617,6 +620,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Public Prompts Routes ---------- register_route_backend_public_prompts(app) +# ------------------- API User Agreement Routes ---------- +register_route_backend_user_agreement(app) + # ------------------- Extenral Health Routes ---------- register_route_external_health(app) diff --git a/application/single_app/config.py b/application/single_app/config.py index 2224a49e1..0596e3ca4 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.235.025" +VERSION = "0.236.011" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -377,6 +377,12 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/id") ) +cosmos_agent_templates_container_name = "agent_templates" +cosmos_agent_templates_container = cosmos_database.create_container_if_not_exists( + id=cosmos_agent_templates_container_name, + partition_key=PartitionKey(path="/id") +) + cosmos_agent_facts_container_name = "agent_facts" cosmos_agent_facts_container = cosmos_database.create_container_if_not_exists( id=cosmos_agent_facts_container_name, @@ -645,7 +651,7 @@ def initialize_clients(settings): azure_apim_content_safety_endpoint = settings.get("azure_apim_content_safety_endpoint") azure_apim_content_safety_subscription_key = settings.get("azure_apim_content_safety_subscription_key") - if safety_endpoint and safety_key: + if safety_endpoint: try: if enable_content_safety_apim: content_safety_client = ContentSafetyClient( diff --git a/application/single_app/foundry_agent_runtime.py b/application/single_app/foundry_agent_runtime.py new file mode 100644 index 000000000..36a99ec3d --- /dev/null +++ b/application/single_app/foundry_agent_runtime.py @@ -0,0 +1,355 @@ +# foundry_agent_runtime.py +"""Azure AI Foundry agent execution helpers.""" + +import asyncio +import logging +import os +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional + +from azure.identity import AzureAuthorityHosts +from azure.identity.aio import ( # type: ignore + ClientSecretCredential, + DefaultAzureCredential, +) +from semantic_kernel.agents import AzureAIAgent +from semantic_kernel.contents.chat_message_content import ChatMessageContent + +from functions_appinsights import log_event +from functions_debug import debug_print +from functions_keyvault import ( + retrieve_secret_from_key_vault_by_full_name, + validate_secret_name_dynamic, +) + +_logger = logging.getLogger("foundry_agent_runtime") + + +@dataclass +class FoundryAgentInvocationResult: + """Represents the outcome from a Foundry agent run.""" + + message: str + model: Optional[str] + citations: List[Dict[str, Any]] + metadata: Dict[str, Any] + + +class FoundryAgentInvocationError(RuntimeError): + """Raised when the Foundry agent invocation cannot be completed.""" + + +class AzureAIFoundryChatCompletionAgent: + """Lightweight wrapper so Foundry agents behave like SK chat agents.""" + + agent_type = "aifoundry" + + def __init__(self, agent_config: Dict[str, Any], settings: Dict[str, Any]): + self.name = agent_config.get("name") + self.display_name = agent_config.get("display_name") or self.name + self.description = agent_config.get("description", "") + self.id = agent_config.get("id") + self.default_agent = agent_config.get("default_agent", False) + self.is_global = agent_config.get("is_global", False) + self.is_group = agent_config.get("is_group", False) + self.group_id = agent_config.get("group_id") + self.group_name = agent_config.get("group_name") + self.max_completion_tokens = agent_config.get("max_completion_tokens", -1) + self.last_run_citations: List[Dict[str, Any]] = [] + self.last_run_model: Optional[str] = None + self._foundry_settings = ( + (agent_config.get("other_settings") or {}).get("azure_ai_foundry") or {} + ) + self._global_settings = settings or {} + + def invoke( + self, + agent_message_history: Iterable[ChatMessageContent], + metadata: Optional[Dict[str, Any]] = None, + ) -> str: + """Synchronously invoke the Foundry agent and return the final message text.""" + + metadata = metadata or {} + history = list(agent_message_history) + debug_print( + f"[FoundryAgent] Invoking agent '{self.name}' with {len(history)} messages" + ) + + try: + result = asyncio.run( + execute_foundry_agent( + foundry_settings=self._foundry_settings, + global_settings=self._global_settings, + message_history=history, + metadata=metadata, + ) + ) + except RuntimeError: + log_event( + "[FoundryAgent] Invocation runtime error", + extra={ + "agent_id": self.id, + "agent_name": self.name, + }, + level=logging.ERROR, + ) + raise + except Exception as exc: # pragma: no cover - defensive logging + log_event( + "[FoundryAgent] Invocation error", + extra={ + "agent_id": self.id, + "agent_name": self.name, + }, + level=logging.ERROR, + ) + raise + + self.last_run_citations = result.citations + self.last_run_model = result.model + return result.message + + +async def execute_foundry_agent( + *, + foundry_settings: Dict[str, Any], + global_settings: Dict[str, Any], + message_history: List[ChatMessageContent], + metadata: Dict[str, Any], +) -> FoundryAgentInvocationResult: + """Invoke a Foundry agent using Semantic Kernel's AzureAIAgent abstraction.""" + + agent_id = (foundry_settings.get("agent_id") or "").strip() + if not agent_id: + raise FoundryAgentInvocationError( + "Azure AI Foundry agents require an agent_id in other_settings.azure_ai_foundry." + ) + + endpoint = _resolve_endpoint(foundry_settings, global_settings) + api_version = foundry_settings.get("api_version") or global_settings.get( + "azure_ai_foundry_api_version" + ) + + credential = _build_async_credential(foundry_settings, global_settings) + client = AzureAIAgent.create_client( + credential=credential, + endpoint=endpoint, + api_version=api_version, + ) + + try: + definition = await client.agents.get_agent(agent_id) + azure_agent = AzureAIAgent(client=client, definition=definition) + responses = [] + async for response in azure_agent.invoke( + messages=message_history, + metadata={k: str(v) for k, v in metadata.items() if v is not None}, + ): + responses.append(response) + + if not responses: + raise FoundryAgentInvocationError("Foundry agent returned no messages.") + + last_response = responses[-1] + + thread_id = None + if last_response.thread is not None: + thread_id = getattr(last_response.thread, "id", None) + + message_obj = last_response.message + + if not thread_id: + metadata_thread_id = None + if isinstance(message_obj.metadata, dict): + metadata_thread_id = message_obj.metadata.get("thread_id") + thread_id = metadata_thread_id or metadata.get("thread_id") + + if thread_id: + try: + if last_response.thread is not None and hasattr(last_response.thread, "delete"): + await last_response.thread.delete() + elif hasattr(client, "agents") and hasattr(client.agents, "delete_thread"): + await client.agents.delete_thread(thread_id) + except Exception as cleanup_error: # pragma: no cover - best effort cleanup + _logger.warning("Failed to delete Foundry thread: %s", cleanup_error) + text = _extract_message_text(message_obj) + citations = _extract_citations(message_obj) + model_name = getattr(definition, "model", None) + if isinstance(model_name, dict): + model_value = model_name.get("id") + else: + model_value = getattr(model_name, "id", None) + + log_event( + "[FoundryAgent] Invocation complete", + extra={ + "agent_id": agent_id, + "endpoint": endpoint, + "model": model_value, + "message_length": len(text or ""), + }, + ) + + return FoundryAgentInvocationResult( + message=text, + model=model_value, + citations=citations, + metadata=message_obj.metadata or {}, + ) + finally: + try: + await client.close() + finally: + await credential.close() + + +def _resolve_endpoint(foundry_settings: Dict[str, Any], global_settings: Dict[str, Any]) -> str: + endpoint = ( + foundry_settings.get("endpoint") + or global_settings.get("azure_ai_foundry_endpoint") + or os.getenv("AZURE_AI_AGENT_ENDPOINT") + ) + if endpoint: + return endpoint.rstrip("/") + + raise FoundryAgentInvocationError( + "Azure AI Foundry endpoint is not configured. Provide an endpoint in the agent's other_settings.azure_ai_foundry or global settings." + ) + + +def _build_async_credential( + foundry_settings: Dict[str, Any], + global_settings: Dict[str, Any], +): + auth_type = ( + foundry_settings.get("authentication_type") + or foundry_settings.get("auth_type") + or global_settings.get("azure_ai_foundry_authentication_type") + ) + managed_identity_type = ( + foundry_settings.get("managed_identity_type") + or global_settings.get("azure_ai_foundry_managed_identity_type") + ) + managed_identity_client_id = ( + foundry_settings.get("managed_identity_client_id") + or global_settings.get("azure_ai_foundry_managed_identity_client_id") + ) + + authority = ( + foundry_settings.get("authority") + or global_settings.get("azure_ai_foundry_authority") + or _authority_from_cloud(foundry_settings.get("cloud") or global_settings.get("azure_ai_foundry_cloud")) + ) + + tenant_id = foundry_settings.get("tenant_id") or global_settings.get( + "azure_ai_foundry_tenant_id" + ) + client_id = foundry_settings.get("client_id") or global_settings.get( + "azure_ai_foundry_client_id" + ) + client_secret = foundry_settings.get("client_secret") or global_settings.get( + "azure_ai_foundry_client_secret" + ) + + if auth_type == "service_principal": + if not client_secret: + raise FoundryAgentInvocationError( + "Foundry service principals require client_secret value." + ) + resolved_secret = _resolve_secret_value(client_secret) + if not tenant_id or not client_id: + raise FoundryAgentInvocationError( + "Foundry service principals require tenant_id and client_id values." + ) + return ClientSecretCredential( + tenant_id=tenant_id, + client_id=client_id, + client_secret=resolved_secret, + authority=authority, + ) + + if client_secret and auth_type != "managed_identity": + resolved_secret = _resolve_secret_value(client_secret) + if not tenant_id or not client_id: + raise FoundryAgentInvocationError( + "Foundry service principals require tenant_id and client_id values." + ) + return ClientSecretCredential( + tenant_id=tenant_id, + client_id=client_id, + client_secret=resolved_secret, + authority=authority, + ) + + if auth_type == "managed_identity": + if managed_identity_type == "user_assigned" and managed_identity_client_id: + return DefaultAzureCredential( + authority=authority, + managed_identity_client_id=managed_identity_client_id, + ) + return DefaultAzureCredential(authority=authority) + + # Fall back to default chained credentials (managed identity, CLI, etc.) + return DefaultAzureCredential(authority=authority) + + +def _resolve_secret_value(value: str) -> str: + if validate_secret_name_dynamic(value): + resolved = retrieve_secret_from_key_vault_by_full_name(value) + if not resolved: + raise FoundryAgentInvocationError( + f"Unable to resolve Key Vault secret '{value}' for Foundry credentials." + ) + return resolved + return value + + +def _authority_from_cloud(cloud_value: Optional[str]) -> str: + if not cloud_value: + return AzureAuthorityHosts.AZURE_PUBLIC_CLOUD + + normalized = cloud_value.lower() + if normalized in ("usgov", "usgovernment", "gcc"): + return AzureAuthorityHosts.AZURE_GOVERNMENT + return AzureAuthorityHosts.AZURE_PUBLIC_CLOUD + + +def _extract_message_text(message: ChatMessageContent) -> str: + if message.content: + if isinstance(message.content, str): + return message.content + try: + return "".join(str(chunk) for chunk in message.content) + except TypeError: + return str(message.content) + return "" + + +def _extract_citations(message: ChatMessageContent) -> List[Dict[str, Any]]: + metadata = message.metadata or {} + citations = metadata.get("citations") + if isinstance(citations, list): + return [c for c in citations if isinstance(c, dict)] + items = getattr(message, "items", None) + if isinstance(items, list): + extracted: List[Dict[str, Any]] = [] + for item in items: + content_type = getattr(item, "content_type", None) + if content_type != "annotation": + continue + url = getattr(item, "url", None) + title = getattr(item, "title", None) + quote = getattr(item, "quote", None) + if not url: + continue + extracted.append( + { + "url": url, + "title": title, + "quote": quote, + "citation_type": getattr(item, "citation_type", None), + } + ) + if extracted: + return extracted + return [] diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index fb005f067..df9cabf31 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -118,6 +118,58 @@ def log_user_activity( debug_print(f"Error logging user activity for user {user_id}: {str(e)}") +def log_web_search_consent_acceptance( + user_id: str, + admin_email: str, + consent_text: str, + source: str = 'admin_settings' +) -> None: + """ + Log web search consent acceptance to activity_logs and App Insights. + + Args: + user_id (str): Admin user ID who accepted the consent. + admin_email (str): Admin email who accepted the consent. + consent_text (str): Consent message accepted by the admin. + source (str, optional): Origin of the consent action. + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'activity_type': 'web_search_consent_acceptance', + 'user_id': user_id, + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'accepted_by': { + 'user_id': user_id, + 'email': admin_email + }, + 'source': source, + 'description': consent_text + } + + cosmos_activity_logs_container.create_item(body=activity_record) + + log_event( + message=consent_text, + extra=activity_record, + level=logging.INFO + ) + debug_print(f"Logged web search consent acceptance for user {user_id}") + + except Exception as e: + log_event( + message=f"Error logging web search consent acceptance: {str(e)}", + extra={ + 'user_id': user_id, + 'admin_email': admin_email, + 'error': str(e) + }, + level=logging.ERROR + ) + debug_print(f"Error logging web search consent acceptance for user {user_id}: {str(e)}") + + def log_document_upload( user_id: str, container_type: str, @@ -1080,3 +1132,210 @@ def log_public_workspace_status_change( level=logging.ERROR ) debug_print(f"āš ļø Warning: Failed to log public workspace status change: {str(e)}") + + +def log_user_agreement_accepted( + user_id: str, + workspace_type: str, + workspace_id: str, + workspace_name: Optional[str] = None, + action_context: Optional[str] = None +) -> None: + """ + Log when a user accepts a user agreement in a workspace. + This record is used to track acceptance and support daily acceptance features. + + Args: + user_id (str): The ID of the user who accepted the agreement + workspace_type (str): Type of workspace ('personal', 'group', 'public') + workspace_id (str): The ID of the workspace + workspace_name (str, optional): The name of the workspace + action_context (str, optional): The context/action that triggered the agreement + (e.g., 'file_upload', 'chat') + """ + + try: + import uuid + + # Create user agreement acceptance record + acceptance_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'user_agreement_accepted', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'accepted_date': datetime.utcnow().strftime('%Y-%m-%d'), # Date only for daily lookup + 'workspace_type': workspace_type, + 'workspace_context': { + f'{workspace_type}_workspace_id': workspace_id, + 'workspace_name': workspace_name + }, + 'action_context': action_context + } + + # Save to activity_logs container + cosmos_activity_logs_container.create_item(body=acceptance_record) + + # Also log to Application Insights for monitoring + log_event( + message=f"User agreement accepted: user {user_id} in {workspace_type} workspace {workspace_id}", + extra=acceptance_record, + level=logging.INFO + ) + + debug_print(f"āœ… Logged user agreement acceptance: user {user_id} in {workspace_type} workspace {workspace_id}") + + except Exception as e: + # Log error but don't fail the operation + log_event( + message=f"Error logging user agreement acceptance: {str(e)}", + extra={ + 'user_id': user_id, + 'workspace_type': workspace_type, + 'workspace_id': workspace_id, + 'error': str(e) + }, + level=logging.ERROR + ) + debug_print(f"āš ļø Warning: Failed to log user agreement acceptance: {str(e)}") + + +def has_user_accepted_agreement_today( + user_id: str, + workspace_type: str, + workspace_id: str +) -> bool: + """ + Check if a user has already accepted the user agreement today for a given workspace. + Used to implement the "accept once per day" feature. + + Args: + user_id (str): The ID of the user + workspace_type (str): Type of workspace ('personal', 'group', 'public') + workspace_id (str): The ID of the workspace + + Returns: + bool: True if user has accepted today, False otherwise + """ + + try: + today_date = datetime.utcnow().strftime('%Y-%m-%d') + + # Query for today's acceptance record + query = """ + SELECT VALUE COUNT(1) FROM c + WHERE c.user_id = @user_id + AND c.activity_type = 'user_agreement_accepted' + AND c.accepted_date = @today_date + AND c.workspace_type = @workspace_type + AND c.workspace_context[@workspace_id_key] = @workspace_id + """ + + workspace_id_key = f'{workspace_type}_workspace_id' + + params = [ + {"name": "@user_id", "value": user_id}, + {"name": "@today_date", "value": today_date}, + {"name": "@workspace_type", "value": workspace_type}, + {"name": "@workspace_id_key", "value": workspace_id_key}, + {"name": "@workspace_id", "value": workspace_id} + ] + + results = list(cosmos_activity_logs_container.query_items( + query=query, + parameters=params, + enable_cross_partition_query=False # Query by partition key (user_id) + )) + + count = results[0] if results else 0 + + debug_print(f"šŸ” User agreement check: user {user_id}, workspace {workspace_id}, today={today_date}, accepted={count > 0}") + + return count > 0 + + except Exception as e: + # Log error and return False (require re-acceptance on error) + log_event( + message=f"Error checking user agreement acceptance: {str(e)}", + extra={ + 'user_id': user_id, + 'workspace_type': workspace_type, + 'workspace_id': workspace_id, + 'error': str(e) + }, + level=logging.ERROR + ) + debug_print(f"āš ļø Error checking user agreement acceptance: {str(e)}") + return False + + +def log_retention_policy_force_push( + admin_user_id: str, + admin_email: str, + scopes: list, + results: dict, + total_updated: int +) -> None: + """ + Log retention policy force push action to activity_logs container. + + This creates a permanent audit record when an admin forces organization + default retention policies to be applied to all workspaces. + + Args: + admin_user_id (str): User ID of the admin performing the force push + admin_email (str): Email of the admin performing the force push + scopes (list): List of workspace types affected (e.g., ['personal', 'group', 'public']) + results (dict): Breakdown of updates per workspace type + total_updated (int): Total number of workspaces/users updated + """ + + try: + # Create force push activity record + force_push_activity = { + 'id': str(uuid.uuid4()), + 'user_id': admin_user_id, # Partition key + 'activity_type': 'retention_policy_force_push', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'admin': { + 'user_id': admin_user_id, + 'email': admin_email + }, + 'force_push_details': { + 'scopes': scopes, + 'results': results, + 'total_updated': total_updated, + 'executed_at': datetime.utcnow().isoformat() + }, + 'workspace_type': 'admin', + 'workspace_context': { + 'action': 'retention_policy_force_push' + } + } + + # Save to activity_logs container for permanent audit trail + cosmos_activity_logs_container.create_item(body=force_push_activity) + + # Also log to Application Insights for monitoring + log_event( + message=f"Retention policy force push executed by {admin_email} for scopes: {', '.join(scopes)}. Total updated: {total_updated}", + extra=force_push_activity, + level=logging.INFO + ) + + debug_print(f"āœ… Retention policy force push logged: {scopes} by {admin_email}, updated {total_updated}") + + except Exception as e: + # Log error but don't break the force push flow + log_event( + message=f"Error logging retention policy force push: {str(e)}", + extra={ + 'admin_user_id': admin_user_id, + 'scopes': scopes, + 'total_updated': total_updated, + 'error': str(e) + }, + level=logging.ERROR + ) + debug_print(f"āš ļø Warning: Failed to log retention policy force push: {str(e)}") diff --git a/application/single_app/functions_agent_payload.py b/application/single_app/functions_agent_payload.py new file mode 100644 index 000000000..09f1f3432 --- /dev/null +++ b/application/single_app/functions_agent_payload.py @@ -0,0 +1,206 @@ +# functions_agent_payload.py +"""Utility helpers for normalizing agent payloads before validation and storage.""" + +from copy import deepcopy +from typing import Any, Dict, List + +_SUPPORTED_AGENT_TYPES = {"local", "aifoundry"} +_APIM_FIELDS = [ + "azure_agent_apim_gpt_endpoint", + "azure_agent_apim_gpt_subscription_key", + "azure_agent_apim_gpt_deployment", + "azure_agent_apim_gpt_api_version", +] +_GPT_FIELDS = [ + "azure_openai_gpt_endpoint", + "azure_openai_gpt_key", + "azure_openai_gpt_deployment", + "azure_openai_gpt_api_version", +] +_FREE_FORM_TEXT = [ + "name", + "display_name", + "description", + "instructions", +] +_TEXT_FIELDS = [ + "name", + "display_name", + "description", + "instructions", + "azure_openai_gpt_endpoint", + "azure_openai_gpt_deployment", + "azure_openai_gpt_api_version", + "azure_agent_apim_gpt_endpoint", + "azure_agent_apim_gpt_deployment", + "azure_agent_apim_gpt_api_version", +] +_STRING_DEFAULT_FIELDS = [ + "azure_openai_gpt_endpoint", + "azure_openai_gpt_key", + "azure_openai_gpt_deployment", + "azure_openai_gpt_api_version", + "azure_agent_apim_gpt_endpoint", + "azure_agent_apim_gpt_subscription_key", + "azure_agent_apim_gpt_deployment", + "azure_agent_apim_gpt_api_version", +] + +_MAX_FIELD_LENGTHS = { + "name": 100, + "display_name": 200, + "description": 2000, + "instructions": 30000, + "azure_openai_gpt_endpoint": 2048, + "azure_openai_gpt_key": 1024, + "azure_openai_gpt_deployment": 256, + "azure_openai_gpt_api_version": 64, + "azure_agent_apim_gpt_endpoint": 2048, + "azure_agent_apim_gpt_subscription_key": 1024, + "azure_agent_apim_gpt_deployment": 256, + "azure_agent_apim_gpt_api_version": 64, +} +_FOUNDRY_FIELD_LENGTHS = { + "agent_id": 128, + "endpoint": 2048, + "api_version": 64, + "authority": 2048, + "tenant_id": 64, + "client_id": 64, + "client_secret": 1024, + "managed_identity_client_id": 64, +} + + +class AgentPayloadError(ValueError): + """Raised when an agent payload violates backend requirements.""" + + +def is_azure_ai_foundry_agent(agent: Dict[str, Any]) -> bool: + """Return True when the agent type is Azure AI Foundry.""" + agent_type = (agent or {}).get("agent_type", "local") + if isinstance(agent_type, str): + return agent_type.strip().lower() == "aifoundry" + return False + + +def _normalize_text_fields(payload: Dict[str, Any]) -> None: + for field in _TEXT_FIELDS: + value = payload.get(field) + if isinstance(value, str): + payload[field] = value.strip() + + +def _coerce_actions(actions: Any) -> List[str]: + if actions is None or actions == "": + return [] + if not isinstance(actions, list): + raise AgentPayloadError("actions_to_load must be an array of strings.") + cleaned: List[str] = [] + for item in actions: + if isinstance(item, str): + trimmed = item.strip() + if trimmed: + cleaned.append(trimmed) + else: + raise AgentPayloadError("actions_to_load entries must be strings.") + return cleaned + + +def _coerce_other_settings(settings: Any) -> Dict[str, Any]: + if settings in (None, ""): + return {} + if not isinstance(settings, dict): + raise AgentPayloadError("other_settings must be an object.") + return settings + + +def _coerce_agent_type(agent_type: Any) -> str: + if isinstance(agent_type, str): + agent_type = agent_type.strip().lower() + else: + agent_type = "local" + if agent_type not in _SUPPORTED_AGENT_TYPES: + return "local" + return agent_type + + +def _coerce_completion_tokens(value: Any) -> int: + if value in (None, "", " "): + return -1 + try: + return int(value) + except (TypeError, ValueError) as exc: + raise AgentPayloadError("max_completion_tokens must be an integer.") from exc + +def _validate_field_lengths(payload: Dict[str, Any]) -> None: + for field, max_len in _MAX_FIELD_LENGTHS.items(): + value = payload.get(field, "") + if isinstance(value, str) and len(value) > max_len: + raise AgentPayloadError(f"{field} exceeds maximum length of {max_len}.") + + +def _validate_foundry_field_lengths(foundry_settings: Dict[str, Any]) -> None: + for field, max_len in _FOUNDRY_FIELD_LENGTHS.items(): + value = foundry_settings.get(field, "") + if isinstance(value, str) and len(value) > max_len: + raise AgentPayloadError(f"azure_ai_foundry.{field} exceeds maximum length of {max_len}.") + +def sanitize_agent_payload(agent: Dict[str, Any]) -> Dict[str, Any]: + """Return a sanitized copy of the agent payload or raise AgentPayloadError.""" + if not isinstance(agent, dict): + raise AgentPayloadError("Agent payload must be an object.") + + sanitized = deepcopy(agent) + _normalize_text_fields(sanitized) + + for field in _STRING_DEFAULT_FIELDS: + value = sanitized.get(field) + if value is None: + sanitized[field] = "" + + _validate_field_lengths(sanitized) + + agent_type = _coerce_agent_type(sanitized.get("agent_type")) + sanitized["agent_type"] = agent_type + + sanitized["other_settings"] = _coerce_other_settings(sanitized.get("other_settings")) + sanitized["actions_to_load"] = _coerce_actions(sanitized.get("actions_to_load")) + sanitized["max_completion_tokens"] = _coerce_completion_tokens( + sanitized.get("max_completion_tokens") + ) + + sanitized["enable_agent_gpt_apim"] = bool( + sanitized.get("enable_agent_gpt_apim", False) + ) + sanitized.setdefault("is_global", False) + sanitized.setdefault("is_group", False) + + if agent_type == "aifoundry": + sanitized["enable_agent_gpt_apim"] = False + for field in _APIM_FIELDS: + sanitized.pop(field, None) + sanitized["actions_to_load"] = [] + + foundry_settings = sanitized["other_settings"].get("azure_ai_foundry") + if not isinstance(foundry_settings, dict): + raise AgentPayloadError( + "Azure AI Foundry agents require other_settings.azure_ai_foundry." + ) + agent_id = str(foundry_settings.get("agent_id", "")).strip() + if not agent_id: + raise AgentPayloadError( + "Azure AI Foundry agents require other_settings.azure_ai_foundry.agent_id." + ) + foundry_settings["agent_id"] = agent_id + _validate_foundry_field_lengths(foundry_settings) + sanitized["other_settings"]["azure_ai_foundry"] = foundry_settings + else: + # Remove stale foundry metadata when toggling back to local agents. + azure_foundry = sanitized["other_settings"].get("azure_ai_foundry") + if azure_foundry is not None and not isinstance(azure_foundry, dict): + raise AgentPayloadError("azure_ai_foundry must be an object when provided.") + if azure_foundry: + sanitized["other_settings"].pop("azure_ai_foundry", None) + + return sanitized \ No newline at end of file diff --git a/application/single_app/functions_agent_templates.py b/application/single_app/functions_agent_templates.py new file mode 100644 index 000000000..10838fd65 --- /dev/null +++ b/application/single_app/functions_agent_templates.py @@ -0,0 +1,349 @@ +# functions_agent_templates.py +"""Agent template helper functions. + +This module centralizes CRUD operations for agent templates stored in the +Cosmos DB `agent_templates` container. Templates are surfaced as reusable +starting points inside the agent builder UI. +""" + +from __future__ import annotations + +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from azure.cosmos import exceptions +from flask import current_app + +from config import cosmos_agent_templates_container +from functions_appinsights import log_event + +STATUS_PENDING = "pending" +STATUS_APPROVED = "approved" +STATUS_REJECTED = "rejected" +STATUS_ARCHIVED = "archived" +ALLOWED_STATUSES = {STATUS_PENDING, STATUS_APPROVED, STATUS_REJECTED, STATUS_ARCHIVED} + +_MAX_TEMPLATE_FIELD_LENGTHS = { + "title": 200, + "display_name": 200, + "helper_text": 140, + "description": 2000, + "instructions": 30000, + "template_key": 128, +} + +_MAX_TEMPLATE_LIST_ITEM_LENGTHS = { + "tags": 64, + "actions_to_load": 128, +} + + +def _utc_now() -> str: + return datetime.utcnow().isoformat() + + +def _slugify(text: str) -> str: + if not text: + return "template" + slug = text.strip().lower() + allowed = "abcdefghijklmnopqrstuvwxyz0123456789-_" + slug = slug.replace(" ", "-") + slug = ''.join(ch for ch in slug if ch in allowed) + slug = slug.strip('-') + return slug or "template" + + +def _normalize_helper_text(description: str, explicit_helper: Optional[str]) -> str: + helper = explicit_helper or description or "" + helper = helper.strip() + if len(helper) <= 140: + return helper + return helper[:137].rstrip() + "..." + + +def _parse_additional_settings(value: Any) -> Dict[str, Any]: + if not value: + return {} + if isinstance(value, dict): + return value + if isinstance(value, str): + trimmed = value.strip() + if not trimmed: + return {} + try: + return json.loads(trimmed) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON for additional_settings: {exc}") from exc + raise ValueError("additional_settings must be a JSON string or object") + + +def _strip_metadata(doc: Dict[str, Any]) -> Dict[str, Any]: + return {k: v for k, v in doc.items() if not k.startswith('_')} + + +def _serialize_additional_settings(raw: Any) -> str: + try: + parsed = _parse_additional_settings(raw) + except ValueError: + return raw if isinstance(raw, str) else "" + if not parsed: + return "" + return json.dumps(parsed, indent=2, sort_keys=True) + + +def _sanitize_template(doc: Dict[str, Any], include_internal: bool = False) -> Dict[str, Any]: + cleaned = _strip_metadata(doc) + cleaned.setdefault('actions_to_load', []) + cleaned['actions_to_load'] = [a for a in cleaned['actions_to_load'] if a] + cleaned.setdefault('tags', []) + cleaned['tags'] = [str(tag)[:64] for tag in cleaned['tags']] + cleaned['helper_text'] = _normalize_helper_text( + cleaned.get('description', ''), + cleaned.get('helper_text') + ) + cleaned['additional_settings'] = _serialize_additional_settings(cleaned.get('additional_settings')) + cleaned.setdefault('status', STATUS_PENDING) + cleaned.setdefault('title', cleaned.get('display_name') or 'Agent Template') + cleaned.setdefault('template_key', _slugify(cleaned['title'])) + + if not include_internal: + for field in ['submission_notes', 'review_notes', 'rejection_reason', 'created_by', 'created_by_email']: + cleaned.pop(field, None) + + return cleaned + + +def _validate_template_lengths(payload: Dict[str, Any]) -> None: + for field, max_len in _MAX_TEMPLATE_FIELD_LENGTHS.items(): + value = payload.get(field, "") + if isinstance(value, str) and len(value) > max_len: + raise ValueError(f"{field} exceeds maximum length of {max_len}.") + + for field, max_len in _MAX_TEMPLATE_LIST_ITEM_LENGTHS.items(): + values = payload.get(field) or [] + if not isinstance(values, list): + continue + for item in values: + if isinstance(item, str) and len(item) > max_len: + raise ValueError(f"{field} entries exceed maximum length of {max_len}.") + + +def validate_template_payload(payload: Dict[str, Any]) -> Optional[str]: + if not isinstance(payload, dict): + return "Template payload must be an object" + if not (payload.get('display_name') or payload.get('title')): + return "Display name is required" + if not payload.get('description'): + return "Description is required" + if not payload.get('instructions'): + return "Instructions are required" + if payload.get('additional_settings'): + try: + _parse_additional_settings(payload['additional_settings']) + except ValueError as exc: + return str(exc) + # Return false if valid to keep with consistency of returning bools or values because we return the error. + return False + + +def list_agent_templates(status: Optional[str] = None, include_internal: bool = False) -> List[Dict[str, Any]]: + query = "SELECT * FROM c" + parameters = [] + if status: + query += " WHERE c.status = @status" + parameters.append({"name": "@status", "value": status}) + + try: + items = list( + cosmos_agent_templates_container.query_items( + query=query, + parameters=parameters or None, + enable_cross_partition_query=True, + ) + ) + except Exception as exc: + current_app.logger.error("Failed to list agent templates: %s", exc) + return [] + + sanitized = [_sanitize_template(item, include_internal) for item in items] + sanitized.sort(key=lambda tpl: tpl.get('title', '').lower()) + return sanitized + + +def get_agent_template(template_id: str) -> Optional[Dict[str, Any]]: + try: + doc = cosmos_agent_templates_container.read_item(item=template_id, partition_key=template_id) + return _sanitize_template(doc, include_internal=True) + except exceptions.CosmosResourceNotFoundError: + return None + except Exception as exc: + current_app.logger.error("Failed to fetch agent template %s: %s", template_id, exc) + return None + + +def _base_template_from_payload(payload: Dict[str, Any], user_info: Optional[Dict[str, Any]], auto_approve: bool) -> Dict[str, Any]: + now = _utc_now() + title = payload.get('title') or payload.get('display_name') or 'Agent Template' + helper_text = _normalize_helper_text(payload.get('description', ''), payload.get('helper_text')) + additional_settings = _parse_additional_settings(payload.get('additional_settings')) + tags = payload.get('tags') or [] + tags = [str(tag)[:64] for tag in tags] + + actions = [str(action) for action in (payload.get('actions_to_load') or []) if action] + + template = { + 'id': payload.get('id') or str(uuid.uuid4()), + 'template_key': payload.get('template_key') or f"{_slugify(title)}-{uuid.uuid4().hex[:6]}", + 'title': title, + 'display_name': payload.get('display_name') or title, + 'helper_text': helper_text, + 'description': payload.get('description', ''), + 'instructions': payload.get('instructions', ''), + 'additional_settings': additional_settings, + 'actions_to_load': actions, + 'tags': tags, + 'status': STATUS_APPROVED if auto_approve else STATUS_PENDING, + 'created_at': now, + 'updated_at': now, + 'created_by': user_info.get('userId') if user_info else None, + 'created_by_name': user_info.get('displayName') if user_info else None, + 'created_by_email': user_info.get('email') if user_info else None, + 'submission_notes': payload.get('submission_notes'), + 'source_agent_id': payload.get('source_agent_id'), + 'source_scope': payload.get('source_scope') or 'personal', + 'approved_by': user_info.get('userId') if auto_approve and user_info else None, + 'approved_at': now if auto_approve else None, + 'review_notes': payload.get('review_notes'), + 'rejection_reason': None, + } + return template + + +def create_agent_template(payload: Dict[str, Any], user_info: Optional[Dict[str, Any]], auto_approve: bool = False) -> Dict[str, Any]: + template = _base_template_from_payload(payload, user_info, auto_approve) + try: + cosmos_agent_templates_container.upsert_item(template) + except Exception as exc: + current_app.logger.error("Failed to save agent template: %s", exc) + raise + + log_event( + "Agent template submitted", + extra={ + "template_id": template['id'], + "status": template['status'], + "created_by": template.get('created_by'), + }, + ) + return _sanitize_template(template, include_internal=True) + + +def update_agent_template(template_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: + doc = get_agent_template(template_id) + if not doc: + return None + + mutable_fields = { + 'title', 'display_name', 'helper_text', 'description', 'instructions', + 'additional_settings', 'actions_to_load', 'tags', 'status' + } + payload = {k: v for k, v in updates.items() if k in mutable_fields} + + if 'additional_settings' in payload: + payload['additional_settings'] = _parse_additional_settings(payload['additional_settings']) + else: + payload['additional_settings'] = _parse_additional_settings(doc.get('additional_settings')) + + if 'tags' in payload: + payload['tags'] = [str(tag)[:64] for tag in payload['tags']] + + if 'status' in payload: + status = payload['status'] + if status not in ALLOWED_STATUSES: + raise ValueError("Invalid template status") + else: + payload['status'] = doc.get('status', STATUS_PENDING) + + template = { + **doc, + **payload, + } + template['helper_text'] = _normalize_helper_text( + template.get('description', ''), + template.get('helper_text') + ) + template['updated_at'] = _utc_now() + template['additional_settings'] = payload['additional_settings'] + _validate_template_lengths(template) + + try: + cosmos_agent_templates_container.upsert_item(template) + except Exception as exc: + current_app.logger.error("Failed to update agent template %s: %s", template_id, exc) + raise + + return _sanitize_template(template, include_internal=True) + + +def approve_agent_template(template_id: str, approver_info: Dict[str, Any], notes: Optional[str] = None) -> Optional[Dict[str, Any]]: + doc = get_agent_template(template_id) + if not doc: + return None + doc['additional_settings'] = _parse_additional_settings(doc.get('additional_settings')) + doc['status'] = STATUS_APPROVED + doc['approved_by'] = approver_info.get('userId') + doc['approved_at'] = _utc_now() + doc['review_notes'] = notes + doc['rejection_reason'] = None + doc['updated_at'] = doc['approved_at'] + + try: + cosmos_agent_templates_container.upsert_item(doc) + except Exception as exc: + current_app.logger.error("Failed to approve agent template %s: %s", template_id, exc) + raise + + log_event( + "Agent template approved", + extra={"template_id": template_id, "approved_by": doc['approved_by']}, + ) + return _sanitize_template(doc, include_internal=True) + + +def reject_agent_template(template_id: str, approver_info: Dict[str, Any], reason: str, notes: Optional[str] = None) -> Optional[Dict[str, Any]]: + doc = get_agent_template(template_id) + if not doc: + return None + doc['additional_settings'] = _parse_additional_settings(doc.get('additional_settings')) + doc['status'] = STATUS_REJECTED + doc['approved_by'] = approver_info.get('userId') + doc['approved_at'] = _utc_now() + doc['review_notes'] = notes + doc['rejection_reason'] = reason + doc['updated_at'] = doc['approved_at'] + + try: + cosmos_agent_templates_container.upsert_item(doc) + except Exception as exc: + current_app.logger.error("Failed to reject agent template %s: %s", template_id, exc) + raise + + log_event( + "Agent template rejected", + extra={"template_id": template_id, "approved_by": doc['approved_by']}, + ) + return _sanitize_template(doc, include_internal=True) + + +def delete_agent_template(template_id: str) -> bool: + try: + cosmos_agent_templates_container.delete_item(item=template_id, partition_key=template_id) + log_event("Agent template deleted", extra={"template_id": template_id}) + return True + except exceptions.CosmosResourceNotFoundError: + return False + except Exception as exc: + current_app.logger.error("Failed to delete agent template %s: %s", template_id, exc) + raise diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 2ffd9d8fa..5cf6a3d4f 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -16,6 +16,7 @@ from config import cosmos_global_agents_container from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper from functions_settings import * +from functions_agent_payload import sanitize_agent_payload, AgentPayloadError def ensure_default_global_agent_exists(): @@ -173,21 +174,19 @@ def save_global_agent(agent_data): dict: Saved agent data or None if failed """ try: - # Ensure required fields user_id = get_current_user_id() - if 'id' not in agent_data: - agent_data['id'] = str(uuid.uuid4()) - # Add metadata - agent_data['is_global'] = True - agent_data['is_group'] = False - agent_data.setdefault('agent_type', 'local') - agent_data['created_at'] = datetime.utcnow().isoformat() - agent_data['updated_at'] = datetime.utcnow().isoformat() + cleaned_agent = sanitize_agent_payload(agent_data) + if 'id' not in cleaned_agent: + cleaned_agent['id'] = str(uuid.uuid4()) + cleaned_agent['is_global'] = True + cleaned_agent['is_group'] = False + cleaned_agent['created_at'] = datetime.utcnow().isoformat() + cleaned_agent['updated_at'] = datetime.utcnow().isoformat() log_event( "Saving global agent.", - extra={"agent_name": agent_data.get('name', 'Unknown')}, + extra={"agent_name": cleaned_agent.get('name', 'Unknown')}, ) - print(f"Saving global agent: {agent_data.get('name', 'Unknown')}") + print(f"Saving global agent: {cleaned_agent.get('name', 'Unknown')}") # Use the new helper to store sensitive agent keys in Key Vault agent_data = keyvault_agent_save_helper(agent_data, agent_data['id'], scope="global") @@ -198,7 +197,7 @@ def save_global_agent(agent_data): if agent_data.get('reasoning_effort') == '': agent_data.pop('reasoning_effort', None) - result = cosmos_global_agents_container.upsert_item(body=agent_data) + result = cosmos_global_agents_container.upsert_item(body=cleaned_agent) log_event( "Global agent saved successfully.", extra={"agent_id": result['id'], "user_id": user_id}, diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py index 764480982..8bf6f87c0 100644 --- a/application/single_app/functions_group_agents.py +++ b/application/single_app/functions_group_agents.py @@ -16,6 +16,7 @@ keyvault_agent_get_helper, keyvault_agent_save_helper, ) +from functions_agent_payload import sanitize_agent_payload _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -64,8 +65,8 @@ def get_group_agent(group_id: str, agent_id: str) -> Optional[Dict[str, Any]]: def save_group_agent(group_id: str, agent_data: Dict[str, Any]) -> Dict[str, Any]: """Create or update a group agent entry.""" - agent_id = agent_data.get("id") or str(uuid.uuid4()) - payload = dict(agent_data) + payload = sanitize_agent_payload(agent_data) + agent_id = payload.get("id") or str(uuid.uuid4()) payload["id"] = agent_id payload["group_id"] = group_id payload["last_updated"] = datetime.utcnow().isoformat() diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index 7462d1b40..bf721842f 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -18,6 +18,7 @@ from config import cosmos_personal_agents_container from functions_settings import get_settings, get_user_settings, update_user_settings from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper +from functions_agent_payload import sanitize_agent_payload from functions_debug import debug_print def get_personal_agents(user_id): @@ -111,12 +112,27 @@ def save_personal_agent(user_id, agent_data): dict: Saved agent data with ID """ try: - # Ensure required fields - if 'id' not in agent_data: - agent_data['id'] = str(f"{user_id}_{agent_data.get('name', 'default')}") + cleaned_agent = sanitize_agent_payload(agent_data) + for field in ['name', 'display_name', 'description', 'instructions']: + cleaned_agent.setdefault(field, '') + for field in [ + 'azure_openai_gpt_endpoint', + 'azure_openai_gpt_key', + 'azure_openai_gpt_deployment', + 'azure_openai_gpt_api_version', + 'azure_agent_apim_gpt_endpoint', + 'azure_agent_apim_gpt_subscription_key', + 'azure_agent_apim_gpt_deployment', + 'azure_agent_apim_gpt_api_version' + ]: + cleaned_agent.setdefault(field, '') + if 'id' not in cleaned_agent: + cleaned_agent['id'] = str(f"{user_id}_{cleaned_agent.get('name', 'default')}") - agent_data['user_id'] = user_id - agent_data['last_updated'] = datetime.utcnow().isoformat() + cleaned_agent['user_id'] = user_id + cleaned_agent['last_updated'] = datetime.utcnow().isoformat() + cleaned_agent['is_global'] = False + cleaned_agent['is_group'] = False # Validate required fields required_fields = ['name', 'display_name', 'description', 'instructions'] diff --git a/application/single_app/functions_retention_policy.py b/application/single_app/functions_retention_policy.py index 6c59ef649..6ce6dee08 100644 --- a/application/single_app/functions_retention_policy.py +++ b/application/single_app/functions_retention_policy.py @@ -82,6 +82,47 @@ def get_all_public_workspaces(): return [] +def resolve_retention_value(value, workspace_type, retention_type, settings=None): + """ + Resolve a retention value, handling 'default' by looking up organization defaults. + + Args: + value: The retention value ('none', 'default', or a number/string of days) + workspace_type: 'personal', 'group', or 'public' + retention_type: 'conversation' or 'document' + settings: Optional pre-loaded settings dict (to avoid repeated lookups) + + Returns: + str or int: 'none' if no deletion, or the number of days as int + """ + if value is None or value == 'default' or value == '': + # Look up the organization default + if settings is None: + settings = get_settings() + + setting_key = f'default_retention_{retention_type}_{workspace_type}' + default_value = settings.get(setting_key, 'none') + + # If the org default is also 'none', return 'none' + if default_value == 'none' or default_value is None: + return 'none' + + # Return the org default as the effective value + try: + return int(default_value) + except (ValueError, TypeError): + return 'none' + + # User/workspace has their own explicit value + if value == 'none': + return 'none' + + try: + return int(value) + except (ValueError, TypeError): + return 'none' + + def execute_retention_policy(workspace_scopes=None, manual_execution=False): """ Execute retention policy for specified workspace scopes. @@ -185,6 +226,9 @@ def process_personal_retention(): # Get all user settings all_users = get_all_user_settings() + # Pre-load settings once for efficiency + settings = get_settings() + for user in all_users: user_id = user.get('id') if not user_id: @@ -194,10 +238,15 @@ def process_personal_retention(): user_settings = user.get('settings', {}) retention_settings = user_settings.get('retention_policy', {}) - conversation_retention_days = retention_settings.get('conversation_retention_days', 'none') - document_retention_days = retention_settings.get('document_retention_days', 'none') + # Get raw values (may be 'default', 'none', or a number) + raw_conversation_days = retention_settings.get('conversation_retention_days') + raw_document_days = retention_settings.get('document_retention_days') - # Skip if both are set to "none" + # Resolve to effective values (handles 'default' -> org default lookup) + conversation_retention_days = resolve_retention_value(raw_conversation_days, 'personal', 'conversation', settings) + document_retention_days = resolve_retention_value(raw_document_days, 'personal', 'document', settings) + + # Skip if both resolve to "none" if conversation_retention_days == 'none' and document_retention_days == 'none': continue @@ -273,6 +322,9 @@ def process_group_retention(): # Get all groups all_groups = get_all_groups() + # Pre-load settings once for efficiency + settings = get_settings() + for group in all_groups: group_id = group.get('id') if not group_id: @@ -281,10 +333,15 @@ def process_group_retention(): # Get group's retention settings retention_settings = group.get('retention_policy', {}) - conversation_retention_days = retention_settings.get('conversation_retention_days', 'none') - document_retention_days = retention_settings.get('document_retention_days', 'none') + # Get raw values (may be 'default', 'none', or a number) + raw_conversation_days = retention_settings.get('conversation_retention_days') + raw_document_days = retention_settings.get('document_retention_days') + + # Resolve to effective values (handles 'default' -> org default lookup) + conversation_retention_days = resolve_retention_value(raw_conversation_days, 'group', 'conversation', settings) + document_retention_days = resolve_retention_value(raw_document_days, 'group', 'document', settings) - # Skip if both are set to "none" + # Skip if both resolve to "none" if conversation_retention_days == 'none' and document_retention_days == 'none': continue @@ -359,6 +416,9 @@ def process_public_retention(): # Get all public workspaces all_workspaces = get_all_public_workspaces() + # Pre-load settings once for efficiency + settings = get_settings() + for workspace in all_workspaces: workspace_id = workspace.get('id') if not workspace_id: @@ -367,10 +427,15 @@ def process_public_retention(): # Get workspace's retention settings retention_settings = workspace.get('retention_policy', {}) - conversation_retention_days = retention_settings.get('conversation_retention_days', 'none') - document_retention_days = retention_settings.get('document_retention_days', 'none') + # Get raw values (may be 'default', 'none', or a number) + raw_conversation_days = retention_settings.get('conversation_retention_days') + raw_document_days = retention_settings.get('document_retention_days') + + # Resolve to effective values (handles 'default' -> org default lookup) + conversation_retention_days = resolve_retention_value(raw_conversation_days, 'public', 'conversation', settings) + document_retention_days = resolve_retention_value(raw_document_days, 'public', 'document', settings) - # Skip if both are set to "none" + # Skip if both resolve to "none" if conversation_retention_days == 'none' and document_retention_days == 'none': continue diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index a0575f54b..7a411064e 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -40,6 +40,12 @@ def get_settings(use_cosmos=False): 'allow_user_plugins': False, 'allow_group_agents': False, 'allow_group_custom_agent_endpoints': False, + 'allow_ai_foundry_agents': False, + 'allow_group_ai_foundry_agents': False, + 'allow_personal_ai_foundry_agents': False, + 'enable_agent_template_gallery': True, + 'agent_templates_allow_user_submission': True, + 'agent_templates_require_approval': True, 'allow_group_plugins': False, 'id': 'app_settings', # Control Center settings @@ -216,6 +222,34 @@ def get_settings(use_cosmos=False): 'azure_apim_document_intelligence_endpoint': '', 'azure_apim_document_intelligence_subscription_key': '', + # Web search (via Azure AI Foundry agent) + 'enable_web_search': False, + 'web_search_consent_accepted': False, + 'enable_web_search_user_notice': False, # Show popup to users explaining their message will be sent to Bing + 'web_search_user_notice_text': 'Your message will be sent to Microsoft Bing for web search. Only your current message is sent, not your conversation history.', + 'web_search_agent': { + 'agent_type': 'aifoundry', + 'azure_openai_gpt_endpoint': '', + 'azure_openai_gpt_api_version': '', + 'azure_openai_gpt_deployment': '', + 'other_settings': { + 'azure_ai_foundry': { + 'agent_id': '', + 'endpoint': '', + 'api_version': 'v1', + 'authentication_type': 'managed_identity', + 'managed_identity_type': 'system_assigned', + 'managed_identity_client_id': '', + 'tenant_id': '', + 'client_id': '', + 'client_secret': '', + 'cloud': '', + 'authority': '', + 'notes': '' + } + } + }, + # Authentication & Redirect Settings 'enable_front_door': False, 'front_door_url': '', @@ -276,6 +310,15 @@ def get_settings(use_cosmos=False): 'retention_conversation_max_days': 3650, # ~10 years 'retention_document_min_days': 1, 'retention_document_max_days': 3650, # ~10 years + # Default retention policies for each workspace type + # 'none' means no automatic deletion (users can still set their own) + # Numeric values (e.g., 30, 60, 90, 180, 365, 730) represent days + 'default_retention_conversation_personal': 'none', + 'default_retention_document_personal': 'none', + 'default_retention_conversation_group': 'none', + 'default_retention_document_group': 'none', + 'default_retention_conversation_public': 'none', + 'default_retention_document_public': 'none', } try: @@ -732,9 +775,26 @@ def wrapper(*args, **kwargs): return decorator def sanitize_settings_for_user(full_settings: dict) -> dict: - # Exclude any key containing "key", "base64", "storage_account_url" - return {k: v for k, v in full_settings.items() - if "key" not in k.lower() and "storage_account_url" not in k.lower()} + if not isinstance(full_settings, dict): + return full_settings + + sensitive_terms = ("key", "secret", "password", "connection", "base64", "storage_account_url") + sanitized = {} + + for k, v in full_settings.items(): + if any(term in k.lower() for term in sensitive_terms): + continue + if isinstance(v, dict): + sanitized[k] = sanitize_settings_for_user(v) + elif isinstance(v, list): + sanitized[k] = [ + sanitize_settings_for_user(item) if isinstance(item, dict) else item + for item in v + ] + else: + sanitized[k] = v + + return sanitized def sanitize_settings_for_logging(full_settings: dict) -> dict: """ diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index 187b4a986..6a738388b 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -4,9 +4,9 @@ azure-monitor-query==1.4.1 Flask==2.2.5 Flask-WTF==1.2.1 gunicorn -Werkzeug==3.1.4 +Werkzeug==3.1.5 requests==2.32.4 -openai==1.67 +openai>=1.98.0,<2.0.0 docx2txt==0.8 Markdown==3.3.4 bleach==6.1.0 @@ -41,7 +41,7 @@ xlrd==2.0.1 pillow==11.1.0 ffmpeg-binaries-compat==1.0.1 ffmpeg-python==0.2.0 -semantic-kernel>=1.32.1 +semantic-kernel>=1.39.2 redis>=5.0,<6.0 pyodbc>=4.0.0 PyMySQL>=1.0.0 @@ -49,7 +49,7 @@ azure-monitor-opentelemetry==1.6.13 psycopg2-binary==2.9.10 cython pyyaml==6.0.2 -aiohttp==3.12.15 +aiohttp==3.13.3 html2text==2025.4.15 matplotlib==3.10.7 azure-cognitiveservices-speech==1.47.0 \ No newline at end of file diff --git a/application/single_app/route_backend_agent_templates.py b/application/single_app/route_backend_agent_templates.py new file mode 100644 index 000000000..282b157c0 --- /dev/null +++ b/application/single_app/route_backend_agent_templates.py @@ -0,0 +1,188 @@ +"""Backend routes for agent template management.""" + +from flask import Blueprint, jsonify, request, session +from swagger_wrapper import swagger_route, get_auth_security + +from functions_authentication import ( + admin_required, + login_required, + get_current_user_info, +) +from functions_agent_templates import ( + STATUS_APPROVED, + validate_template_payload, + list_agent_templates, + create_agent_template, + update_agent_template, + approve_agent_template, + reject_agent_template, + delete_agent_template, + get_agent_template, +) +from functions_settings import get_settings + +bp_agent_templates = Blueprint('agent_templates', __name__) + + +def _feature_flags(): + settings = get_settings() + enabled = settings.get('enable_agent_template_gallery', False) + allow_submissions = settings.get('agent_templates_allow_user_submission', True) + require_approval = settings.get('agent_templates_require_approval', True) + return enabled, allow_submissions, require_approval, settings + + +def _is_admin() -> bool: + user = session.get('user') or {} + return 'Admin' in (user.get('roles') or []) + + +@bp_agent_templates.route('/api/agent-templates', methods=['GET']) +@login_required +@swagger_route(security=get_auth_security()) +def list_public_agent_templates(): + enabled, _, _, _ = _feature_flags() + if not enabled: + return jsonify({'templates': []}) + templates = list_agent_templates(status=STATUS_APPROVED, include_internal=False) + return jsonify({'templates': templates}) + + +@bp_agent_templates.route('/api/agent-templates', methods=['POST']) +@login_required +@swagger_route(security=get_auth_security()) +def submit_agent_template(): + enabled, allow_submissions, require_approval, settings = _feature_flags() + if not enabled: + return jsonify({'error': 'Agent template gallery is disabled.'}), 403 + if not settings.get('allow_user_agents') and not _is_admin(): + return jsonify({'error': 'Agent creation is disabled for your workspace.'}), 403 + if not allow_submissions and not _is_admin(): + return jsonify({'error': 'Template submissions are disabled for users.'}), 403 + + data = request.get_json(silent=True) or {} + payload = data.get('template') or data + validation_error = validate_template_payload(payload) + # validate_template_payload returns false if valid, returns the simple error otherwise. + if validation_error: + return jsonify({'error': validation_error}), 400 + + is_admin_user = _is_admin() + payload['source_agent_id'] = payload.get('source_agent_id') or data.get('source_agent_id') + submission_scope = ( + payload.get('source_scope') + or data.get('source_scope') + or ('global' if is_admin_user else 'personal') + ) + submission_scope = str(submission_scope).lower() + payload['source_scope'] = submission_scope + + admin_context_submission = is_admin_user and submission_scope == 'global' + auto_approve = admin_context_submission or not require_approval + + try: + template = create_agent_template(payload, get_current_user_info(), auto_approve=auto_approve) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception: + return jsonify({'error': 'Failed to submit template.'}), 500 + + if not is_admin_user: + for field in ('submission_notes', 'review_notes', 'rejection_reason', 'created_by_email'): + template.pop(field, None) + + status_code = 201 if template.get('status') == STATUS_APPROVED else 202 + return jsonify({'template': template}), status_code + + +@bp_agent_templates.route('/api/admin/agent-templates', methods=['GET']) +@login_required +@admin_required +@swagger_route(security=get_auth_security()) +def admin_list_agent_templates(): + status = request.args.get('status') + if status == 'all': + status = None + templates = list_agent_templates(status=status, include_internal=True) + return jsonify({'templates': templates}) + + +@bp_agent_templates.route('/api/admin/agent-templates/', methods=['GET']) +@login_required +@admin_required +@swagger_route(security=get_auth_security()) +def admin_get_agent_template(template_id): + template = get_agent_template(template_id) + if not template: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'template': template}) + + +@bp_agent_templates.route('/api/admin/agent-templates/', methods=['PATCH']) +@login_required +@admin_required +@swagger_route(security=get_auth_security()) +def admin_update_agent_template(template_id): + payload = request.get_json(silent=True) or {} + try: + template = update_agent_template(template_id, payload) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception: + return jsonify({'error': 'Failed to update template.'}), 500 + + if not template: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'template': template}) + + +@bp_agent_templates.route('/api/admin/agent-templates//approve', methods=['POST']) +@login_required +@admin_required +@swagger_route(security=get_auth_security()) +def admin_approve_agent_template(template_id): + data = request.get_json(silent=True) or {} + notes = data.get('notes') + try: + template = approve_agent_template(template_id, get_current_user_info(), notes) + except Exception: + return jsonify({'error': 'Failed to approve template.'}), 500 + + if not template: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'template': template}) + + +@bp_agent_templates.route('/api/admin/agent-templates//reject', methods=['POST']) +@login_required +@admin_required +@swagger_route(security=get_auth_security()) +def admin_reject_agent_template(template_id): + data = request.get_json(silent=True) or {} + reason = (data.get('reason') or '').strip() + if not reason: + return jsonify({'error': 'A rejection reason is required.'}), 400 + notes = data.get('notes') + try: + template = reject_agent_template(template_id, get_current_user_info(), reason, notes) + except Exception: + return jsonify({'error': 'Failed to reject template.'}), 500 + + if not template: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'template': template}) + + +@bp_agent_templates.route('/api/admin/agent-templates/', methods=['DELETE']) +@login_required +@admin_required +@swagger_route(security=get_auth_security()) +def admin_delete_agent_template(template_id): + try: + deleted = delete_agent_template(template_id) + except Exception: + return jsonify({'error': 'Failed to delete template.'}), 500 + + if not deleted: + return jsonify({'error': 'Template not found.'}), 404 + return jsonify({'success': True}) diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 5032ebec1..b3a8220ae 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -10,6 +10,7 @@ from functions_global_agents import get_global_agents, save_global_agent, delete_global_agent from functions_personal_agents import get_personal_agents, ensure_migration_complete, save_personal_agent, delete_personal_agent from functions_group import require_active_group, assert_group_role +from functions_agent_payload import sanitize_agent_payload, AgentPayloadError from functions_group_agents import ( get_group_agents, get_group_agent, @@ -111,15 +112,16 @@ def set_user_agents(): for agent in agents: if agent.get('is_global', False): continue # Skip global agents - agent['is_global'] = False # Ensure user agents are not global - agent['is_group'] = False - # --- Require at least one deployment field --- - #if not (agent.get('azure_openai_gpt_deployment') or agent.get('azure_agent_apim_gpt_deployment')): - # return jsonify({'error': f'Agent "{agent.get("name", "(unnamed)")}" must have either azure_openai_gpt_deployment or azure_agent_apim_gpt_deployment set.'}), 400 - validation_error = validate_agent(agent) + try: + cleaned_agent = sanitize_agent_payload(agent) + except AgentPayloadError as exc: + return jsonify({'error': str(exc)}), 400 + cleaned_agent['is_global'] = False + cleaned_agent['is_group'] = False + validation_error = validate_agent(cleaned_agent) if validation_error: return jsonify({'error': f'Agent validation failed: {validation_error}'}), 400 - filtered_agents.append(agent) + filtered_agents.append(cleaned_agent) # Enforce global agent only if per_user_semantic_kernel is False per_user_semantic_kernel = settings.get('per_user_semantic_kernel', False) @@ -258,14 +260,15 @@ def create_group_agent_route(): payload = request.get_json(silent=True) or {} try: validate_group_agent_payload(payload, partial=False) - except ValueError as exc: + cleaned_payload = sanitize_agent_payload(payload) + except (ValueError, AgentPayloadError) as exc: return jsonify({'error': str(exc)}), 400 for key in ('group_id', 'last_updated', 'is_global', 'is_group'): - payload.pop(key, None) + cleaned_payload.pop(key, None) try: - saved = save_group_agent(active_group, payload) + saved = save_group_agent(active_group, cleaned_payload) except Exception as exc: debug_print('Failed to save group agent: %s', exc) return jsonify({'error': 'Unable to save agent'}), 500 @@ -313,7 +316,12 @@ def update_group_agent_route(agent_id): return jsonify({'error': str(exc)}), 400 try: - saved = save_group_agent(active_group, merged) + cleaned_payload = sanitize_agent_payload(merged) + except AgentPayloadError as exc: + return jsonify({'error': str(exc)}), 400 + + try: + saved = save_group_agent(active_group, cleaned_payload) except Exception as exc: debug_print('Failed to update group agent %s: %s', agent_id, exc) return jsonify({'error': 'Unable to update agent'}), 500 @@ -466,26 +474,31 @@ def add_agent(): try: agents = get_global_agents() new_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) - new_agent['is_global'] = True - new_agent['is_group'] = False - validation_error = validate_agent(new_agent) + try: + cleaned_agent = sanitize_agent_payload(new_agent) + except AgentPayloadError as exc: + log_event("Add agent failed: payload error", level=logging.WARNING, extra={"action": "add", "error": str(exc)}) + return jsonify({'error': str(exc)}), 400 + cleaned_agent['is_global'] = True + cleaned_agent['is_group'] = False + validation_error = validate_agent(cleaned_agent) if validation_error: - log_event("Add agent failed: validation error", level=logging.WARNING, extra={"action": "add", "agent": new_agent, "error": validation_error}) + log_event("Add agent failed: validation error", level=logging.WARNING, extra={"action": "add", "agent": cleaned_agent, "error": validation_error}) return jsonify({'error': validation_error}), 400 # Prevent duplicate names (case-insensitive) - if any(a['name'].lower() == new_agent['name'].lower() for a in agents): - log_event("Add agent failed: duplicate name", level=logging.WARNING, extra={"action": "add", "agent": new_agent}) + if any(a['name'].lower() == cleaned_agent['name'].lower() for a in agents): + log_event("Add agent failed: duplicate name", level=logging.WARNING, extra={"action": "add", "agent": cleaned_agent}) return jsonify({'error': 'Agent with this name already exists.'}), 400 # Assign a new GUID as id unless this is the default agent (which should have a static GUID) - if not new_agent.get('default_agent', False): - new_agent['id'] = str(uuid.uuid4()) + if not cleaned_agent.get('default_agent', False): + cleaned_agent['id'] = str(uuid.uuid4()) else: # If default_agent, ensure the static GUID is present (do not overwrite if already set) - if not new_agent.get('id'): - new_agent['id'] = '15b0c92a-741d-42ff-ba0b-367c7ee0c848' + if not cleaned_agent.get('id'): + cleaned_agent['id'] = '15b0c92a-741d-42ff-ba0b-367c7ee0c848' # Save to global agents container - result = save_global_agent(new_agent) + result = save_global_agent(cleaned_agent) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 @@ -499,7 +512,7 @@ def add_agent(): if not found: return jsonify({'error': 'There must be at least one agent matching the global_selected_agent.'}), 400 - log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in new_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) + log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) return jsonify({'success': True}) @@ -576,15 +589,20 @@ def edit_agent(agent_name): try: agents = get_global_agents() updated_agent = request.json.copy() if hasattr(request.json, 'copy') else dict(request.json) - updated_agent['is_global'] = True - updated_agent['is_group'] = False - validation_error = validate_agent(updated_agent) + try: + cleaned_agent = sanitize_agent_payload(updated_agent) + except AgentPayloadError as exc: + log_event("Edit agent failed: payload error", level=logging.WARNING, extra={"action": "edit", "agent_name": agent_name, "error": str(exc)}) + return jsonify({'error': str(exc)}), 400 + cleaned_agent['is_global'] = True + cleaned_agent['is_group'] = False + validation_error = validate_agent(cleaned_agent) if validation_error: - log_event("Edit agent failed: validation error", level=logging.WARNING, extra={"action": "edit", "agent": updated_agent, "error": validation_error}) + log_event("Edit agent failed: validation error", level=logging.WARNING, extra={"action": "edit", "agent": cleaned_agent, "error": validation_error}) return jsonify({'error': validation_error}), 400 # --- Require at least one deployment field --- - if not (updated_agent.get('azure_openai_gpt_deployment') or updated_agent.get('azure_agent_apim_gpt_deployment')): - log_event("Edit agent failed: missing deployment field", level=logging.WARNING, extra={"action": "edit", "agent": updated_agent}) + if not (cleaned_agent.get('azure_openai_gpt_deployment') or cleaned_agent.get('azure_agent_apim_gpt_deployment')): + log_event("Edit agent failed: missing deployment field", level=logging.WARNING, extra={"action": "edit", "agent": cleaned_agent}) return jsonify({'error': 'Agent must have either azure_openai_gpt_deployment or azure_agent_apim_gpt_deployment set.'}), 400 # Find the agent to update @@ -592,7 +610,7 @@ def edit_agent(agent_name): for a in agents: if a['name'] == agent_name: # Preserve the existing id - updated_agent['id'] = a.get('id') + cleaned_agent['id'] = a.get('id') agent_found = True break @@ -601,7 +619,7 @@ def edit_agent(agent_name): return jsonify({'error': 'Agent not found.'}), 404 # Save the updated agent - result = save_global_agent(updated_agent) + result = save_global_agent(cleaned_agent) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 @@ -619,7 +637,7 @@ def edit_agent(agent_name): f"Agent {agent_name} edited", extra={ "action": "edit", - "agent": {k: v for k, v in updated_agent.items() if k != 'id'}, + "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id()), } ) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 27c30e9c8..ad514e6f7 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -8,10 +8,13 @@ from semantic_kernel_fact_memory_store import FactMemoryStore from semantic_kernel_loader import initialize_semantic_kernel from semantic_kernel_plugins.plugin_invocation_logger import get_plugin_logger +from foundry_agent_runtime import FoundryAgentInvocationError, execute_foundry_agent import builtins import asyncio, types +import ast import json -from typing import Any, Dict, List +import re +from typing import Any, Dict, List, Mapping, Optional from config import * from flask import g from functions_authentication import * @@ -22,7 +25,7 @@ from functions_chat import * from functions_conversation_metadata import collect_conversation_metadata, update_conversation_with_metadata from functions_debug import debug_print -from functions_activity_logging import log_chat_activity, log_conversation_creation +from functions_activity_logging import log_chat_activity, log_conversation_creation, log_token_usage from flask import current_app from swagger_wrapper import swagger_route, get_auth_security @@ -55,6 +58,7 @@ def chat_api(): user_message = data.get('message', '') conversation_id = data.get('conversation_id') hybrid_search_enabled = data.get('hybrid_search') + web_search_enabled = data.get('web_search_enabled') selected_document_id = data.get('selected_document_id') image_gen_enabled = data.get('image_generation') document_scope = data.get('doc_scope') @@ -153,6 +157,7 @@ def result_requires_message_reload(result: Any) -> bool: search_query = user_message # <--- ADD THIS LINE (Initialize search_query) hybrid_citations_list = [] # <--- ADD THIS LINE (Initialize hybrid list) agent_citations_list = [] # <--- ADD THIS LINE (Initialize agent citations list) + web_search_citations_list = [] system_messages_for_augmentation = [] # Collect system messages from search search_results = [] selected_agent = None # Initialize selected_agent early to prevent NameError @@ -172,6 +177,8 @@ def result_requires_message_reload(result: Any) -> bool: # Convert toggles from string -> bool if needed if isinstance(hybrid_search_enabled, str): hybrid_search_enabled = hybrid_search_enabled.lower() == 'true' + if isinstance(web_search_enabled, str): + web_search_enabled = web_search_enabled.lower() == 'true' if isinstance(image_gen_enabled, str): image_gen_enabled = image_gen_enabled.lower() == 'true' @@ -262,7 +269,7 @@ def result_requires_message_reload(result: Any) -> bool: debug_print(f"Error initializing GPT client/model: {e}") # Handle error appropriately - maybe return 500 or default behavior return jsonify({'error': f'Failed to initialize AI model: {str(e)}'}), 500 - + # region 1 - Load or Create Conversation # --------------------------------------------------------------------- # 1) Load or create conversation # --------------------------------------------------------------------- @@ -356,7 +363,7 @@ def result_requires_message_reload(result: Any) -> bool: elif document_scope == 'public': actual_chat_type = 'public' debug_print(f"New conversation - using legacy logic: {actual_chat_type}") - + # region 2 - Append User Message # --------------------------------------------------------------------- # 2) Append the user message to conversation immediately (or use existing for retry) # --------------------------------------------------------------------- @@ -406,7 +413,8 @@ def result_requires_message_reload(result: Any) -> bool: # Button states and selections user_metadata['button_states'] = { 'image_generation': image_gen_enabled, - 'document_search': hybrid_search_enabled + 'document_search': hybrid_search_enabled, + 'web_search': bool(web_search_enabled) } # Document search scope and selections @@ -635,7 +643,7 @@ def result_requires_message_reload(result: Any) -> bool: conversation_item['last_updated'] = datetime.utcnow().isoformat() cosmos_conversations_container.upsert_item(conversation_item) # Update timestamp and potentially title - + # region 3 - Content Safety # --------------------------------------------------------------------- # 3) Check Content Safety (but DO NOT return 403). # If blocked, add a "safety" role message & skip GPT. @@ -741,7 +749,7 @@ def result_requires_message_reload(result: Any) -> bool: debug_print(f"[Content Safety Error] {e}") except Exception as ex: debug_print(f"[Content Safety] Unexpected error: {ex}") - + # region 4 - Augmentation # --------------------------------------------------------------------- # 4) Augmentation (Search, etc.) - Run *before* final history prep # --------------------------------------------------------------------- @@ -1449,6 +1457,24 @@ def result_requires_message_reload(result: Any) -> bool: 'error': user_friendly_message }), status_code + if web_search_enabled: + perform_web_search( + settings=settings, + conversation_id=conversation_id, + user_id=user_id, + user_message=user_message, + user_message_id=user_message_id, + chat_type=chat_type, + document_scope=document_scope, + active_group_id=active_group_id, + active_public_workspace_id=active_public_workspace_id, + search_query=search_query, + system_messages_for_augmentation=system_messages_for_augmentation, + agent_citations_list=agent_citations_list, + web_search_citations_list=web_search_citations_list, + ) + + # region 5 - FINAL conversation history preparation # --------------------------------------------------------------------- # 5) Prepare FINAL conversation history for GPT (including summarization) # --------------------------------------------------------------------- @@ -1728,6 +1754,7 @@ def result_requires_message_reload(result: Any) -> bool: debug_print(f"Error preparing conversation history: {e}") return jsonify({'error': f'Error preparing conversation history: {str(e)}'}), 500 + # region 6 - Final GPT Call # --------------------------------------------------------------------- # 6) Final GPT Call # --------------------------------------------------------------------- @@ -2153,12 +2180,101 @@ def agent_error(e): level=logging.ERROR, exceptionTraceback=True ) - fallback_steps.append({ - 'name': 'agent', - 'func': invoke_selected_agent, - 'on_success': agent_success, - 'on_error': agent_error - }) + + selected_agent_type = getattr(selected_agent, 'agent_type', 'local') or 'local' + if isinstance(selected_agent_type, str): + selected_agent_type = selected_agent_type.lower() + + if selected_agent_type == 'aifoundry': + def invoke_foundry_agent(): + foundry_metadata = { + 'conversation_id': conversation_id, + 'user_id': user_id, + 'message_id': user_message_id, + 'chat_type': chat_type, + 'document_scope': document_scope, + 'group_id': active_group_id if chat_type == 'group' else None, + 'hybrid_search_enabled': hybrid_search_enabled, + 'selected_document_id': selected_document_id, + 'search_query': search_query, + } + return selected_agent.invoke( + agent_message_history, + metadata={k: v for k, v in foundry_metadata.items() if v is not None} + ) + + def foundry_agent_success(result): + msg = str(result) + notice = None + agent_used = getattr(selected_agent, 'name', 'Azure AI Foundry Agent') + actual_model_deployment = ( + getattr(selected_agent, 'last_run_model', None) + or getattr(selected_agent, 'deployment_name', None) + or agent_used + ) + + foundry_citations = getattr(selected_agent, 'last_run_citations', []) or [] + if foundry_citations: + for citation in foundry_citations: + try: + serializable = json.loads(json.dumps(citation, default=str)) + except (TypeError, ValueError): + serializable = {'value': str(citation)} + agent_citations_list.append({ + 'tool_name': agent_used, + 'function_name': 'azure_ai_foundry_citation', + 'plugin_name': 'azure_ai_foundry', + 'function_arguments': serializable, + 'function_result': serializable, + 'timestamp': datetime.utcnow().isoformat(), + 'success': True + }) + + if enable_multi_agent_orchestration and not per_user_semantic_kernel: + notice = ( + "[SK Fallback]: The AI assistant is running in single agent fallback mode. " + "Some advanced features may not be available. " + "Please contact your administrator to configure Semantic Kernel for richer responses." + ) + + log_event( + f"[Foundry Agent] Invocation complete for {agent_used}", + extra={ + 'conversation_id': conversation_id, + 'user_id': user_id, + 'agent_id': getattr(selected_agent, 'id', None), + 'model_used': actual_model_deployment, + 'citation_count': len(foundry_citations), + } + ) + + return (msg, actual_model_deployment, 'agent', notice) + + def foundry_agent_error(e): + log_event( + f"Error during Azure AI Foundry agent invocation: {str(e)}", + extra={ + 'conversation_id': conversation_id, + 'user_id': user_id, + 'agent_id': getattr(selected_agent, 'id', None) + }, + level=logging.ERROR, + exceptionTraceback=True + ) + + fallback_steps.append({ + 'name': 'foundry_agent', + 'func': invoke_foundry_agent, + 'on_success': foundry_agent_success, + 'on_error': foundry_agent_error + }) + else: + fallback_steps.append({ + 'name': 'agent', + 'func': invoke_selected_agent, + 'on_success': agent_success, + 'on_error': agent_error + }) if kernel: def invoke_kernel(): @@ -2342,7 +2458,7 @@ def gpt_error(e): exceptionTraceback=True ) - + # region 7 - Save GPT Response # --------------------------------------------------------------------- # 7) Save GPT response (or error message) # --------------------------------------------------------------------- @@ -2390,6 +2506,7 @@ def gpt_error(e): 'timestamp': datetime.utcnow().isoformat(), 'augmented': bool(system_messages_for_augmentation), 'hybrid_citations': hybrid_citations_list, # <--- SIMPLIFIED: Directly use the list + 'web_search_citations': web_search_citations_list, 'hybridsearch_query': search_query if hybrid_search_enabled and search_results else None, # Log query only if hybrid search ran and found results 'agent_citations': agent_citations_list, # <--- NEW: Store agent tool invocation results 'user_message': user_message, @@ -2521,6 +2638,7 @@ def gpt_error(e): 'blocked': False, # Explicitly false if we got this far 'augmented': bool(system_messages_for_augmentation), 'hybrid_citations': hybrid_citations_list, + 'web_search_citations': web_search_citations_list, 'agent_citations': agent_citations_list, 'reload_messages': reload_messages_required, 'kernel_fallback_notice': kernel_fallback_notice @@ -2580,6 +2698,7 @@ def generate(): user_message = data.get('message', '') conversation_id = data.get('conversation_id') hybrid_search_enabled = data.get('hybrid_search') + web_search_enabled = data.get('web_search_enabled') selected_document_id = data.get('selected_document_id') image_gen_enabled = data.get('image_generation') document_scope = data.get('doc_scope') @@ -2657,6 +2776,7 @@ def generate(): search_query = user_message hybrid_citations_list = [] agent_citations_list = [] + web_search_citations_list = [] system_messages_for_augmentation = [] search_results = [] selected_agent = None @@ -2670,6 +2790,8 @@ def generate(): # Convert toggles if isinstance(hybrid_search_enabled, str): hybrid_search_enabled = hybrid_search_enabled.lower() == 'true' + if isinstance(web_search_enabled, str): + web_search_enabled = web_search_enabled.lower() == 'true' # Initialize GPT client (simplified version) gpt_model = "" @@ -2716,7 +2838,7 @@ def generate(): credential = DefaultAzureCredential() token_provider = get_bearer_token_provider( credential, - "https://cognitiveservices.azure.com/.default" + cognitive_services_scope ) gpt_client = AzureOpenAI( api_version=api_version, @@ -2789,7 +2911,8 @@ def generate(): user_metadata['button_states'] = { 'image_generation': False, - 'document_search': hybrid_search_enabled + 'document_search': hybrid_search_enabled, + 'web_search': bool(web_search_enabled) } # Document search scope and selections @@ -3126,16 +3249,15 @@ def generate(): retrieved_content = "\n\n".join(retrieved_texts) system_prompt_search = f"""You are an AI assistant. Use the following retrieved document excerpts to answer the user's question. Cite sources using the format (Source: filename, Page: page number). + Retrieved Excerpts: + {retrieved_content} -Retrieved Excerpts: -{retrieved_content} - -Based *only* on the information provided above, answer the user's query. If the answer isn't in the excerpts, say so. + Based *only* on the information provided above, answer the user's query. If the answer isn't in the excerpts, say so. -Example -User: What is the policy on double dipping? -Assistant: The policy prohibits entities from using federal funds received through one program to apply for additional funds through another program, commonly known as 'double dipping' (Source: PolicyDocument.pdf, Page: 12) -""" + Example + User: What is the policy on double dipping? + Assistant: The policy prohibits entities from using federal funds received through one program to apply for additional funds through another program, commonly known as 'double dipping' (Source: PolicyDocument.pdf, Page: 12) + """ system_messages_for_augmentation.append({ 'role': 'system', @@ -3146,6 +3268,23 @@ def generate(): # Reorder hybrid citations list in descending order based on page_number hybrid_citations_list.sort(key=lambda x: x.get('page_number', 0), reverse=True) + if web_search_enabled: + perform_web_search( + settings=settings, + conversation_id=conversation_id, + user_id=user_id, + user_message=user_message, + user_message_id=user_message_id, + chat_type=chat_type, + document_scope=document_scope, + active_group_id=active_group_id, + active_public_workspace_id=active_public_workspace_id, + search_query=search_query, + system_messages_for_augmentation=system_messages_for_augmentation, + agent_citations_list=agent_citations_list, + web_search_citations_list=web_search_citations_list, + ) + # Update message chat type message_chat_type = None if hybrid_search_enabled and search_results and len(search_results) > 0: @@ -3529,6 +3668,7 @@ def make_json_serializable(obj): 'timestamp': datetime.utcnow().isoformat(), 'augmented': bool(system_messages_for_augmentation), 'hybrid_citations': hybrid_citations_list, + 'web_search_citations': web_search_citations_list, 'hybridsearch_query': search_query if hybrid_search_enabled and search_results else None, 'agent_citations': agent_citations_list, 'user_message': user_message, @@ -3619,6 +3759,7 @@ def make_json_serializable(obj): 'user_message_id': user_message_id, 'augmented': bool(system_messages_for_augmentation), 'hybrid_citations': hybrid_citations_list, + 'web_search_citations': web_search_citations_list, 'agent_citations': agent_citations_list, 'agent_display_name': agent_display_name_used if use_agent_streaming else None, 'agent_name': agent_name_used if use_agent_streaming else None, @@ -3642,6 +3783,7 @@ def make_json_serializable(obj): 'timestamp': datetime.utcnow().isoformat(), 'augmented': bool(system_messages_for_augmentation), 'hybrid_citations': hybrid_citations_list, + 'web_search_citations': web_search_citations_list, 'hybridsearch_query': search_query if hybrid_search_enabled and search_results else None, 'agent_citations': agent_citations_list, 'user_message': user_message, @@ -3889,4 +4031,414 @@ def remove_masked_content(content, masked_ranges): if start < end: result = result[:start] + result[end:] - return result \ No newline at end of file + return result + + +def _extract_web_search_citations_from_content(content: str) -> List[Dict[str, str]]: + if not content: + return [] + debug_print(f"[Citation Extraction] Extracting citations from:\n{content}\n") + + citations: List[Dict[str, str]] = [] + + markdown_pattern = re.compile(r"\[([^\]]+)\]\((https?://[^\s\)]+)(?:\s+\"([^\"]+)\")?\)") + html_pattern = re.compile( + r"]+href=\"(https?://[^\"]+)\"([^>]*)>(.*?)", + re.IGNORECASE | re.DOTALL, + ) + title_pattern = re.compile(r"title=\"([^\"]+)\"", re.IGNORECASE) + url_pattern = re.compile(r"https?://[^\s\)\]\">]+") + + occupied_spans: List[range] = [] + + for match in markdown_pattern.finditer(content): + text, url, title = match.groups() + url = (url or "").strip().rstrip(".,)") + if not url: + continue + display_title = (title or text or url).strip() + citations.append({"url": url, "title": display_title}) + occupied_spans.append(range(match.start(), match.end())) + + for match in html_pattern.finditer(content): + url, attrs, inner = match.groups() + url = (url or "").strip().rstrip(".,)") + if not url: + continue + title_match = title_pattern.search(attrs or "") + title = title_match.group(1) if title_match else None + inner_text = re.sub(r"<[^>]+>", "", inner or "").strip() + display_title = (title or inner_text or url).strip() + citations.append({"url": url, "title": display_title}) + occupied_spans.append(range(match.start(), match.end())) + + for match in url_pattern.finditer(content): + if any(match.start() in span for span in occupied_spans): + continue + url = (match.group(0) or "").strip().rstrip(".,)") + if not url: + continue + citations.append({"url": url, "title": url}) + debug_print(f"[Citation Extraction] Extracted {len(citations)} citations. - {citations}\n") + + return citations + + +def _extract_token_usage_from_metadata(metadata: Dict[str, Any]) -> Dict[str, int]: + if not isinstance(metadata, Mapping): + debug_print( + "[Web Search][Token Usage Extraction] Metadata is not a mapping. " + f"type={type(metadata)}" + ) + return {} + + usage = metadata.get("usage") + if not usage: + debug_print("[Web Search][Token Usage Extraction] No usage field found in metadata.") + return {} + + if isinstance(usage, str): + raw_usage = usage.strip() + if not raw_usage: + debug_print("[Web Search][Token Usage Extraction] Usage string was empty.") + return {} + try: + usage = json.loads(raw_usage) + except json.JSONDecodeError: + try: + usage = ast.literal_eval(raw_usage) + except (ValueError, SyntaxError): + debug_print( + "[Web Search][Token Usage Extraction] Failed to parse usage string." + ) + return {} + + if not isinstance(usage, Mapping): + debug_print( + "[Web Search][Token Usage Extraction] Usage is not a mapping. " + f"type={type(usage)}" + ) + return {} + + def to_int(value: Any) -> Optional[int]: + try: + return int(float(value)) + except (TypeError, ValueError): + return None + + total_tokens = to_int(usage.get("total_tokens")) + if total_tokens is None: + debug_print( + "[Web Search][Token Usage Extraction] total_tokens missing or invalid. " + f"usage={usage}" + ) + return {} + + prompt_tokens = to_int(usage.get("prompt_tokens")) or 0 + completion_tokens = to_int(usage.get("completion_tokens")) or 0 + debug_print( + "[Web Search][Token Usage Extraction] Extracted token usage - " + f"prompt: {prompt_tokens}, completion: {completion_tokens}, total: {total_tokens}" + ) + + return { + "total_tokens": int(total_tokens), + "prompt_tokens": int(prompt_tokens), + "completion_tokens": int(completion_tokens), + } + +def perform_web_search( + *, + settings, + conversation_id, + user_id, + user_message, + user_message_id, + chat_type, + document_scope, + active_group_id, + active_public_workspace_id, + search_query, + system_messages_for_augmentation, + agent_citations_list, + web_search_citations_list, +): + debug_print("[WebSearch] ========== ENTERING perform_web_search ==========") + debug_print(f"[WebSearch] Parameters received:") + debug_print(f"[WebSearch] conversation_id: {conversation_id}") + debug_print(f"[WebSearch] user_id: {user_id}") + debug_print(f"[WebSearch] user_message: {user_message[:100] if user_message else None}...") + debug_print(f"[WebSearch] user_message_id: {user_message_id}") + debug_print(f"[WebSearch] chat_type: {chat_type}") + debug_print(f"[WebSearch] document_scope: {document_scope}") + debug_print(f"[WebSearch] active_group_id: {active_group_id}") + debug_print(f"[WebSearch] active_public_workspace_id: {active_public_workspace_id}") + debug_print(f"[WebSearch] search_query: {search_query[:100] if search_query else None}...") + + enable_web_search = settings.get("enable_web_search") + debug_print(f"[WebSearch] enable_web_search setting: {enable_web_search}") + + if not enable_web_search: + debug_print("[WebSearch] Web search is DISABLED in settings, returning early") + return True # Not an error, just disabled + + debug_print("[WebSearch] Web search is ENABLED, proceeding...") + + web_search_agent = settings.get("web_search_agent") or {} + debug_print(f"[WebSearch] web_search_agent config present: {bool(web_search_agent)}") + if web_search_agent: + # Avoid logging sensitive data, just log structure + debug_print(f"[WebSearch] web_search_agent keys: {list(web_search_agent.keys())}") + + other_settings = web_search_agent.get("other_settings") or {} + debug_print(f"[WebSearch] other_settings keys: {list(other_settings.keys()) if other_settings else ''}") + + foundry_settings = other_settings.get("azure_ai_foundry") or {} + debug_print(f"[WebSearch] foundry_settings present: {bool(foundry_settings)}") + if foundry_settings: + # Log only non-sensitive keys + safe_keys = ['agent_id', 'project_id', 'endpoint'] + safe_info = {k: foundry_settings.get(k, '') for k in safe_keys} + debug_print(f"[WebSearch] foundry_settings (safe keys): {safe_info}") + + agent_id = (foundry_settings.get("agent_id") or "").strip() + debug_print(f"[WebSearch] Extracted agent_id: '{agent_id}'") + + if not agent_id: + log_event( + "[WebSearch] Skipping Foundry web search: agent_id is not configured", + extra={ + "conversation_id": conversation_id, + "user_id": user_id, + }, + level=logging.WARNING, + ) + debug_print("[WebSearch] Foundry agent_id not configured, skipping web search.") + # Add failure message so the model knows search was requested but not configured + system_messages_for_augmentation.append({ + "role": "system", + "content": "Web search was requested but is not properly configured. Please inform the user that web search is currently unavailable and you cannot provide real-time information. Do not attempt to answer questions requiring current information from your training data.", + }) + return False # Configuration error + + debug_print(f"[WebSearch] Agent ID is configured: {agent_id}") + + query_text = None + try: + query_text = search_query + debug_print(f"[WebSearch] Using search_query as query_text: {query_text[:100] if query_text else None}...") + except NameError: + query_text = None + debug_print("[WebSearch] search_query not defined, query_text is None") + + query_text = (query_text or user_message or "").strip() + debug_print(f"[WebSearch] Final query_text after fallback: '{query_text[:100] if query_text else ''}'") + + if not query_text: + debug_print("[WebSearch] Query text is EMPTY after processing, skipping web search") + log_event( + "[WebSearch] Skipping Foundry web search: empty query", + extra={ + "conversation_id": conversation_id, + "user_id": user_id, + }, + level=logging.WARNING, + ) + return True # Not an error, just empty query + + debug_print(f"[WebSearch] Building message history with query: {query_text[:100]}...") + message_history = [ + ChatMessageContent(role="user", content=query_text) + ] + debug_print(f"[WebSearch] Message history created with {len(message_history)} message(s)") + + try: + foundry_metadata = { + "conversation_id": conversation_id, + "user_id": user_id, + "message_id": user_message_id, + "chat_type": chat_type, + "document_scope": document_scope, + "group_id": active_group_id if chat_type == "group" else None, + "public_workspace_id": active_public_workspace_id, + "search_query": query_text, + } + debug_print(f"[WebSearch] Foundry metadata prepared: {json.dumps(foundry_metadata, default=str)}") + + debug_print("[WebSearch] Calling execute_foundry_agent...") + debug_print(f"[WebSearch] foundry_settings keys: {list(foundry_settings.keys())}") + debug_print(f"[WebSearch] global_settings type: {type(settings)}") + + result = asyncio.run( + execute_foundry_agent( + foundry_settings=foundry_settings, + global_settings=settings, + message_history=message_history, + metadata={k: v for k, v in foundry_metadata.items() if v is not None}, + ) + ) + except FoundryAgentInvocationError as exc: + log_event( + f"[WebSearch] Foundry agent invocation failed: {exc}", + extra={ + "conversation_id": conversation_id, + "user_id": user_id, + "agent_id": agent_id, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + # Add failure message so the model informs the user + system_messages_for_augmentation.append({ + "role": "system", + "content": f"Web search failed with error: {exc}. Please inform the user that the web search encountered an error and you cannot provide real-time information for this query. Do not attempt to answer questions requiring current information from your training data - instead, acknowledge the search failure and suggest the user try again.", + }) + return False # Search failed + except Exception as exc: + log_event( + f"[WebSearch] Unexpected error invoking Foundry agent: {exc}", + extra={ + "conversation_id": conversation_id, + "user_id": user_id, + "agent_id": agent_id, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + # Add failure message so the model informs the user + system_messages_for_augmentation.append({ + "role": "system", + "content": f"Web search failed with an unexpected error: {exc}. Please inform the user that the web search encountered an error and you cannot provide real-time information for this query. Do not attempt to answer questions requiring current information from your training data - instead, acknowledge the search failure and suggest the user try again.", + }) + return False # Search failed + + debug_print("[WebSearch] ========== FOUNDRY AGENT RESULT ==========") + debug_print(f"[WebSearch] Result type: {type(result)}") + debug_print(f"[WebSearch] Result has message: {bool(result.message)}") + debug_print(f"[WebSearch] Result has citations: {bool(result.citations)}") + debug_print(f"[WebSearch] Result has metadata: {bool(result.metadata)}") + debug_print(f"[WebSearch] Result model: {getattr(result, 'model', 'N/A')}") + + if result.message: + debug_print(f"[WebSearch] Result message length: {len(result.message)} chars") + debug_print(f"[WebSearch] Result message preview: {result.message[:500] if len(result.message) > 500 else result.message}") + else: + debug_print("[WebSearch] Result message is EMPTY or None") + + if result.citations: + debug_print(f"[WebSearch] Result citations count: {len(result.citations)}") + for i, cit in enumerate(result.citations[:3]): + debug_print(f"[WebSearch] Citation {i}: {json.dumps(cit, default=str)[:200]}...") + else: + debug_print("[WebSearch] Result citations is EMPTY or None") + + if result.metadata: + try: + metadata_payload = json.dumps(result.metadata, default=str) + except (TypeError, ValueError): + metadata_payload = str(result.metadata) + debug_print(f"[WebSearch] Foundry metadata: {metadata_payload}") + else: + debug_print("[WebSearch] Foundry metadata: ") + + if result.message: + debug_print("[WebSearch] Adding result message to system_messages_for_augmentation") + system_messages_for_augmentation.append({ + "role": "system", + "content": f"Web search results:\n{result.message}", + }) + debug_print(f"[WebSearch] Added system message to augmentation list. Total augmentation messages: {len(system_messages_for_augmentation)}") + + debug_print("[WebSearch] Extracting web citations from result message...") + web_citations = _extract_web_search_citations_from_content(result.message) + debug_print(f"[WebSearch] Extracted {len(web_citations)} web citations from message content") + if web_citations: + web_search_citations_list.extend(web_citations) + debug_print(f"[WebSearch] Total web_search_citations_list now has {len(web_search_citations_list)} citations") + else: + debug_print("[WebSearch] No web citations extracted from message content") + else: + debug_print("[WebSearch] No result.message to process for augmentation") + + citations = result.citations or [] + debug_print(f"[WebSearch] Processing {len(citations)} citations from result.citations") + if citations: + for i, citation in enumerate(citations): + debug_print(f"[WebSearch] Processing citation {i}: {json.dumps(citation, default=str)[:200]}...") + try: + serializable = json.loads(json.dumps(citation, default=str)) + except (TypeError, ValueError): + serializable = {"value": str(citation)} + citation_title = serializable.get("title") or serializable.get("url") or "Web search source" + debug_print(f"[WebSearch] Adding agent citation with title: {citation_title}") + agent_citations_list.append({ + "tool_name": citation_title, + "function_name": "azure_ai_foundry_web_search", + "plugin_name": "azure_ai_foundry", + "function_arguments": serializable, + "function_result": serializable, + "timestamp": datetime.utcnow().isoformat(), + "success": True, + }) + debug_print(f"[WebSearch] Total agent_citations_list now has {len(agent_citations_list)} citations") + else: + debug_print("[WebSearch] No citations in result.citations to process") + + debug_print(f"[WebSearch] Starting token usage extraction from Foundry metadata. Metadata: {result.metadata}") + token_usage = _extract_token_usage_from_metadata(result.metadata or {}) + if token_usage.get("total_tokens"): + try: + workspace_type = 'personal' + if active_public_workspace_id: + workspace_type = 'public' + elif active_group_id: + workspace_type = 'group' + + log_token_usage( + user_id=user_id, + token_type='web_search', + total_tokens=token_usage.get('total_tokens', 0), + model=result.model or 'azure-ai-foundry-web-search', + workspace_type=workspace_type, + prompt_tokens=token_usage.get('prompt_tokens'), + completion_tokens=token_usage.get('completion_tokens'), + conversation_id=conversation_id, + message_id=user_message_id, + group_id=active_group_id, + public_workspace_id=active_public_workspace_id, + additional_context={ + 'agent_id': agent_id, + 'search_query': query_text, + 'token_source': 'foundry_metadata' + } + ) + except Exception as log_error: + log_event( + f"[WebSearch] Failed to log web search token usage: {log_error}", + extra={ + "conversation_id": conversation_id, + "user_id": user_id, + "agent_id": agent_id, + }, + level=logging.WARNING, + ) + + debug_print("[WebSearch] ========== FINAL SUMMARY ==========") + debug_print(f"[WebSearch] system_messages_for_augmentation count: {len(system_messages_for_augmentation)}") + debug_print(f"[WebSearch] agent_citations_list count: {len(agent_citations_list)}") + debug_print(f"[WebSearch] web_search_citations_list count: {len(web_search_citations_list)}") + debug_print(f"[WebSearch] Token usage extracted: {token_usage}") + debug_print("[WebSearch] ========== EXITING perform_web_search ==========") + + log_event( + "[WebSearch] Foundry web search invocation complete", + extra={ + "conversation_id": conversation_id, + "user_id": user_id, + "agent_id": agent_id, + "citation_count": len(citations), + }, + level=logging.INFO, + ) + + return True # Search succeeded \ No newline at end of file diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py index 0e5bcc290..2c3952f1f 100644 --- a/application/single_app/route_backend_control_center.py +++ b/application/single_app/route_backend_control_center.py @@ -1355,7 +1355,8 @@ def get_activity_trends_data(start_date, end_date): date_key = current_date.strftime('%Y-%m-%d') token_daily_data[date_key] = { 'embedding': 0, - 'chat': 0 + 'chat': 0, + 'web_search': 0 } current_date += timedelta(days=1) @@ -1364,7 +1365,7 @@ def get_activity_trends_data(start_date, end_date): token_type = token_record.get('token_type', '') token_count = token_record.get('token_count', 0) - if timestamp and token_type in ['embedding', 'chat']: + if timestamp and token_type in ['embedding', 'chat', 'web_search']: try: if isinstance(timestamp, str): token_date = datetime.fromisoformat(timestamp.replace('Z', '+00:00') if 'Z' in timestamp else timestamp) @@ -1387,7 +1388,7 @@ def get_activity_trends_data(start_date, end_date): current_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) while current_date <= end_date: date_key = current_date.strftime('%Y-%m-%d') - token_daily_data[date_key] = {'embedding': 0, 'chat': 0} + token_daily_data[date_key] = {'embedding': 0, 'chat': 0, 'web_search': 0} current_date += timedelta(days=1) # Calculate totals for each day diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index edd53dbd0..01d448b58 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -2,6 +2,7 @@ import re import builtins +import json from flask import Blueprint, jsonify, request, current_app from semantic_kernel_plugins.plugin_loader import get_all_plugin_metadata from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery @@ -802,6 +803,58 @@ def merge_plugin_settings(plugin_type): merged = get_merged_plugin_settings(plugin_type, current_settings, schema_dir) return jsonify(merged) + +@bpap.route('/api/plugins//auth-types', methods=['GET']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def get_plugin_auth_types(plugin_type): + """ + Returns allowed auth types for a plugin type. Uses definition file if present, + otherwise falls back to AuthType enum in plugin.schema.json. + """ + schema_dir = os.path.join(current_app.root_path, 'static', 'json', 'schemas') + safe_type = re.sub(r'[^a-zA-Z0-9_]', '_', plugin_type).lower() + + definition_path = os.path.join(schema_dir, f'{safe_type}.definition.json') + schema_path = os.path.join(schema_dir, 'plugin.schema.json') + + allowed_auth_types = [] + source = "schema" + + try: + with open(schema_path, 'r', encoding='utf-8') as schema_file: + schema = json.load(schema_file) + allowed_auth_types = ( + schema + .get('definitions', {}) + .get('AuthType', {}) + .get('enum', []) + ) + except Exception as exc: + debug_print(f"Failed to read plugin.schema.json: {exc}") + allowed_auth_types = [] + + if os.path.exists(definition_path): + try: + with open(definition_path, 'r', encoding='utf-8') as definition_file: + definition = json.load(definition_file) + allowed_from_definition = definition.get('allowedAuthTypes') + if isinstance(allowed_from_definition, list) and allowed_from_definition: + allowed_auth_types = allowed_from_definition + source = "definition" + except Exception as exc: + debug_print(f"Failed to read {definition_path}: {exc}") + + if not allowed_auth_types: + allowed_auth_types = [] + source = "schema" + + return jsonify({ + "allowedAuthTypes": allowed_auth_types, + "source": source + }) + ########################################################################################################## # Dynamic Plugin Metadata Endpoint diff --git a/application/single_app/route_backend_retention_policy.py b/application/single_app/route_backend_retention_policy.py index 70d5cc76d..60935f609 100644 --- a/application/single_app/route_backend_retention_policy.py +++ b/application/single_app/route_backend_retention_policy.py @@ -3,7 +3,8 @@ from config import * from functions_authentication import * from functions_settings import * -from functions_retention_policy import execute_retention_policy +from functions_retention_policy import execute_retention_policy, get_all_user_settings, get_all_groups, get_all_public_workspaces +from functions_activity_logging import log_retention_policy_force_push from swagger_wrapper import swagger_route, get_auth_security from functions_debug import debug_print @@ -106,6 +107,75 @@ def update_retention_policy_settings(): }), 500 + @app.route('/api/retention-policy/defaults/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def get_retention_policy_defaults(workspace_type): + """ + Get organization default retention policy settings for a specific workspace type. + + Args: + workspace_type: One of 'personal', 'group', or 'public' + + Returns: + JSON with default_conversation_days and default_document_days for the workspace type + """ + try: + # Validate workspace type + if workspace_type not in ['personal', 'group', 'public']: + return jsonify({ + 'success': False, + 'error': f'Invalid workspace type: {workspace_type}' + }), 400 + + settings = get_settings() + + # Get the default values for the specified workspace type + default_conversation = settings.get(f'default_retention_conversation_{workspace_type}', 'none') + default_document = settings.get(f'default_retention_document_{workspace_type}', 'none') + + # Get human-readable labels for the values + def get_retention_label(value): + if value == 'none' or value is None: + return 'No automatic deletion' + try: + days = int(value) + if days == 1: + return '1 day' + elif days == 21: + return '21 days (3 weeks)' + elif days == 90: + return '90 days (3 months)' + elif days == 180: + return '180 days (6 months)' + elif days == 365: + return '365 days (1 year)' + elif days == 730: + return '730 days (2 years)' + else: + return f'{days} days' + except (ValueError, TypeError): + return 'No automatic deletion' + + return jsonify({ + 'success': True, + 'workspace_type': workspace_type, + 'default_conversation_days': default_conversation, + 'default_document_days': default_document, + 'default_conversation_label': get_retention_label(default_conversation), + 'default_document_label': get_retention_label(default_document) + }) + + except Exception as e: + debug_print(f"Error fetching retention policy defaults: {e}") + log_event(f"Fetching retention policy defaults failed: {e}", level=logging.ERROR) + return jsonify({ + 'success': False, + 'error': 'Failed to fetch retention policy defaults' + }), 500 + + @app.route('/api/admin/retention-policy/execute', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required @@ -155,6 +225,165 @@ def manual_execute_retention_policy(): }), 500 + @app.route('/api/admin/retention-policy/force-push', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def force_push_retention_defaults(): + """ + Force push organization default retention policies to all users/groups/workspaces. + This resets all custom retention policies to use the organization default ('default' value). + + Body: + scopes (list): List of workspace types to push defaults to: 'personal', 'group', 'public' + """ + try: + data = request.get_json() + scopes = data.get('scopes', []) + + if not scopes: + return jsonify({ + 'success': False, + 'error': 'No workspace scopes provided' + }), 400 + + # Validate scopes + valid_scopes = ['personal', 'group', 'public'] + invalid_scopes = [s for s in scopes if s not in valid_scopes] + if invalid_scopes: + return jsonify({ + 'success': False, + 'error': f'Invalid workspace scopes: {", ".join(invalid_scopes)}' + }), 400 + + details = {} + total_updated = 0 + + # Force push to personal workspaces (user settings) + if 'personal' in scopes: + debug_print("Force pushing retention defaults to personal workspaces...") + all_users = get_all_user_settings() + personal_count = 0 + + for user in all_users: + user_id = user.get('id') + if not user_id: + continue + + try: + # Update user's retention policy to use 'default' + user_settings = user.get('settings', {}) + user_settings['retention_policy'] = { + 'conversation_retention_days': 'default', + 'document_retention_days': 'default' + } + user['settings'] = user_settings + + cosmos_user_settings_container.upsert_item(user) + personal_count += 1 + except Exception as e: + debug_print(f"Error updating user {user_id}: {e}") + log_event(f"Error updating user {user_id} during force push: {e}", level=logging.ERROR) + continue + + details['personal'] = personal_count + total_updated += personal_count + debug_print(f"Updated {personal_count} personal workspaces") + + # Force push to group workspaces + if 'group' in scopes: + debug_print("Force pushing retention defaults to group workspaces...") + from functions_group import cosmos_groups_container + all_groups = get_all_groups() + group_count = 0 + + for group in all_groups: + group_id = group.get('id') + if not group_id: + continue + + try: + # Update group's retention policy to use 'default' + group['retention_policy'] = { + 'conversation_retention_days': 'default', + 'document_retention_days': 'default' + } + + cosmos_groups_container.upsert_item(group) + group_count += 1 + except Exception as e: + debug_print(f"Error updating group {group_id}: {e}") + log_event(f"Error updating group {group_id} during force push: {e}", level=logging.ERROR) + continue + + details['group'] = group_count + total_updated += group_count + debug_print(f"Updated {group_count} group workspaces") + + # Force push to public workspaces + if 'public' in scopes: + debug_print("Force pushing retention defaults to public workspaces...") + from functions_public_workspaces import cosmos_public_workspaces_container + all_workspaces = get_all_public_workspaces() + public_count = 0 + + for workspace in all_workspaces: + workspace_id = workspace.get('id') + if not workspace_id: + continue + + try: + # Update workspace's retention policy to use 'default' + workspace['retention_policy'] = { + 'conversation_retention_days': 'default', + 'document_retention_days': 'default' + } + + cosmos_public_workspaces_container.upsert_item(workspace) + public_count += 1 + except Exception as e: + debug_print(f"Error updating public workspace {workspace_id}: {e}") + log_event(f"Error updating public workspace {workspace_id} during force push: {e}", level=logging.ERROR) + continue + + details['public'] = public_count + total_updated += public_count + debug_print(f"Updated {public_count} public workspaces") + + # Log to activity logs for audit trail + admin_user_id = session.get('user', {}).get('oid', 'unknown') + admin_email = session.get('user', {}).get('preferred_username', session.get('user', {}).get('email', 'unknown')) + log_retention_policy_force_push( + admin_user_id=admin_user_id, + admin_email=admin_email, + scopes=scopes, + results=details, + total_updated=total_updated + ) + + log_event("retention_policy_force_push", { + "scopes": scopes, + "updated_count": total_updated, + "details": details + }) + + return jsonify({ + 'success': True, + 'message': f'Defaults pushed to {total_updated} items', + 'updated_count': total_updated, + 'scopes': scopes, + 'details': details + }) + + except Exception as e: + debug_print(f"Error force pushing retention defaults: {e}") + log_event(f"Force push retention defaults failed: {e}", level=logging.ERROR) + return jsonify({ + 'success': False, + 'error': f'Failed to push retention defaults' + }), 500 + + @app.route('/api/retention-policy/user', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/route_backend_user_agreement.py b/application/single_app/route_backend_user_agreement.py new file mode 100644 index 000000000..f46559fff --- /dev/null +++ b/application/single_app/route_backend_user_agreement.py @@ -0,0 +1,167 @@ +# route_backend_user_agreement.py + +from config import * +from functions_authentication import * +from functions_settings import get_settings +from functions_public_workspaces import find_public_workspace_by_id +from functions_activity_logging import log_user_agreement_accepted, has_user_accepted_agreement_today +from swagger_wrapper import swagger_route, get_auth_security +from functions_debug import debug_print + + +def register_route_backend_user_agreement(app): + """ + Register user agreement API endpoints under '/api/user_agreement/...' + These endpoints handle checking and recording user agreement acceptance. + """ + + @app.route("/api/user_agreement/check", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_check_user_agreement(): + """ + GET /api/user_agreement/check + Check if the current user needs to accept a user agreement for a workspace. + + Query params: + workspace_id: The workspace ID + workspace_type: The workspace type ('personal', 'group', 'public', 'chat') + action_context: The action context ('file_upload', 'chat') - optional + + Returns: + { + needsAgreement: bool, + agreementText: str (if needs agreement), + enableDailyAcceptance: bool + } + """ + info = get_current_user_info() + user_id = info["userId"] + + workspace_id = request.args.get("workspace_id") + workspace_type = request.args.get("workspace_type") + action_context = request.args.get("action_context", "file_upload") + + if not workspace_id or not workspace_type: + return jsonify({"error": "workspace_id and workspace_type are required"}), 400 + + # Validate workspace type + valid_types = ["personal", "group", "public", "chat"] + if workspace_type not in valid_types: + return jsonify({"error": f"Invalid workspace_type. Must be one of: {', '.join(valid_types)}"}), 400 + + # Get global user agreement settings from app settings + settings = get_settings() + + # Check if user agreement is enabled globally + if not settings.get("enable_user_agreement", False): + return jsonify({ + "needsAgreement": False, + "agreementText": "", + "enableDailyAcceptance": False + }), 200 + + apply_to = settings.get("user_agreement_apply_to", []) + + # Check if the agreement applies to this workspace type or action + applies = False + if workspace_type in apply_to: + applies = True + elif action_context == "chat" and "chat" in apply_to: + applies = True + + if not applies: + return jsonify({ + "needsAgreement": False, + "agreementText": "", + "enableDailyAcceptance": False + }), 200 + + # Check if daily acceptance is enabled and user already accepted today + enable_daily_acceptance = settings.get("enable_user_agreement_daily", False) + + if enable_daily_acceptance: + already_accepted = has_user_accepted_agreement_today(user_id, workspace_type, workspace_id) + if already_accepted: + debug_print(f"[USER_AGREEMENT] User {user_id} already accepted today for {workspace_type} workspace {workspace_id}") + return jsonify({ + "needsAgreement": False, + "agreementText": "", + "enableDailyAcceptance": True, + "alreadyAcceptedToday": True + }), 200 + + # User needs to accept the agreement + return jsonify({ + "needsAgreement": True, + "agreementText": settings.get("user_agreement_text", ""), + "enableDailyAcceptance": enable_daily_acceptance + }), 200 + + @app.route("/api/user_agreement/accept", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @user_required + def api_accept_user_agreement(): + """ + POST /api/user_agreement/accept + Record that a user has accepted the user agreement for a workspace. + + Body JSON: + { + workspace_id: str, + workspace_type: str ('personal', 'group', 'public'), + action_context: str (optional, e.g., 'file_upload', 'chat') + } + + Returns: + { success: bool, message: str } + """ + info = get_current_user_info() + user_id = info["userId"] + + data = request.get_json() or {} + workspace_id = data.get("workspace_id") + workspace_type = data.get("workspace_type") + action_context = data.get("action_context", "file_upload") + + if not workspace_id or not workspace_type: + return jsonify({"error": "workspace_id and workspace_type are required"}), 400 + + # Validate workspace type + valid_types = ["personal", "group", "public"] + if workspace_type not in valid_types: + return jsonify({"error": f"Invalid workspace_type. Must be one of: {', '.join(valid_types)}"}), 400 + + # Get workspace name for logging + workspace_name = None + if workspace_type == "public": + ws = find_public_workspace_by_id(workspace_id) + if ws: + workspace_name = ws.get("name", "") + + # Log the acceptance + try: + log_user_agreement_accepted( + user_id=user_id, + workspace_type=workspace_type, + workspace_id=workspace_id, + workspace_name=workspace_name, + action_context=action_context + ) + + debug_print(f"[USER_AGREEMENT] Recorded acceptance: user {user_id}, {workspace_type} workspace {workspace_id}") + + return jsonify({ + "success": True, + "message": "User agreement acceptance recorded" + }), 200 + + except Exception as e: + debug_print(f"[USER_AGREEMENT] Error recording acceptance: {str(e)}") + log_event(f"Error recording user agreement acceptance: {str(e)}", level=logging.ERROR) + return jsonify({ + "success": False, + "error": f"Failed to record acceptance: {str(e)}" + }), 500 diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index da45c965c..ae3619841 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -4,6 +4,7 @@ from functions_documents import * from functions_authentication import * from functions_settings import * +from functions_activity_logging import log_web_search_consent_acceptance from functions_logging import * from swagger_wrapper import swagger_route, get_auth_security from datetime import datetime, timedelta @@ -78,6 +79,9 @@ def admin_settings(): settings['per_user_semantic_kernel'] = False if 'enable_semantic_kernel' not in settings: settings['enable_semantic_kernel'] = False + + if 'web_search_consent_accepted' not in settings: + settings['web_search_consent_accepted'] = False # --- Add default for swagger documentation --- if 'enable_swagger' not in settings: @@ -135,6 +139,9 @@ def admin_settings(): 'name': 'default_agent', 'is_global': True } + log_event("Error retrieving global agents for default selection.", level=logging.ERROR) + debug_print("Error retrieving global agents for default selection.") + if 'allow_user_agents' not in settings: settings['allow_user_agents'] = False if 'allow_user_custom_agent_endpoints' not in settings: @@ -147,6 +154,12 @@ def admin_settings(): settings['allow_group_custom_agent_endpoints'] = False if 'allow_group_plugins' not in settings: settings['allow_group_plugins'] = False + if 'enable_agent_template_gallery' not in settings: + settings['enable_agent_template_gallery'] = True + if 'agent_templates_allow_user_submission' not in settings: + settings['agent_templates_allow_user_submission'] = True + if 'agent_templates_require_approval' not in settings: + settings['agent_templates_require_approval'] = True # --- Add defaults for classification banner --- if 'classification_banner_enabled' not in settings: @@ -158,6 +171,16 @@ def admin_settings(): if 'classification_banner_text_color' not in settings: settings['classification_banner_text_color'] = '#ffffff' # White text by default + # --- Add defaults for user agreement --- + if 'enable_user_agreement' not in settings: + settings['enable_user_agreement'] = False + if 'user_agreement_text' not in settings: + settings['user_agreement_text'] = '' + if 'user_agreement_apply_to' not in settings: + settings['user_agreement_apply_to'] = [] + if 'enable_user_agreement_daily' not in settings: + settings['enable_user_agreement_daily'] = False + # --- Add defaults for key vault if 'enable_key_vault_secret_storage' not in settings: settings['enable_key_vault_secret_storage'] = False @@ -190,7 +213,7 @@ def admin_settings(): pass # Replace with actual logic except Exception as e: print(f"Error retrieving GPT deployments: {e}") - # ... similar try/except for embedding and image models ... + log_event(f"Error retrieving GPT deployments: {e}", level=logging.ERROR) # Check for application updates current_version = app.config['VERSION'] @@ -233,6 +256,7 @@ def admin_settings(): settings.update(new_settings) except Exception as e: print(f"Error checking for updates: {e}") + log_event(f"Error checking for updates: {e}", level=logging.ERROR) # Get the persisted values for template rendering update_available = settings.get('update_available', False) @@ -258,6 +282,7 @@ def admin_settings(): if request.method == 'POST': form_data = request.form # Use a variable for easier access + user_id = get_current_user_id() # --- Fetch all other form data as before --- app_title = form_data.get('app_title', 'AI Chat Application') @@ -279,6 +304,33 @@ def admin_settings(): require_member_of_control_center_dashboard_reader = form_data.get('require_member_of_control_center_dashboard_reader') == 'on' require_member_of_feedback_admin = form_data.get('require_member_of_feedback_admin') == 'on' + web_search_consent_message = ( + "When you use Grounding with Bing Search, your customer data is transferred " + "outside of the Azure compliance boundary to the Grounding with Bing Search service. " + "Grounding with Bing Search is not subject to the same data processing terms " + "(including location of processing) and does not have the same compliance standards " + "and certifications as the Azure AI Agent Service, as described in the " + "Grounding with Bing Search TOU (https://www.microsoft.com/en-us/bing/apis/grounding-legal). " + "It is your responsibility to assess whether use of Grounding with Bing Search in your agent " + "meets your needs and requirements." + ) + web_search_consent_accepted = form_data.get('web_search_consent_accepted') == 'true' + requested_enable_web_search = form_data.get('enable_web_search') == 'on' + enable_web_search = requested_enable_web_search and web_search_consent_accepted + + if requested_enable_web_search and not web_search_consent_accepted: + flash('Web search requires consent before it can be enabled.', 'warning') + + if enable_web_search and web_search_consent_accepted and not settings.get('web_search_consent_accepted'): + admin_user = session.get('user', {}) + admin_email = admin_user.get('preferred_username', admin_user.get('email', 'unknown')) + log_web_search_consent_acceptance( + user_id=user_id, + admin_email=admin_email, + consent_text=web_search_consent_message, + source='admin_settings' + ) + # --- Handle Document Classification Toggle --- enable_document_classification = form_data.get('enable_document_classification') == 'on' @@ -367,19 +419,22 @@ def admin_settings(): except Exception as e: print(f"Error parsing gpt_model_json: {e}") flash('Error parsing GPT model data. Changes may not be saved.', 'warning') + log_event(f"Error parsing GPT model data: {e}", level=logging.ERROR) gpt_model_obj = settings.get('gpt_model', {'selected': [], 'all': []}) # Fallback - # ... similar try/except for embedding and image models ... + try: embedding_model_obj = json.loads(embedding_model_json) if embedding_model_json else {'selected': [], 'all': []} except Exception as e: print(f"Error parsing embedding_model_json: {e}") flash('Error parsing Embedding model data. Changes may not be saved.', 'warning') + log_event(f"Error parsing Embedding model data: {e}", level=logging.ERROR) embedding_model_obj = settings.get('embedding_model', {'selected': [], 'all': []}) # Fallback try: image_gen_model_obj = json.loads(image_gen_model_json) if image_gen_model_json else {'selected': [], 'all': []} except Exception as e: print(f"Error parsing image_gen_model_json: {e}") flash('Error parsing Image Gen model data. Changes may not be saved.', 'warning') + log_event(f"Error parsing Image Gen model data: {e}", level=logging.ERROR) image_gen_model_obj = settings.get('image_gen_model', {'selected': [], 'all': []}) # Fallback # --- Extract banner fields from form_data --- @@ -520,6 +575,14 @@ def admin_settings(): enable_retention_policy_public = form_data.get('enable_retention_policy_public') == 'on' retention_policy_execution_hour = int(form_data.get('retention_policy_execution_hour', 2)) + # Default retention policy values for each workspace type + default_retention_conversation_personal = form_data.get('default_retention_conversation_personal', 'none') + default_retention_document_personal = form_data.get('default_retention_document_personal', 'none') + default_retention_conversation_group = form_data.get('default_retention_conversation_group', 'none') + default_retention_document_group = form_data.get('default_retention_document_group', 'none') + default_retention_conversation_public = form_data.get('default_retention_conversation_public', 'none') + default_retention_document_public = form_data.get('default_retention_document_public', 'none') + # Validate execution hour (0-23) if retention_policy_execution_hour < 0 or retention_policy_execution_hour > 23: retention_policy_execution_hour = 2 # Default to 2 AM @@ -537,6 +600,28 @@ def admin_settings(): retention_policy_next_run = next_run.isoformat() + # --- User Agreement Settings --- + enable_user_agreement = form_data.get('enable_user_agreement') == 'on' + user_agreement_text = form_data.get('user_agreement_text', '').strip() + enable_user_agreement_daily = form_data.get('enable_user_agreement_daily') == 'on' + + # Build apply_to list from checkboxes + user_agreement_apply_to = [] + if form_data.get('user_agreement_apply_personal') == 'on': + user_agreement_apply_to.append('personal') + if form_data.get('user_agreement_apply_group') == 'on': + user_agreement_apply_to.append('group') + if form_data.get('user_agreement_apply_public') == 'on': + user_agreement_apply_to.append('public') + if form_data.get('user_agreement_apply_chat') == 'on': + user_agreement_apply_to.append('chat') + + # Validate word count (max 200 words) + if enable_user_agreement and user_agreement_text: + word_count = len(user_agreement_text.split()) + if word_count > 200: + flash('User Agreement text exceeds 200 word limit. Please shorten the text.', 'warning') + # --- Authentication & Redirect Settings --- enable_front_door = form_data.get('enable_front_door') == 'on' front_door_url = form_data.get('front_door_url', '').strip() @@ -586,6 +671,9 @@ def is_valid_url(url): 'enable_swagger': form_data.get('enable_swagger') == 'on', 'enable_semantic_kernel': form_data.get('enable_semantic_kernel') == 'on', 'per_user_semantic_kernel': form_data.get('per_user_semantic_kernel') == 'on', + 'enable_agent_template_gallery': form_data.get('enable_agent_template_gallery') == 'on', + 'agent_templates_allow_user_submission': form_data.get('agent_templates_allow_user_submission') == 'on', + 'agent_templates_require_approval': form_data.get('agent_templates_require_approval') == 'on', # GPT (Direct & APIM) 'enable_gpt_apim': form_data.get('enable_gpt_apim') == 'on', @@ -657,6 +745,18 @@ def is_valid_url(url): 'enable_retention_policy_public': enable_retention_policy_public, 'retention_policy_execution_hour': retention_policy_execution_hour, 'retention_policy_next_run': retention_policy_next_run, + 'default_retention_conversation_personal': default_retention_conversation_personal, + 'default_retention_document_personal': default_retention_document_personal, + 'default_retention_conversation_group': default_retention_conversation_group, + 'default_retention_document_group': default_retention_document_group, + 'default_retention_conversation_public': default_retention_conversation_public, + 'default_retention_document_public': default_retention_document_public, + + # User Agreement + 'enable_user_agreement': enable_user_agreement, + 'user_agreement_text': user_agreement_text, + 'user_agreement_apply_to': user_agreement_apply_to, + 'enable_user_agreement_daily': enable_user_agreement_daily, # Multimedia & Metadata 'enable_video_file_support': enable_video_file_support, @@ -706,11 +806,33 @@ def is_valid_url(url): 'enable_user_feedback': form_data.get('enable_user_feedback') == 'on', 'enable_conversation_archiving': form_data.get('enable_conversation_archiving') == 'on', - # Search (Web Search Direct & APIM) - 'enable_web_search': form_data.get('enable_web_search') == 'on', - 'enable_web_search_apim': form_data.get('enable_web_search_apim') == 'on', - 'azure_apim_web_search_endpoint': form_data.get('azure_apim_web_search_endpoint', '').strip(), - 'azure_apim_web_search_subscription_key': form_data.get('azure_apim_web_search_subscription_key', '').strip(), + # Search (Web Search via Azure AI Foundry agent) + 'enable_web_search': enable_web_search, + 'web_search_consent_accepted': web_search_consent_accepted, + 'enable_web_search_user_notice': form_data.get('enable_web_search_user_notice') == 'on', + 'web_search_user_notice_text': form_data.get('web_search_user_notice_text', 'Your message will be sent to Microsoft Bing for web search. Only your current message is sent, not your conversation history.').strip(), + 'web_search_agent': { + 'agent_type': 'aifoundry', + 'azure_openai_gpt_endpoint': form_data.get('web_search_foundry_endpoint', '').strip(), + 'azure_openai_gpt_api_version': form_data.get('web_search_foundry_api_version', '').strip(), + 'azure_openai_gpt_deployment': '', + 'other_settings': { + 'azure_ai_foundry': { + 'agent_id': form_data.get('web_search_foundry_agent_id', '').strip(), + 'endpoint': form_data.get('web_search_foundry_endpoint', '').strip(), + 'api_version': form_data.get('web_search_foundry_api_version', '').strip(), + 'authentication_type': form_data.get('web_search_foundry_auth_type', 'managed_identity').strip(), + 'managed_identity_type': form_data.get('web_search_foundry_managed_identity_type', 'system_assigned').strip(), + 'managed_identity_client_id': form_data.get('web_search_foundry_managed_identity_client_id', '').strip(), + 'tenant_id': form_data.get('web_search_foundry_tenant_id', '').strip(), + 'client_id': form_data.get('web_search_foundry_client_id', '').strip(), + 'client_secret': form_data.get('web_search_foundry_client_secret', '').strip(), + 'cloud': form_data.get('web_search_foundry_cloud', '').strip(), + 'authority': form_data.get('web_search_foundry_authority', '').strip(), + 'notes': form_data.get('web_search_foundry_notes', '').strip() + } + } + }, # Search (AI Search Direct & APIM) 'azure_ai_search_endpoint': form_data.get('azure_ai_search_endpoint', '').strip(), @@ -786,6 +908,16 @@ def is_valid_url(url): del new_settings['semantic_kernel_agents'] if 'semantic_kernel_plugins' in new_settings: del new_settings['semantic_kernel_plugins'] + + # Remove legacy web search keys if present + for legacy_key in [ + 'bing_search_key', + 'enable_web_search_apim', + 'azure_apim_web_search_endpoint', + 'azure_apim_web_search_subscription_key' + ]: + if legacy_key in new_settings: + del new_settings[legacy_key] logo_file = request.files.get('logo_file') if logo_file and allowed_file(logo_file.filename, ALLOWED_EXTENSIONS_IMG): @@ -866,7 +998,7 @@ def is_valid_url(url): except Exception as e: print(f"Error processing logo file: {e}") # Log the error for debugging flash(f"Error processing logo file: {e}. Existing logo preserved.", "danger") - # On error, new_settings['custom_logo_base64'] keeps its initial value (the old logo) + log_event(f"Error processing logo file: {e}", level=logging.ERROR) # Process dark mode logo file upload logo_dark_file = request.files.get('logo_dark_file') @@ -949,7 +1081,7 @@ def is_valid_url(url): except Exception as e: print(f"Error processing dark mode logo file: {e}") # Log the error for debugging flash(f"Error processing dark mode logo file: {e}. Existing dark mode logo preserved.", "danger") - # On error, new_settings['custom_logo_dark_base64'] keeps its initial value (the old logo) + log_event(f"Error processing dark mode logo file: {e}", level=logging.ERROR) # Process favicon file upload favicon_file = request.files.get('favicon_file') @@ -1023,7 +1155,7 @@ def is_valid_url(url): except Exception as e: print(f"Error processing favicon file: {e}") # Log the error for debugging flash(f"Error processing favicon file: {e}. Existing favicon preserved.", "danger") - # On error, new_settings['custom_favicon_base64'] keeps its initial value (the old favicon) + log_event(f"Error processing favicon file: {e}", level=logging.ERROR) # --- Update settings in DB --- # new_settings now contains either the new logo/favicon base64 or the original ones diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 0874fa203..35d359654 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -20,6 +20,7 @@ from semantic_kernel_plugins.embedding_model_plugin import EmbeddingModelPlugin from semantic_kernel_plugins.fact_memory_plugin import FactMemoryPlugin from functions_settings import get_settings, get_user_settings +from foundry_agent_runtime import AzureAIFoundryChatCompletionAgent from functions_appinsights import log_event, get_appinsights_logger from functions_authentication import get_current_user_id from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery @@ -106,6 +107,7 @@ def resolve_agent_config(agent, settings): debug_print(f"[SK Loader] Agent is_group flag: {agent.get('is_group')}") agent_type = (agent.get('agent_type') or 'local').lower() agent['agent_type'] = agent_type + other_settings = agent.get("other_settings", {}) or {} gpt_model_obj = settings.get('gpt_model', {}) selected_model = gpt_model_obj.get('selected', [{}])[0] if gpt_model_obj.get('selected') else {} @@ -231,6 +233,22 @@ def merge_fields(primary, fallback): return tuple(p if p not in [None, ""] else f for p, f in zip(primary, fallback)) # If per-user mode is not enabled, ignore all user/agent-specific config fields + if agent_type == "aifoundry": + return { + "name": agent.get("name"), + "display_name": agent.get("display_name", agent.get("name")), + "description": agent.get("description", ""), + "id": agent.get("id", ""), + "default_agent": agent.get("default_agent", False), + "is_global": agent.get("is_global", False), + "is_group": agent.get("is_group", False), + "group_id": agent.get("group_id"), + "group_name": agent.get("group_name"), + "agent_type": "aifoundry", + "other_settings": other_settings, + "max_completion_tokens": agent.get("max_completion_tokens", -1), + } + if not per_user_enabled: try: if global_apim_enabled: @@ -258,7 +276,8 @@ def merge_fields(primary, fallback): "group_name": agent.get("group_name"), "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False), "max_completion_tokens": agent.get("max_completion_tokens", -1), - "agent_type": agent_type or "local" + "agent_type": agent_type or "local", + "other_settings": other_settings, } except Exception as e: log_event(f"[SK Loader] Error resolving agent config: {e}", level=logging.ERROR, exceptionTraceback=True) @@ -317,6 +336,7 @@ def merge_fields(primary, fallback): "enable_agent_gpt_apim": agent.get("enable_agent_gpt_apim", False), # Use this to check if APIM is enabled for the agent "max_completion_tokens": agent.get("max_completion_tokens", -1), # -1 meant use model default determined by the service, 35-trubo is 4096, 4o is 16384, 4.1 is at least 32768 "agent_type": agent_type or "local", + "other_settings": other_settings, } print(f"[SK Loader] Final resolved config for {agent.get('name')}: endpoint={bool(endpoint)}, key={bool(key)}, deployment={deployment}") @@ -722,6 +742,20 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis chat_service = None apim_enabled = settings.get("enable_gpt_apim", False) + if agent_type == "aifoundry": + foundry_agent = AzureAIFoundryChatCompletionAgent(agent_config, settings) + agent_objs[agent_config["name"]] = foundry_agent + log_event( + f"[SK Loader] Registered Foundry agent: {agent_config['name']} ({mode_label})", + { + "agent_name": agent_config["name"], + "agent_id": agent_config.get("id"), + "is_global": agent_config.get("is_global", False), + }, + level=logging.INFO, + ) + return kernel, agent_objs + log_event(f"[SK Loader] Agent config resolved for {agent_cfg.get('name')} - endpoint: {bool(agent_config.get('endpoint'))}, key: {bool(agent_config.get('key'))}, deployment: {agent_config.get('deployment')}, max_completion_tokens: {agent_config.get('max_completion_tokens')}", level=logging.INFO) if AzureChatCompletion and agent_config["endpoint"] and agent_config["key"] and agent_config["deployment"]: diff --git a/application/single_app/semantic_kernel_plugins/smart_http_plugin.py b/application/single_app/semantic_kernel_plugins/smart_http_plugin.py index f52096852..2292e7bc8 100644 --- a/application/single_app/semantic_kernel_plugins/smart_http_plugin.py +++ b/application/single_app/semantic_kernel_plugins/smart_http_plugin.py @@ -560,6 +560,7 @@ async def _summarize_large_content(self, content: str, uri: str, page_count: int from functions_settings import get_settings from openai import AzureOpenAI from azure.identity import DefaultAzureCredential, get_bearer_token_provider + from config import cognitive_services_scope settings = get_settings() @@ -580,7 +581,6 @@ async def _summarize_large_content(self, content: str, uri: str, page_count: int ) else: if settings.get('azure_openai_gpt_authentication_type') == 'managed_identity': - cognitive_services_scope = "https://cognitiveservices.azure.com/.default" token_provider = get_bearer_token_provider( DefaultAzureCredential(), cognitive_services_scope diff --git a/application/single_app/static/js/admin/admin_agent_templates.js b/application/single_app/static/js/admin/admin_agent_templates.js new file mode 100644 index 000000000..4bea4924d --- /dev/null +++ b/application/single_app/static/js/admin/admin_agent_templates.js @@ -0,0 +1,515 @@ +// admin_agent_templates.js +// Admin UI logic for reviewing, approving, and deleting agent template submissions + +import { showToast } from "../chat/chat-toast.js"; + +const panel = document.getElementById("agent-templates-admin-panel"); +const tableBody = document.getElementById("agent-template-table-body"); +const statusFilters = document.getElementById("agent-template-status-filters"); +const disabledAlert = document.getElementById("agent-templates-disabled-alert"); +const searchInput = document.getElementById("agent-template-search"); +const paginationEl = document.getElementById("agent-template-pagination"); +const paginationSummary = document.getElementById("agent-template-pagination-summary"); +const paginationNav = document.getElementById("agent-template-pagination-nav"); +const modalEl = document.getElementById("agentTemplateReviewModal"); +const approveBtn = document.getElementById("agent-template-approve-btn"); +const rejectBtn = document.getElementById("agent-template-reject-btn"); +const deleteBtn = document.getElementById("agent-template-delete-btn"); +const notesInput = document.getElementById("agent-template-review-notes"); +const rejectReasonInput = document.getElementById("agent-template-reject-reason"); +const errorAlert = document.getElementById("agent-template-review-error"); +const statusBadge = document.getElementById("agent-template-review-status"); +const helperEl = document.getElementById("agent-template-review-helper"); +const descriptionEl = document.getElementById("agent-template-review-description"); +const instructionsEl = document.getElementById("agent-template-review-instructions"); +const actionsWrapper = document.getElementById("agent-template-review-actions-wrapper"); +const actionsList = document.getElementById("agent-template-review-actions"); +const settingsWrapper = document.getElementById("agent-template-review-settings-wrapper"); +const settingsEl = document.getElementById("agent-template-review-settings"); +const tagsContainer = document.getElementById("agent-template-review-tags"); +const subtitleEl = document.getElementById("agent-template-review-subtitle"); +const metaEl = document.getElementById("agent-template-review-meta"); +const titleEl = document.getElementById("agentTemplateReviewModalLabel"); + +let currentFilter = "pending"; +let templates = []; +let selectedTemplate = null; +let reviewModal = null; +let currentPage = 1; +let searchQuery = ""; +const PAGE_SIZE = 10; + +function init() { + if (!panel) { + return; + } + + if (modalEl && window.bootstrap) { + reviewModal = bootstrap.Modal.getOrCreateInstance(modalEl); + } + + if (!window.appSettings?.enable_agent_template_gallery) { + if (disabledAlert) disabledAlert.classList.remove("d-none"); + renderEmptyState("Template gallery is disabled."); + return; + } + + attachFilterHandlers(); + attachTableHandlers(); + attachSearchHandler(); + attachModalHandlers(); + loadTemplatesForFilter(currentFilter); +} + +function attachFilterHandlers() { + if (!statusFilters) { + return; + } + statusFilters.addEventListener("click", (event) => { + const button = event.target.closest("button[data-status]"); + if (!button) { + return; + } + const { status } = button.dataset; + if (!status || status === currentFilter) { + return; + } + currentFilter = status; + statusFilters.querySelectorAll("button").forEach((btn) => btn.classList.remove("active")); + button.classList.add("active"); + currentPage = 1; + loadTemplatesForFilter(currentFilter); + }); +} + +function attachTableHandlers() { + if (!tableBody) { + return; + } + tableBody.addEventListener("click", (event) => { + const reviewBtn = event.target.closest(".agent-template-review-btn"); + if (reviewBtn) { + const templateId = reviewBtn.dataset.templateId; + openReviewModal(templateId); + return; + } + const deleteBtn = event.target.closest(".agent-template-inline-delete"); + if (deleteBtn) { + const templateId = deleteBtn.dataset.templateId; + confirmAndDelete(templateId); + } + }); +} + +function attachModalHandlers() { + if (!approveBtn || !rejectBtn || !deleteBtn) { + return; + } + + approveBtn.addEventListener("click", () => handleApproval()); + rejectBtn.addEventListener("click", () => handleRejection()); + deleteBtn.addEventListener("click", () => { + if (selectedTemplate?.id) { + confirmAndDelete(selectedTemplate.id, true); + } + }); +} + +function attachSearchHandler() { + if (!searchInput) { + return; + } + searchInput.addEventListener("input", (event) => { + searchQuery = event.target.value?.trim().toLowerCase() || ""; + currentPage = 1; + renderTemplates(); + }); +} + +async function loadTemplatesForFilter(status) { + renderLoadingRow(); + try { + const query = status && status !== "all" ? `?status=${encodeURIComponent(status)}` : "?status=all"; + const response = await fetch(`/api/admin/agent-templates${query}`); + if (!response.ok) { + throw new Error("Failed to load templates."); + } + const data = await response.json(); + templates = data.templates || []; + currentPage = 1; + renderTemplates(); + } catch (error) { + console.error("Error loading agent templates", error); + renderEmptyState(error.message || "Unable to load templates."); + } +} + +function renderLoadingRow() { + if (!tableBody) return; + tableBody.innerHTML = ` +
Loading...
+ Loading templates... + `; + setSummaryMessage("Loading templates..."); + renderPaginationControls(0); +} + +function renderEmptyState(message) { + if (!tableBody) return; + tableBody.innerHTML = `${message}`; + setSummaryMessage(message); + renderPaginationControls(0); +} + +function renderTemplates() { + if (!tableBody) { + return; + } + const filtered = getFilteredTemplates(); + if (!filtered.length) { + const emptyMessage = searchQuery ? "No templates match your search." : "No templates found for this filter."; + renderEmptyState(emptyMessage); + return; + } + + const totalItems = filtered.length; + const totalPages = Math.ceil(totalItems / PAGE_SIZE) || 1; + if (currentPage > totalPages) { + currentPage = totalPages; + } + const startIndex = (currentPage - 1) * PAGE_SIZE; + const pageItems = filtered.slice(startIndex, startIndex + PAGE_SIZE); + const endIndex = startIndex + pageItems.length; + + tableBody.innerHTML = ""; + pageItems.forEach((template) => { + const row = document.createElement("tr"); + row.innerHTML = ` + +
${escapeHtml(template.title || template.display_name || "Template")}
+
${escapeHtml(template.helper_text || template.description || "")}
+ + ${renderStatusBadge(template.status)} + +
${escapeHtml(template.created_by_name || 'Unknown')}
+
${escapeHtml(template.created_by_email || '')}
+ + ${formatDate(template.updated_at || template.created_at)} + +
+ + +
+ + `; + tableBody.appendChild(row); + }); + + setSummaryMessage(`Showing ${startIndex + 1}-${endIndex} of ${totalItems} (page ${currentPage} of ${totalPages})`); + renderPaginationControls(totalPages); +} + +function getFilteredTemplates() { + if (!searchQuery) { + return templates; + } + return templates.filter((template) => { + return [ + template.title, + template.display_name, + template.created_by_name, + template.created_by_email + ].some((value) => value && value.toString().toLowerCase().includes(searchQuery)); + }); +} + +function renderStatusBadge(status) { + const normalized = (status || "pending").toLowerCase(); + const variants = { + approved: "success", + rejected: "danger", + archived: "secondary", + pending: "warning", + }; + const badgeClass = variants[normalized] || "secondary"; + return `${normalized}`; +} + +function setSummaryMessage(message = "") { + if (paginationSummary) { + paginationSummary.textContent = message; + } +} + +function renderPaginationControls(totalPages) { + if (!paginationEl) { + return; + } + + if (paginationNav) { + if (totalPages <= 1) { + paginationNav.classList.add("d-none"); + } else { + paginationNav.classList.remove("d-none"); + } + } + + if (totalPages <= 1) { + paginationEl.innerHTML = ""; + return; + } + + const maxButtons = 5; + let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2)); + let endPage = startPage + maxButtons - 1; + if (endPage > totalPages) { + endPage = totalPages; + startPage = Math.max(1, endPage - maxButtons + 1); + } + + const fragment = document.createDocumentFragment(); + fragment.appendChild(createPageItem("Previous", currentPage - 1, currentPage === 1)); + + for (let page = startPage; page <= endPage; page += 1) { + fragment.appendChild(createPageItem(page, page, false, page === currentPage)); + } + + fragment.appendChild(createPageItem("Next", currentPage + 1, currentPage === totalPages)); + + paginationEl.innerHTML = ""; + paginationEl.appendChild(fragment); +} + +function createPageItem(label, targetPage, disabled, active = false) { + const li = document.createElement("li"); + li.className = "page-item"; + if (disabled) li.classList.add("disabled"); + if (active) li.classList.add("active"); + + const button = document.createElement("button"); + button.type = "button"; + button.className = "page-link"; + button.textContent = label.toString(); + button.disabled = disabled; + button.addEventListener("click", () => { + if (disabled || targetPage === currentPage) { + return; + } + currentPage = Math.min(Math.max(targetPage, 1), Math.ceil(getFilteredTemplates().length / PAGE_SIZE) || 1); + renderTemplates(); + }); + + li.appendChild(button); + return li; +} + +function formatDate(value) { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +} + +async function openReviewModal(templateId) { + if (!templateId || !reviewModal) { + return; + } + try { + const response = await fetch(`/api/admin/agent-templates/${templateId}`); + if (!response.ok) { + throw new Error('Failed to load template.'); + } + const data = await response.json(); + selectedTemplate = data.template; + populateReviewModal(selectedTemplate); + reviewModal.show(); + } catch (error) { + console.error('Failed to open template modal', error); + showToast(error.message || 'Unable to load template.', 'danger'); + } +} + +function populateReviewModal(template) { + if (!template) { + return; + } + titleEl.textContent = template.title || template.display_name || 'Agent Template'; + helperEl.textContent = template.helper_text || template.description || '-'; + descriptionEl.textContent = template.description || '-'; + instructionsEl.textContent = template.instructions || ''; + notesInput.value = template.review_notes || ''; + rejectReasonInput.value = template.rejection_reason || ''; + updateStatusBadge(template.status); + + const submittedBy = template.created_by_name || 'Unknown submitter'; + const submittedAt = formatDate(template.created_at); + subtitleEl.textContent = `Submitted by ${submittedBy} on ${submittedAt}`; + metaEl.textContent = `Updated ${formatDate(template.updated_at)}`; + + if (Array.isArray(template.actions_to_load) && template.actions_to_load.length) { + actionsWrapper.classList.remove('d-none'); + actionsList.innerHTML = ''; + template.actions_to_load.forEach((action) => { + const badge = document.createElement('span'); + badge.className = 'badge bg-info text-dark me-1 mb-1'; + badge.textContent = action; + actionsList.appendChild(badge); + }); + } else { + actionsWrapper.classList.add('d-none'); + actionsList.innerHTML = ''; + } + + if (template.additional_settings) { + settingsWrapper.classList.remove('d-none'); + settingsEl.textContent = template.additional_settings; + } else { + settingsWrapper.classList.add('d-none'); + settingsEl.textContent = ''; + } + + if (Array.isArray(template.tags) && template.tags.length) { + tagsContainer.classList.remove('d-none'); + tagsContainer.innerHTML = ''; + template.tags.slice(0, 8).forEach((tag) => { + const badge = document.createElement('span'); + badge.className = 'badge bg-secondary-subtle text-secondary-emphasis'; + badge.textContent = tag; + tagsContainer.appendChild(badge); + }); + } else { + tagsContainer.classList.add('d-none'); + tagsContainer.innerHTML = ''; + } + + hideModalError(); +} + +function updateStatusBadge(status) { + const normalized = (status || 'pending').toLowerCase(); + statusBadge.textContent = normalized; + statusBadge.className = 'badge'; + statusBadge.classList.add(`bg-${{ + approved: 'success', + rejected: 'danger', + archived: 'secondary', + pending: 'warning' + }[normalized] || 'secondary'}`); +} + +function hideModalError() { + if (errorAlert) { + errorAlert.classList.add('d-none'); + errorAlert.textContent = ''; + } +} + +function showModalError(message) { + if (!errorAlert) { + showToast(message, 'danger'); + return; + } + errorAlert.classList.remove('d-none'); + errorAlert.textContent = message; +} + +async function handleApproval() { + if (!selectedTemplate?.id) { + return; + } + await submitTemplateDecision(`/api/admin/agent-templates/${selectedTemplate.id}/approve`, { + notes: notesInput.value?.trim() || undefined + }, 'Template approved!'); +} + +async function handleRejection() { + if (!selectedTemplate?.id) { + return; + } + const reason = rejectReasonInput.value?.trim(); + if (!reason) { + showModalError('A rejection reason is required.'); + rejectReasonInput.focus(); + return; + } + await submitTemplateDecision(`/api/admin/agent-templates/${selectedTemplate.id}/reject`, { + reason, + notes: notesInput.value?.trim() || undefined + }, 'Template rejected.'); +} + +async function submitTemplateDecision(url, payload, successMessage) { + try { + setModalButtonsDisabled(true); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Failed to update template.'); + } + showToast(successMessage, 'success'); + hideModalError(); + reviewModal?.hide(); + loadTemplatesForFilter(currentFilter); + } catch (error) { + console.error('Template decision failed', error); + showModalError(error.message || 'Failed to update template.'); + } finally { + setModalButtonsDisabled(false); + } +} + +function setModalButtonsDisabled(disabled) { + [approveBtn, rejectBtn, deleteBtn].forEach((btn) => { + if (btn) btn.disabled = disabled; + }); +} + +async function confirmAndDelete(templateId, closeModal = false) { + if (!templateId) { + return; + } + if (!confirm('Delete this template? This action cannot be undone.')) { + return; + } + try { + const response = await fetch(`/api/admin/agent-templates/${templateId}`, { + method: 'DELETE' + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Failed to delete template.'); + } + showToast('Template deleted.', 'success'); + if (closeModal) { + reviewModal?.hide(); + } + loadTemplatesForFilter(currentFilter); + } catch (error) { + console.error('Failed to delete template', error); + showToast(error.message || 'Failed to delete template.', 'danger'); + } +} + +function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = value || ''; + return div.innerHTML; +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 6b3ed8c29..81f80f9e2 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -1575,22 +1575,158 @@ function setupToggles() { } const enableWebSearch = document.getElementById('enable_web_search'); - if (enableWebSearch) { + const webSearchFoundrySettings = document.getElementById('web_search_foundry_settings'); + const webSearchConsentInput = document.getElementById('web_search_consent_accepted'); + const webSearchConsentModalEl = document.getElementById('web-search-consent-modal'); + const webSearchConsentAcceptBtn = document.getElementById('web-search-consent-accept'); + const webSearchConsentDeclineBtn = document.getElementById('web-search-consent-decline'); + let webSearchConsentModal = null; + const toggleVisibility = (element, isVisible) => { + if (!element) { + return; + } + element.classList.toggle('d-none', !isVisible); + }; + if (enableWebSearch && webSearchFoundrySettings) { + const setConsentAccepted = (value) => { + if (webSearchConsentInput) { + webSearchConsentInput.value = value ? 'true' : 'false'; + } + }; + + const showConsentModal = () => { + if (!webSearchConsentModalEl) { + showToast('Consent modal could not be loaded.', 'warning'); + return; + } + + if (!webSearchConsentModal) { + webSearchConsentModal = new bootstrap.Modal(webSearchConsentModalEl, { + backdrop: 'static', + keyboard: false + }); + } + + webSearchConsentModal.show(); + }; + + const hasConsent = () => webSearchConsentInput?.value === 'true'; + + if (enableWebSearch.checked && !hasConsent()) { + enableWebSearch.checked = false; + } + toggleVisibility(webSearchFoundrySettings, enableWebSearch.checked && hasConsent()); + enableWebSearch.addEventListener('change', function () { - document.getElementById('web_search_settings').style.display = this.checked ? 'block' : 'none'; + if (this.checked && !hasConsent()) { + this.checked = false; + toggleVisibility(webSearchFoundrySettings, false); + showConsentModal(); + return; + } + + toggleVisibility(webSearchFoundrySettings, this.checked); + markFormAsModified(); + }); + + if (webSearchConsentAcceptBtn) { + webSearchConsentAcceptBtn.addEventListener('click', () => { + setConsentAccepted(true); + enableWebSearch.checked = true; + toggleVisibility(webSearchFoundrySettings, true); + markFormAsModified(); + if (webSearchConsentModal) { + webSearchConsentModal.hide(); + } + }); + } + + if (webSearchConsentDeclineBtn) { + webSearchConsentDeclineBtn.addEventListener('click', () => { + setConsentAccepted(false); + enableWebSearch.checked = false; + toggleVisibility(webSearchFoundrySettings, false); + markFormAsModified(); + if (webSearchConsentModal) { + webSearchConsentModal.hide(); + } + }); + } + } + + // Web Search User Notice toggle + const enableWebSearchUserNotice = document.getElementById('enable_web_search_user_notice'); + const webSearchUserNoticeSettings = document.getElementById('web_search_user_notice_settings'); + if (enableWebSearchUserNotice && webSearchUserNoticeSettings) { + enableWebSearchUserNotice.addEventListener('change', function() { + toggleVisibility(webSearchUserNoticeSettings, this.checked); + markFormAsModified(); + }); + } + + const foundryAuthType = document.getElementById('web_search_foundry_auth_type'); + const foundryMiType = document.getElementById('web_search_foundry_managed_identity_type'); + const foundryCloud = document.getElementById('web_search_foundry_cloud'); + const foundrySpFields = document.getElementById('web_search_foundry_service_principal_fields'); + const foundryMiTypeContainer = document.getElementById('web_search_foundry_managed_identity_type_container'); + const foundryMiClientIdContainer = document.getElementById('web_search_foundry_managed_identity_client_id_container'); + const foundryCloudContainer = document.getElementById('web_search_foundry_cloud_container'); + const foundryAuthorityContainer = document.getElementById('web_search_foundry_authority_container'); + + function updateFoundryAuthVisibility() { + const authType = foundryAuthType?.value || 'managed_identity'; + const cloudValue = foundryCloud?.value || ''; + + toggleVisibility(foundrySpFields, authType === 'service_principal'); + toggleVisibility(foundryCloudContainer, authType === 'service_principal'); + toggleVisibility( + foundryAuthorityContainer, + authType === 'service_principal' && cloudValue === 'custom' + ); + toggleVisibility(foundryMiTypeContainer, authType === 'managed_identity'); + if (foundryMiClientIdContainer) { + const miType = foundryMiType?.value || 'system_assigned'; + toggleVisibility( + foundryMiClientIdContainer, + authType === 'managed_identity' && miType === 'user_assigned' + ); + } + } + + if (foundryAuthType || foundryMiType || foundryCloud) { + updateFoundryAuthVisibility(); + } + + if (foundryMiType) { + foundryMiType.addEventListener('change', () => { + updateFoundryAuthVisibility(); markFormAsModified(); }); } - const enableWebSearchApim = document.getElementById('enable_web_search_apim'); - if (enableWebSearchApim) { - enableWebSearchApim.addEventListener('change', function () { - document.getElementById('non_apim_web_search_settings').style.display = this.checked ? 'none' : 'block'; - document.getElementById('apim_web_search_settings').style.display = this.checked ? 'block' : 'none'; + if (foundryCloud) { + foundryCloud.addEventListener('change', () => { + updateFoundryAuthVisibility(); markFormAsModified(); }); } + if (foundryAuthType) { + foundryAuthType.addEventListener('change', () => { + updateFoundryAuthVisibility(); + markFormAsModified(); + }); + } + + const toggleFoundrySecret = document.getElementById('toggle_web_search_foundry_client_secret'); + const foundrySecretInput = document.getElementById('web_search_foundry_client_secret'); + if (toggleFoundrySecret && foundrySecretInput) { + toggleFoundrySecret.addEventListener('click', () => { + foundrySecretInput.type = foundrySecretInput.type === 'password' ? 'text' : 'password'; + toggleFoundrySecret.textContent = foundrySecretInput.type === 'password' ? 'Show' : 'Hide'; + }); + } + const enableAiSearchApim = document.getElementById('enable_ai_search_apim'); if (enableAiSearchApim) { enableAiSearchApim.addEventListener('change', function () { diff --git a/application/single_app/static/js/admin/admin_sidebar_nav.js b/application/single_app/static/js/admin/admin_sidebar_nav.js index 729657810..3f1bb6678 100644 --- a/application/single_app/static/js/admin/admin_sidebar_nav.js +++ b/application/single_app/static/js/admin/admin_sidebar_nav.js @@ -206,6 +206,7 @@ function scrollToSection(sectionId) { // Security tab sections 'keyvault-section': 'keyvault-section', // Search & Extract tab sections + 'web-search-section': 'web-search-foundry-section', 'azure-ai-search-section': 'azure-ai-search-section', 'document-intelligence-section': 'document-intelligence-section', 'multimedia-support-section': 'multimedia-support-section' diff --git a/application/single_app/static/js/agent_modal_stepper.js b/application/single_app/static/js/agent_modal_stepper.js index 30cf31fc0..800751be6 100644 --- a/application/single_app/static/js/agent_modal_stepper.js +++ b/application/single_app/static/js/agent_modal_stepper.js @@ -10,11 +10,18 @@ export class AgentModalStepper { this.maxSteps = 6; this.isEditMode = false; this.isAdmin = isAdmin; // Track if this is admin context + this.currentAgentType = 'local'; this.originalAgent = null; // Track original state for change detection this.actionsToSelect = null; // Store actions to select when they're loaded this.updateStepIndicatorTimeout = null; // For debouncing step indicator updates + this.templateSubmitButton = document.getElementById('agent-modal-submit-template-btn'); + this.foundryPlaceholderInstructions = 'Placeholder instructions: Azure AI Foundry agent manages its own prompt.'; this.bindEvents(); + + if (this.templateSubmitButton) { + this.templateSubmitButton.addEventListener('click', () => this.submitTemplate()); + } } bindEvents() { @@ -24,6 +31,7 @@ export class AgentModalStepper { const saveBtn = document.getElementById('agent-modal-save-btn'); const skipBtn = document.getElementById('agent-modal-skip'); const powerUserToggle = document.getElementById('agent-power-user-toggle'); + const agentTypeRadios = document.querySelectorAll('input[name="agent-type"]'); if (nextBtn) { nextBtn.addEventListener('click', () => this.nextStep()); @@ -40,6 +48,12 @@ export class AgentModalStepper { if (powerUserToggle) { powerUserToggle.addEventListener('change', (e) => this.togglePowerUserMode(e.target.checked)); } + + if (agentTypeRadios && agentTypeRadios.length) { + agentTypeRadios.forEach(r => { + r.addEventListener('change', (e) => this.handleAgentTypeChange(e.target.value)); + }); + } // Set up display name to generated name conversion this.setupNameGeneration(); @@ -70,6 +84,90 @@ export class AgentModalStepper { } } + handleAgentTypeChange(agentType) { + this.currentAgentType = agentType || 'local'; + this.applyAgentTypeVisibility(); + // Clear actions if switching to foundry + if (this.currentAgentType === 'aifoundry') { + this.clearSelectedActions(); + } + this.populateSummary(); + } + + applyAgentTypeVisibility() { + const isFoundry = this.currentAgentType === 'aifoundry'; + const foundryFields = document.getElementById('agent-foundry-fields'); + const modelGroup = document.getElementById('agent-global-model-group'); + const customToggle = document.getElementById('agent-custom-connection-toggle'); + const customFields = document.getElementById('agent-custom-connection-fields'); + const actionsSection = document.getElementById('agent-step-4'); + const actionsDisabled = document.getElementById('agent-actions-disabled'); + const actionsContainer = document.getElementById('agent-actions-container'); + const actionsHeader = actionsSection?.querySelector('.card'); + const summaryActionsSection = document.getElementById('summary-actions-section'); + const instructionsContainer = document.getElementById('agent-instructions-container'); + const instructionsFoundryNote = document.getElementById('agent-instructions-foundry-note'); + const instructionsInput = document.getElementById('agent-instructions'); + + if (foundryFields) foundryFields.classList.toggle('d-none', !isFoundry); + if (modelGroup) modelGroup.classList.toggle('d-none', isFoundry); + if (customToggle) customToggle.classList.toggle('d-none', isFoundry); + if (customFields) customFields.classList.toggle('d-none', isFoundry); + + if (instructionsContainer) instructionsContainer.classList.toggle('d-none', isFoundry); + if (instructionsFoundryNote) instructionsFoundryNote.classList.toggle('d-none', !isFoundry); + if (instructionsInput) { + if (isFoundry) { + instructionsInput.value = this.foundryPlaceholderInstructions; + } + } + + if (actionsSection) { + // Hide interactive actions when foundry + if (actionsDisabled) actionsDisabled.classList.toggle('d-none', !isFoundry); + if (actionsHeader) actionsHeader.classList.toggle('d-none', isFoundry); + if (actionsContainer) actionsContainer.classList.toggle('d-none', isFoundry); + const noActionsMsg = document.getElementById('agent-no-actions-message'); + if (noActionsMsg) noActionsMsg.classList.toggle('d-none', isFoundry); + const selectedSummary = document.getElementById('agent-selected-actions-summary'); + if (selectedSummary) selectedSummary.classList.toggle('d-none', isFoundry); + } + + if (summaryActionsSection) { + summaryActionsSection.classList.toggle('d-none', isFoundry); + } + + // Update helper text + const helper = document.getElementById('agent-type-helper'); + if (helper) { + helper.textContent = isFoundry + ? 'Foundry agents use Azure-managed tools. Actions step is disabled.' + : 'Local agents can attach actions and use SK plugins.'; + } + } + + updateAgentTypeLock() { + const radios = document.querySelectorAll('input[name="agent-type"]'); + if (!radios || !radios.length) { + return; + } + + const shouldDisable = this.isEditMode || this.currentStep > 1; + + radios.forEach(radio => { + radio.disabled = shouldDisable; + const wrapper = radio.closest('.form-check'); + if (wrapper) { + wrapper.classList.toggle('opacity-50', shouldDisable); + } + }); + + const selector = document.getElementById('agent-type-selector'); + if (selector) { + selector.classList.toggle('pe-none', shouldDisable); + } + } + updateReasoningEffortForModel() { const globalModelSelect = document.getElementById('agent-global-model-select'); const reasoningEffortSelect = document.getElementById('agent-reasoning-effort'); @@ -147,6 +245,7 @@ export class AgentModalStepper { showModal(agent = null) { this.isEditMode = !!agent; + this.currentAgentType = (agent && agent.agent_type) || 'local'; // Store original state for change detection this.originalAgent = agent ? JSON.parse(JSON.stringify(agent)) : null; @@ -179,6 +278,9 @@ export class AgentModalStepper { // Ensure generated name is populated for both new and existing agents this.updateGeneratedName(); + this.syncAgentTypeSelector(); + this.applyAgentTypeVisibility(); + this.updateAgentTypeLock(); // Load models for the modal this.loadModelsForModal(); @@ -197,6 +299,7 @@ export class AgentModalStepper { this.updateStepIndicator(); this.showStep(1); this.updateNavigationButtons(); + this.updateTemplateButtonVisibility(); console.log('Step indicators initialized'); } else { // Modal not ready yet, try again @@ -225,6 +328,14 @@ export class AgentModalStepper { } } + syncAgentTypeSelector() { + const radios = document.querySelectorAll('input[name="agent-type"]'); + if (!radios || !radios.length) return; + radios.forEach(r => { + r.checked = r.value === this.currentAgentType; + }); + } + clearFields() { // Clear all form fields const displayName = document.getElementById('agent-display-name'); @@ -282,6 +393,11 @@ export class AgentModalStepper { customConnection.checked = agentsCommon.shouldEnableCustomConnection(agent); } + // Agent type selection + this.currentAgentType = agent.agent_type || 'local'; + this.syncAgentTypeSelector(); + this.applyAgentTypeVisibility(); + // Use shared function to populate all fields if (agentsCommon && typeof agentsCommon.setAgentModalFields === 'function') { agentsCommon.setAgentModalFields(agent); @@ -327,6 +443,24 @@ export class AgentModalStepper { if (agent.actions_to_load && Array.isArray(agent.actions_to_load)) { this.actionsToSelect = agent.actions_to_load; } + + // Foundry-specific fields + if (agent.agent_type === 'aifoundry') { + const other = agent.other_settings || {}; + const foundry = (other && other.azure_ai_foundry) || {}; + const endpointEl = document.getElementById('agent-foundry-endpoint'); + const apiEl = document.getElementById('agent-foundry-api-version'); + const depEl = document.getElementById('agent-foundry-deployment'); + const idEl = document.getElementById('agent-foundry-agent-id'); + const notesEl = document.getElementById('agent-foundry-notes'); + if (endpointEl) endpointEl.value = agent.azure_openai_gpt_endpoint || ''; + if (apiEl) apiEl.value = agent.azure_openai_gpt_api_version || ''; + if (depEl) depEl.value = agent.azure_openai_gpt_deployment || ''; + if (idEl) idEl.value = foundry.agent_id || ''; + if (notesEl) notesEl.value = foundry.notes || ''; + // ensure actions cleared for UI + this.clearSelectedActions(); + } } nextStep() { @@ -357,7 +491,9 @@ export class AgentModalStepper { skipBtn.innerHTML = `Skipping...`; } try { - await this.loadAvailableActions(); + if (this.currentAgentType !== 'aifoundry') { + await this.loadAvailableActions(); + } this.goToStep(this.maxSteps); } catch (error) { console.error('Error loading actions:', error); @@ -380,6 +516,8 @@ export class AgentModalStepper { this.showStep(stepNumber); this.updateStepIndicator(); this.updateNavigationButtons(); + this.updateTemplateButtonVisibility(); + this.updateAgentTypeLock(); } showStep(stepNumber) { @@ -398,22 +536,31 @@ export class AgentModalStepper { } if (stepNumber === 2) { - if (!this.isAdmin) { - const customConnectionToggle = document.getElementById('agent-custom-connection-toggle'); - if (customConnectionToggle) { + const isFoundry = this.currentAgentType === 'aifoundry'; + const customConnectionToggle = document.getElementById('agent-custom-connection-toggle'); + const modelGroup = document.getElementById('agent-global-model-group'); + + if (customConnectionToggle) { + if (isFoundry) { + customConnectionToggle.classList.add('d-none'); + } else if (!this.isAdmin) { const allowUserCustom = appSettings?.allow_user_custom_agent_endpoints; - if (!allowUserCustom) { - customConnectionToggle.classList.add('d-none'); - } else { - customConnectionToggle.classList.remove('d-none'); - } + customConnectionToggle.classList.toggle('d-none', !allowUserCustom); + } else { + customConnectionToggle.classList.remove('d-none'); } } + + if (modelGroup) { + modelGroup.classList.toggle('d-none', isFoundry); + } } // Load actions when reaching step 4 if (stepNumber === 4) { - this.loadAvailableActions(); + if (this.currentAgentType !== 'aifoundry') { + this.loadAvailableActions(); + } } // Populate summary when reaching step 6 @@ -511,6 +658,27 @@ export class AgentModalStepper { } } + canSubmitTemplate() { + if (!window.appSettings || !window.appSettings.enable_agent_template_gallery) { + return false; + } + if (this.isAdmin) { + return true; + } + if (window.appSettings.allow_user_agents === false) { + return false; + } + return window.appSettings.agent_templates_allow_user_submission !== false; + } + + updateTemplateButtonVisibility() { + if (!this.templateSubmitButton) { + return; + } + const shouldShow = this.canSubmitTemplate() && this.currentStep === this.maxSteps; + this.templateSubmitButton.classList.toggle('d-none', !shouldShow); + } + validateCurrentStep() { switch (this.currentStep) { case 1: // Basic Info @@ -531,20 +699,54 @@ export class AgentModalStepper { break; case 2: // Model & Connection - // Model validation would go here + if (this.currentAgentType === 'aifoundry') { + const endpoint = document.getElementById('agent-foundry-endpoint'); + const apiVersion = document.getElementById('agent-foundry-api-version'); + const deployment = document.getElementById('agent-foundry-deployment'); + const agentId = document.getElementById('agent-foundry-agent-id'); + if (!endpoint || !endpoint.value.trim()) { + this.showError('Azure AI Foundry endpoint is required.'); + endpoint?.focus(); + return false; + } + if (!apiVersion || !apiVersion.value.trim()) { + this.showError('Azure AI Foundry API version is required.'); + apiVersion?.focus(); + return false; + } + if (!deployment || !deployment.value.trim()) { + this.showError('Foundry deployment/project is required.'); + deployment?.focus(); + return false; + } + if (!agentId || !agentId.value.trim()) { + this.showError('Foundry agent ID is required.'); + agentId?.focus(); + return false; + } + } break; case 3: // Instructions const instructions = document.getElementById('agent-instructions'); - if (!instructions || !instructions.value.trim()) { - this.showError('Please provide instructions for the agent.'); - if (instructions) instructions.focus(); - return false; - } + if (this.currentAgentType !== 'aifoundry') { + if (!instructions || !instructions.value.trim()) { + this.showError('Please provide instructions for the agent.'); + if (instructions) instructions.focus(); + return false; + } + } else { + // Ensure placeholder present + if (instructions && !instructions.value.trim()) { + instructions.value = this.foundryPlaceholderInstructions; + } + } break; case 4: // Actions - // Actions validation would go here if needed + if (this.currentAgentType !== 'aifoundry') { + // Actions validation would go here if needed + } break; case 5: // Advanced @@ -648,6 +850,10 @@ export class AgentModalStepper { } getFormModelName() { + if (this.currentAgentType === 'aifoundry') { + const foundryDeployment = document.getElementById('agent-foundry-deployment'); + return foundryDeployment?.value?.trim() || '-'; + } const customConnection = document.getElementById('agent-custom-connection')?.checked || false; let modelName = '-'; if (customConnection) { @@ -671,6 +877,7 @@ export class AgentModalStepper { const displayName = document.getElementById('agent-display-name')?.value || '-'; const generatedName = document.getElementById('agent-name')?.value || '-'; const description = document.getElementById('agent-description')?.value || '-'; + const agentType = this.currentAgentType || 'local'; // Model & Connection const customConnection = document.getElementById('agent-custom-connection')?.checked ? 'Yes' : 'No'; @@ -691,6 +898,11 @@ export class AgentModalStepper { // Update configuration document.getElementById('summary-model').textContent = modelName; document.getElementById('summary-custom-connection').textContent = customConnection; + const typeBadge = document.getElementById('summary-agent-type-badge'); + if (typeBadge) { + typeBadge.textContent = agentType === 'aifoundry' ? 'Azure AI Foundry' : 'Local (Semantic Kernel)'; + typeBadge.className = agentType === 'aifoundry' ? 'badge bg-warning text-dark' : 'badge bg-info'; + } // Update instructions document.getElementById('summary-instructions').textContent = instructions; @@ -705,10 +917,16 @@ export class AgentModalStepper { const actionsListContainer = document.getElementById('summary-actions-list'); const actionsEmptyContainer = document.getElementById('summary-actions-empty'); - if (actionsCount > 0) { + if (this.currentAgentType === 'aifoundry') { + // Hide actions entirely for Foundry + const actionsSection = document.getElementById('summary-actions-section'); + if (actionsSection) actionsSection.style.display = 'none'; + } else if (actionsCount > 0) { // Show actions list, hide empty message actionsListContainer.style.display = 'block'; actionsEmptyContainer.style.display = 'none'; + const actionsSection = document.getElementById('summary-actions-section'); + if (actionsSection) actionsSection.style.display = ''; // Clear existing content actionsListContainer.innerHTML = ''; @@ -751,6 +969,8 @@ export class AgentModalStepper { // Hide actions list, show empty message actionsListContainer.style.display = 'none'; actionsEmptyContainer.style.display = 'block'; + const actionsSection = document.getElementById('summary-actions-section'); + if (actionsSection) actionsSection.style.display = ''; } // Update creation date @@ -1247,8 +1467,12 @@ export class AgentModalStepper { } } - // Add selected actions - agentData.actions_to_load = this.getSelectedActionIds(); + // Add selected actions (skip for Foundry) + if (agentData.agent_type === 'aifoundry') { + agentData.actions_to_load = []; + } else { + agentData.actions_to_load = this.getSelectedActionIds(); + } agentData.is_global = this.isAdmin; // Set based on admin context // Ensure required schema fields are present @@ -1304,6 +1528,9 @@ export class AgentModalStepper { } getAgentFormData() { + const agentTypeInput = document.querySelector('input[name="agent-type"]:checked'); + const selectedAgentType = agentTypeInput ? agentTypeInput.value : 'local'; + const formData = { display_name: document.getElementById('agent-display-name')?.value || '', name: document.getElementById('agent-name')?.value || '', @@ -1314,8 +1541,37 @@ export class AgentModalStepper { other_settings: document.getElementById('agent-additional-settings')?.value || '{}', max_completion_tokens: parseInt(document.getElementById('agent-max-completion-tokens')?.value.trim()) || null, reasoning_effort: document.getElementById('agent-reasoning-effort')?.value || '', - agent_type: 'local' + agent_type: selectedAgentType }; + + if (selectedAgentType === 'aifoundry') { + // Foundry required fields + formData.azure_openai_gpt_endpoint = document.getElementById('agent-foundry-endpoint')?.value?.trim() || ''; + formData.azure_openai_gpt_deployment = document.getElementById('agent-foundry-deployment')?.value?.trim() || ''; + formData.azure_openai_gpt_api_version = document.getElementById('agent-foundry-api-version')?.value?.trim() || ''; + formData.instructions = document.getElementById('agent-instructions')?.value?.trim() || this.foundryPlaceholderInstructions; + + // other_settings for foundry + let otherSettingsObj = {}; + try { + otherSettingsObj = JSON.parse(formData.other_settings || '{}'); + } catch (e) { + otherSettingsObj = {}; + } + otherSettingsObj = otherSettingsObj || {}; + const notesVal = document.getElementById('agent-foundry-notes')?.value || ''; + otherSettingsObj.azure_ai_foundry = { + ...(otherSettingsObj.azure_ai_foundry || {}), + agent_id: document.getElementById('agent-foundry-agent-id')?.value?.trim() || '', + ...(notesVal ? { notes: notesVal } : {}) + }; + formData.other_settings = JSON.stringify(otherSettingsObj); + + // Foundry agents cannot have actions + formData.actions_to_load = []; + formData.enable_agent_gpt_apim = false; + return formData; + } // Handle model and deployment configuration if (formData.custom_connection) { @@ -1472,6 +1728,100 @@ export class AgentModalStepper { window.showToast(`Agent ${this.isEditMode ? 'updated' : 'created'} successfully!`, 'success'); } } + + validateTemplateRequirements() { + const displayName = document.getElementById('agent-display-name'); + const description = document.getElementById('agent-description'); + const instructions = document.getElementById('agent-instructions'); + + if (!displayName || !displayName.value.trim()) { + this.showError('Please add a display name before submitting a template.'); + displayName?.focus(); + return false; + } + + if (!description || !description.value.trim()) { + this.showError('Please add a description before submitting a template.'); + description?.focus(); + return false; + } + + if (!instructions || !instructions.value.trim()) { + this.showError('Instructions are required before submitting a template.'); + instructions?.focus(); + return false; + } + + this.hideError(); + return true; + } + + buildTemplatePayload() { + const displayName = document.getElementById('agent-display-name')?.value?.trim() || ''; + const description = document.getElementById('agent-description')?.value?.trim() || ''; + const instructions = document.getElementById('agent-instructions')?.value || ''; + const additionalSettings = document.getElementById('agent-additional-settings')?.value || ''; + + return { + title: displayName || 'Agent Template', + display_name: displayName || 'Agent Template', + description, + helper_text: description, + instructions, + additional_settings: additionalSettings, + actions_to_load: this.getSelectedActionIds(), + source_agent_id: this.originalAgent?.id, + source_scope: this.isAdmin ? 'global' : 'personal' + }; + } + + async submitTemplate() { + if (!this.canSubmitTemplate()) { + showToast('Template submissions are disabled right now.', 'warning'); + return; + } + + if (!this.validateTemplateRequirements()) { + return; + } + + const button = this.templateSubmitButton; + if (!button) { + return; + } + + const originalHtml = button.innerHTML; + button.disabled = true; + button.innerHTML = 'Submitting...'; + + try { + const payload = { template: this.buildTemplatePayload() }; + const response = await fetch('/api/agent-templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Failed to submit agent template.'); + } + + const status = data.template?.status; + const successMessage = (this.isAdmin && status === 'approved') + ? 'Template published to the gallery!' + : 'Template submitted for review.'; + showToast(successMessage, 'success'); + this.hideError(); + } catch (error) { + console.error('Template submission failed:', error); + this.showError(error.message || 'Failed to submit template.'); + showToast(error.message || 'Failed to submit template.', 'error'); + } finally { + button.disabled = false; + button.innerHTML = originalHtml; + } + } } // Global instance will be created contextually by the calling code diff --git a/application/single_app/static/js/agent_templates_gallery.js b/application/single_app/static/js/agent_templates_gallery.js new file mode 100644 index 000000000..428ebf702 --- /dev/null +++ b/application/single_app/static/js/agent_templates_gallery.js @@ -0,0 +1,278 @@ +// agent_templates_gallery.js +// Dynamically renders the agent template gallery within the agent builder + +import { showToast } from "./chat/chat-toast.js"; + +const gallerySelector = ".agent-template-gallery"; +let cachedTemplates = null; +let loadingPromise = null; + +function getGalleryElements(container) { + return { + spinner: container.querySelector(".agent-template-gallery-loading"), + emptyState: container.querySelector(".agent-template-gallery-empty"), + disabledState: container.querySelector(".agent-template-gallery-disabled"), + errorState: container.querySelector(".agent-template-gallery-error"), + errorText: container.querySelector(".agent-template-gallery-error-text"), + accordion: container.querySelector(".accordion"), + }; +} + +async function fetchTemplates() { + if (cachedTemplates) { + return cachedTemplates; + } + if (loadingPromise) { + return loadingPromise; + } + loadingPromise = fetch("/api/agent-templates") + .then(async (response) => { + if (!response.ok) { + throw new Error("Failed to load templates."); + } + const data = await response.json(); + cachedTemplates = data.templates || []; + return cachedTemplates; + }) + .catch((error) => { + cachedTemplates = []; + throw error; + }) + .finally(() => { + loadingPromise = null; + }); + return loadingPromise; +} + +function renderAccordion(accordion, templates, options = {}) { + const accordionId = options.accordionId || "agentTemplates"; + const showCopy = options.showCopy !== "false"; + const showCreate = options.showCreate !== "false"; + + accordion.innerHTML = ""; + + templates.forEach((template, index) => { + const collapseId = `${accordionId}-collapse-${index}`; + const headingId = `${accordionId}-heading-${index}`; + const instructionsId = `${accordionId}-instructions-${index}`; + + const accordionItem = document.createElement("div"); + accordionItem.className = "accordion-item"; + + const header = document.createElement("h2"); + header.className = "accordion-header"; + header.id = headingId; + + const headerButton = document.createElement("button"); + headerButton.className = `accordion-button${index === 0 ? "" : " collapsed"}`; + headerButton.type = "button"; + headerButton.setAttribute("data-bs-toggle", "collapse"); + headerButton.setAttribute("data-bs-target", `#${collapseId}`); + headerButton.textContent = template.title || template.display_name || "Agent Template"; + header.appendChild(headerButton); + + const collapse = document.createElement("div"); + collapse.id = collapseId; + collapse.className = `accordion-collapse collapse${index === 0 ? " show" : ""}`; + collapse.setAttribute("aria-labelledby", headingId); + collapse.setAttribute("data-bs-parent", `#${accordionId}`); + + const body = document.createElement("div"); + body.className = "accordion-body"; + + const headerRow = document.createElement("div"); + headerRow.className = "d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3"; + + const helper = document.createElement("div"); + helper.className = "small text-muted"; + helper.textContent = template.helper_text || template.description || "Reusable agent template"; + headerRow.appendChild(helper); + + const buttonGroup = document.createElement("div"); + buttonGroup.className = "d-flex gap-2 flex-wrap"; + + if (showCopy) { + const copyBtn = document.createElement("button"); + copyBtn.type = "button"; + copyBtn.className = "btn btn-sm btn-outline-secondary"; + copyBtn.innerHTML = ' Copy'; + copyBtn.addEventListener("click", () => copyInstructions(instructionsId)); + buttonGroup.appendChild(copyBtn); + } + + if (showCreate) { + const createBtn = document.createElement("button"); + createBtn.type = "button"; + createBtn.className = "btn btn-sm btn-success agent-example-create-btn"; + createBtn.innerHTML = ' Use Template'; + const payload = { + display_name: template.display_name || template.title || "Agent Template", + description: template.description || template.helper_text || "", + instructions: template.instructions || "", + additional_settings: template.additional_settings || "", + actions_to_load: template.actions_to_load || [], + }; + createBtn.dataset.agentExample = JSON.stringify(payload); + buttonGroup.appendChild(createBtn); + } + + headerRow.appendChild(buttonGroup); + body.appendChild(headerRow); + + const metaList = document.createElement("div"); + metaList.className = "mb-3"; + + const helperLine = document.createElement("p"); + helperLine.className = "mb-1 text-muted small"; + helperLine.innerHTML = `Suggested display name: ${escapeHtml(template.display_name || template.title || "Agent Template")}`; + metaList.appendChild(helperLine); + + if (Array.isArray(template.tags) && template.tags.length) { + const tagList = document.createElement("div"); + tagList.className = "mb-1"; + template.tags.slice(0, 5).forEach((tag) => { + const badge = document.createElement("span"); + badge.className = "badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1"; + badge.textContent = tag; + tagList.appendChild(badge); + }); + metaList.appendChild(tagList); + } + + if (Array.isArray(template.actions_to_load) && template.actions_to_load.length) { + const actionLine = document.createElement("p"); + actionLine.className = "mb-0 text-muted small"; + actionLine.innerHTML = `Recommended actions: ${template.actions_to_load.join(", ")}`; + metaList.appendChild(actionLine); + } + + body.appendChild(metaList); + + const description = document.createElement("p"); + description.className = "mb-3"; + description.textContent = template.description || template.helper_text || "No description provided."; + body.appendChild(description); + + const instructions = document.createElement("pre"); + instructions.className = "bg-dark text-white p-3 rounded"; + instructions.id = instructionsId; + instructions.textContent = template.instructions || ""; + body.appendChild(instructions); + + if (template.additional_settings) { + const advancedBlock = document.createElement("pre"); + advancedBlock.className = "bg-light border rounded p-3 mt-3"; + advancedBlock.textContent = template.additional_settings; + const advancedLabel = document.createElement("p"); + advancedLabel.className = "text-muted small mb-1"; + advancedLabel.textContent = "Additional settings"; + body.appendChild(advancedLabel); + body.appendChild(advancedBlock); + } + + collapse.appendChild(body); + accordionItem.appendChild(header); + accordionItem.appendChild(collapse); + accordion.appendChild(accordionItem); + }); +} + +function escapeHtml(value) { + const div = document.createElement("div"); + div.textContent = value || ""; + return div.innerHTML; +} + +function copyInstructions(instructionsId) { + const target = document.getElementById(instructionsId); + if (!target) { + return; + } + if (typeof window.copyAgentInstructionSample === "function") { + window.copyAgentInstructionSample(instructionsId); + return; + } + const text = target.textContent || ""; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(() => { + showToast("Instructions copied to clipboard", "success"); + }).catch(() => { + fallbackCopyText(text); + }); + } else { + fallbackCopyText(text); + } +} + +function fallbackCopyText(text) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + document.execCommand("copy"); + showToast("Instructions copied to clipboard", "success"); + } catch (err) { + console.error("Clipboard copy failed", err); + showToast("Unable to copy instructions", "error"); + } finally { + document.body.removeChild(textarea); + } +} + +async function initializeGallery(container) { + const elements = getGalleryElements(container); + + if (!window.appSettings?.enable_agent_template_gallery) { + if (elements.spinner) elements.spinner.classList.add("d-none"); + if (elements.disabledState) elements.disabledState.classList.remove("d-none"); + return; + } + + try { + const templates = await fetchTemplates(); + if (elements.spinner) elements.spinner.classList.add("d-none"); + + if (!templates.length) { + if (elements.emptyState) elements.emptyState.classList.remove("d-none"); + return; + } + + if (elements.accordion) { + elements.accordion.classList.remove("d-none"); + renderAccordion(elements.accordion, templates, { + accordionId: container.dataset.accordionId, + showCopy: container.dataset.showCopy, + showCreate: container.dataset.showCreate, + }); + } + } catch (error) { + console.error("Failed to render agent templates", error); + if (elements.spinner) elements.spinner.classList.add("d-none"); + if (elements.errorState) { + elements.errorState.classList.remove("d-none"); + if (elements.errorText) { + elements.errorText.textContent = error.message || "Unexpected error"; + } + } + } +} + +function initAgentTemplateGalleries() { + const containers = document.querySelectorAll(gallerySelector); + if (!containers.length) { + return; + } + containers.forEach((container) => { + initializeGallery(container); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initAgentTemplateGalleries); +} else { + initAgentTemplateGalleries(); +} diff --git a/application/single_app/static/js/chat/chat-citations.js b/application/single_app/static/js/chat/chat-citations.js index a69619c92..abad0af06 100644 --- a/application/single_app/static/js/chat/chat-citations.js +++ b/application/single_app/static/js/chat/chat-citations.js @@ -306,6 +306,13 @@ export function showAgentCitationModal(toolName, toolArgs, toolResult) {
Tool Name:
+
+
Source:
+
+ +
+
+
Function Arguments:

@@ -325,17 +332,20 @@ export function showAgentCitationModal(toolName, toolArgs, toolResult) {
   const toolNameEl = document.getElementById("agent-tool-name");
   const toolArgsEl = document.getElementById("agent-tool-args");
   const toolResultEl = document.getElementById("agent-tool-result");
+  const toolSourceEl = document.getElementById("agent-tool-source");
+  const toolUrlEl = document.getElementById("agent-tool-url");
+  const toolUrlMetaEl = document.getElementById("agent-tool-url-meta");
 
   if (toolNameEl) {
     toolNameEl.textContent = toolName || "Unknown";
   }
   
+  let parsedArgs = null;
   if (toolArgsEl) {
     // Handle empty or no parameters more gracefully
     let argsContent = "";
     
     try {
-      let parsedArgs;
       if (!toolArgs || toolArgs === "" || toolArgs === "{}") {
         argsContent = "No parameters required";
       } else {
@@ -379,9 +389,9 @@ export function showAgentCitationModal(toolName, toolArgs, toolResult) {
   if (toolResultEl) {
     // Handle result formatting and truncation with expand/collapse
     let resultContent = "";
+    let parsedResult = null;
     
     try {
-      let parsedResult;
       if (!toolResult || toolResult === "" || toolResult === "{}") {
         resultContent = "No result";
       } else if (toolResult === "[object Object]") {
@@ -399,6 +409,9 @@ export function showAgentCitationModal(toolName, toolArgs, toolResult) {
     } catch (e) {
       resultContent = toolResult || "No result";
     }
+
+    const citationDetails = extractAgentCitationDetails(parsedResult || parsedArgs);
+    updateAgentCitationSource(toolSourceEl, toolUrlEl, toolUrlMetaEl, citationDetails);
     
     // Add truncation with expand/collapse if content is long
     if (resultContent.length > 300) {
@@ -424,6 +437,63 @@ export function showAgentCitationModal(toolName, toolArgs, toolResult) {
   modal.show();
 }
 
+function extractAgentCitationDetails(source) {
+  if (!source || typeof source !== "object") {
+    return null;
+  }
+
+  const url = source.url;
+  if (!isValidHttpUrl(url)) {
+    return null;
+  }
+
+  return {
+    url,
+    title: source.title || null,
+    quote: source.quote || null,
+    citationType: source.citation_type || null,
+  };
+}
+
+function updateAgentCitationSource(containerEl, linkEl, metaEl, details) {
+  if (!containerEl || !linkEl || !metaEl) {
+    return;
+  }
+
+  if (!details || !details.url) {
+    containerEl.classList.add("d-none");
+    linkEl.textContent = "";
+    linkEl.removeAttribute("href");
+    metaEl.textContent = "";
+    return;
+  }
+
+  containerEl.classList.remove("d-none");
+  linkEl.href = details.url;
+  linkEl.textContent = details.title || details.url;
+
+  const metaParts = [];
+  if (details.citationType) {
+    metaParts.push(`Type: ${details.citationType}`);
+  }
+  if (details.quote) {
+    metaParts.push(`Quote: ${details.quote}`);
+  }
+  metaEl.textContent = metaParts.join(" • ");
+}
+
+function isValidHttpUrl(value) {
+  if (!value || typeof value !== "string") {
+    return false;
+  }
+  try {
+    const parsed = new URL(value);
+    return parsed.protocol === "http:" || parsed.protocol === "https:";
+  } catch (error) {
+    return false;
+  }
+}
+
 // --- MODIFIED: Added citationId parameter and fallback in catch ---
 export function showPdfModal(docId, pageNumber, citationId) {
   const fetchUrl = `/view_pdf?doc_id=${encodeURIComponent(docId)}&page=${encodeURIComponent(pageNumber)}`;
diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js
index 7d1990cfc..9eb3e61f9 100644
--- a/application/single_app/static/js/chat/chat-conversations.js
+++ b/application/single_app/static/js/chat/chat-conversations.js
@@ -242,7 +242,7 @@ export function loadConversations() {
   isLoadingConversations = true;
   conversationsList.innerHTML = '
Loading conversations...
'; // Loading state - fetch("/api/get_conversations") + return fetch("/api/get_conversations") .then(response => response.ok ? response.json() : response.json().then(err => Promise.reject(err))) .then(data => { conversationsList.innerHTML = ""; // Clear loading state @@ -310,6 +310,48 @@ export function loadConversations() { }); } +// Ensure a conversation exists in the list; fetch metadata if missing +export async function ensureConversationPresent(conversationId) { + if (!conversationId) throw new Error('No conversationId provided'); + + // Already in list + const existing = document.querySelector(`.conversation-item[data-conversation-id="${conversationId}"]`); + if (existing) return existing; + + // Fetch metadata to validate ownership and get details + const res = await fetch(`/api/conversations/${conversationId}/metadata`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to load conversation ${conversationId}`); + } + const metadata = await res.json(); + + // Build a conversation object compatible with createConversationItem + const convo = { + id: conversationId, + title: metadata.title || 'Conversation', + last_updated: metadata.last_updated || new Date().toISOString(), + classification: metadata.classification || [], + context: metadata.context || [], + chat_type: metadata.chat_type || null, + is_pinned: metadata.is_pinned || false, + is_hidden: metadata.is_hidden || false, + }; + + // Keep allConversations in sync + allConversations = [convo, ...allConversations.filter(c => c.id !== conversationId)]; + + const convoItem = createConversationItem(convo); + conversationsList.prepend(convoItem); + + // Refresh sidebar so it appears there too + if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { + window.chatSidebarConversations.loadSidebarConversations(); + } + + return convoItem; +} + export function createConversationItem(convo) { const convoItem = document.createElement("div"); // Changed from to
for better semantics with checkboxes convoItem.classList.add("list-group-item", "list-group-item-action", "conversation-item", "d-flex", "align-items-center"); // Use action class @@ -922,6 +964,8 @@ export async function selectConversation(conversationId) { setSidebarActiveConversation(conversationId); } + updateConversationUrl(conversationId); + // Clear any "edit mode" state if switching conversations if (currentlyEditingId && currentlyEditingId !== conversationId) { const editingItem = document.querySelector(`.conversation-item[data-conversation-id="${currentlyEditingId}"]`); @@ -1030,6 +1074,7 @@ export async function createNewConversation(callback) { if (titleEl) { titleEl.textContent = data.title || "New Conversation"; } + updateConversationUrl(data.conversation_id); console.log('[createNewConversation] Created conversation without reload:', data.conversation_id); // Execute callback if provided (e.g., to send the first message) @@ -1567,4 +1612,16 @@ function addChatTypeBadges(convoItem, classificationsEl) { // If chatType is unknown/null or model-only, don't add any workspace badges console.log(`addChatTypeBadges: No badges added for chatType="${chatType}" (likely model-only conversation)`); } +} + +function updateConversationUrl(conversationId) { + if (!conversationId) return; + + try { + const url = new URL(window.location.href); + url.searchParams.set('conversationId', conversationId); + window.history.replaceState({}, '', url.toString()); + } catch (error) { + console.warn('Failed to update conversation URL:', error); + } } \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-input-actions.js b/application/single_app/static/js/chat/chat-input-actions.js index 0325812f5..778513195 100644 --- a/application/single_app/static/js/chat/chat-input-actions.js +++ b/application/single_app/static/js/chat/chat-input-actions.js @@ -347,8 +347,38 @@ if (imageGenBtn) { } if (webSearchBtn) { + const webSearchNoticeContainer = document.getElementById("web-search-notice-container"); + const webSearchNoticeDismiss = document.getElementById("web-search-notice-dismiss"); + const webSearchNoticeSessionKey = "webSearchNoticeDismissed"; + + // Check if notice was dismissed this session + const isNoticeDismissed = () => sessionStorage.getItem(webSearchNoticeSessionKey) === "true"; + + // Show/hide notice based on web search state + const updateWebSearchNotice = (isActive) => { + if (webSearchNoticeContainer && window.appSettings?.enable_web_search_user_notice) { + if (isActive && !isNoticeDismissed()) { + webSearchNoticeContainer.style.display = "block"; + } else { + webSearchNoticeContainer.style.display = "none"; + } + } + }; + + // Dismiss button handler + if (webSearchNoticeDismiss) { + webSearchNoticeDismiss.addEventListener("click", function() { + sessionStorage.setItem(webSearchNoticeSessionKey, "true"); + if (webSearchNoticeContainer) { + webSearchNoticeContainer.style.display = "none"; + } + }); + } + webSearchBtn.addEventListener("click", function () { this.classList.toggle("active"); + const isActive = this.classList.contains("active"); + updateWebSearchNotice(isActive); }); } @@ -374,13 +404,29 @@ if (fileInputEl) { // Hide the upload button since we're auto-uploading uploadBtn.style.display = "none"; - // Automatically upload the file - if (!currentConversationId) { - createNewConversation(() => { + // Check for user agreement before uploading + const doUpload = () => { + if (!currentConversationId) { + createNewConversation(() => { + uploadFileToConversation(file); + }); + } else { uploadFileToConversation(file); - }); + } + }; + + // Check if UserAgreementManager exists and check for agreement + if (window.UserAgreementManager) { + window.UserAgreementManager.checkBeforeUpload( + fileInputEl.files, + 'chat', + 'default', + function(files) { + doUpload(); + } + ); } else { - uploadFileToConversation(file); + doUpload(); } } else { resetFileButton(); @@ -407,12 +453,29 @@ if (uploadBtn) { return; } - if (!currentConversationId) { - createNewConversation(() => { + // Check for user agreement before uploading + const doUpload = () => { + if (!currentConversationId) { + createNewConversation(() => { + uploadFileToConversation(file); + }); + } else { uploadFileToConversation(file); - }); + } + }; + + // Check if UserAgreementManager exists and check for agreement + if (window.UserAgreementManager) { + window.UserAgreementManager.checkBeforeUpload( + fileInput.files, + 'chat', + 'default', + function(files) { + doUpload(); + } + ); } else { - uploadFileToConversation(file); + doUpload(); } }); } diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 48fc6166e..45dbf6f38 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -1460,6 +1460,8 @@ export function actuallySendMessage(finalMessageToSend) { // Fallback: if group_id is null/empty, use window.activeGroupId const finalGroupId = group_id || window.activeGroupId || null; + const webSearchToggle = document.getElementById("search-web-btn"); + const webSearchEnabled = webSearchToggle ? webSearchToggle.classList.contains("active") : false; // Prepare message data object // Get active public workspace ID from user settings (similar to active_group_id) @@ -1469,6 +1471,7 @@ export function actuallySendMessage(finalMessageToSend) { message: finalMessageToSend, conversation_id: currentConversationId, hybrid_search: hybridSearchEnabled, + web_search_enabled: webSearchEnabled, selected_document_id: selectedDocumentId, classifications: classificationsToSend, image_generation: imageGenEnabled, diff --git a/application/single_app/static/js/chat/chat-onload.js b/application/single_app/static/js/chat/chat-onload.js index 2a83b20bd..e20f7240f 100644 --- a/application/single_app/static/js/chat/chat-onload.js +++ b/application/single_app/static/js/chat/chat-onload.js @@ -1,6 +1,6 @@ // chat-onload.js -import { loadConversations } from "./chat-conversations.js"; +import { loadConversations, selectConversation, ensureConversationPresent } from "./chat-conversations.js"; // Import handleDocumentSelectChange import { loadAllDocs, populateDocumentSelectScope, handleDocumentSelectChange } from "./chat-documents.js"; import { getUrlParameter } from "./chat-utils.js"; // Assuming getUrlParameter is in chat-utils.js now @@ -12,10 +12,11 @@ import { initializeStreamingToggle } from "./chat-streaming.js"; import { initializeReasoningToggle } from "./chat-reasoning.js"; import { initializeSpeechInput } from "./chat-speech-input.js"; -window.addEventListener('DOMContentLoaded', () => { +window.addEventListener('DOMContentLoaded', async () => { console.log("DOM Content Loaded. Starting initializations."); // Log start - loadConversations(); // Load conversations immediately + // Load conversations immediately (awaitable so deep-link can run after) + await loadConversations(); // Initialize the conversation info button initConversationInfoButton(); @@ -78,13 +79,13 @@ window.addEventListener('DOMContentLoaded', () => { } // Load documents, prompts, and user settings - Promise.all([ - loadAllDocs(), - loadUserPrompts(), - loadGroupPrompts(), - loadUserSettings() - ]) - .then(([docsResult, userPromptsResult, groupPromptsResult, userSettings]) => { + try { + const [docsResult, userPromptsResult, groupPromptsResult, userSettings] = await Promise.all([ + loadAllDocs(), + loadUserPrompts(), + loadGroupPrompts(), + loadUserSettings() + ]); console.log("Initial data (Docs, Prompts, Settings) loaded successfully."); // Log success // Set the preferred model if available @@ -199,13 +200,24 @@ window.addEventListener('DOMContentLoaded', () => { initializePromptInteractions(); + // Deep-link: conversationId query param + const conversationId = getUrlParameter("conversationId") || getUrlParameter("conversation_id"); + if (conversationId) { + try { + await ensureConversationPresent(conversationId); + await selectConversation(conversationId); + } catch (err) { + console.error('Failed to load conversation from URL param:', err); + showToast('Could not open that conversation.', 'danger'); + } + } + console.log("All initializations complete."); // Log end - }) - .catch((err) => { + } catch (err) { console.error("Error during initial data loading or setup:", err); // Maybe try to initialize prompts even if doc loading fails? Depends on requirements. // console.log("Attempting to initialize prompts despite data load error..."); // initializePromptInteractions(); - }); + } }); diff --git a/application/single_app/static/js/control-center.js b/application/single_app/static/js/control-center.js index e804865ba..bf155fe7b 100644 --- a/application/single_app/static/js/control-center.js +++ b/application/single_app/static/js/control-center.js @@ -1,9 +1,28 @@ - +// control-center.js // Control Center JavaScript functionality // Handles user management, pagination, modals, and API interactions import { showToast } from "./chat/chat-toast.js"; +function parseDateKey(dateStr) { + if (!dateStr) { + return null; + } + + const parts = dateStr.split("-"); + if (parts.length === 3) { + const year = Number(parts[0]); + const month = Number(parts[1]); + const day = Number(parts[2]); + if (Number.isFinite(year) && Number.isFinite(month) && Number.isFinite(day)) { + return new Date(year, month - 1, day); + } + } + + const parsed = new Date(dateStr); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + // Group Table Sorter - similar to user table but for groups class GroupTableSorter { constructor(tableId) { @@ -1423,8 +1442,10 @@ class ControlCenter { const allDates = [...new Set([...Object.keys(createdData), ...Object.keys(deletedData)])].sort(); const labels = allDates.map(date => { - const dateObj = new Date(date); - return dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const dateObj = parseDateKey(date); + return dateObj + ? dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + : date; }); const createdValues = allDates.map(date => createdData[date] || 0); @@ -1553,8 +1574,10 @@ class ControlCenter { console.log(`šŸ” [Frontend Debug] Documents date range:`, allDates); const labels = allDates.map(date => { - const dateObj = new Date(date); - return dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const dateObj = parseDateKey(date); + return dateObj + ? dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + : date; }); // Prepare datasets - lines for creations, bars for deletions @@ -1661,7 +1684,10 @@ class ControlCenter { title: function(context) { const dataIndex = context[0].dataIndex; const dateStr = allDates[dataIndex]; - const date = new Date(dateStr); + const date = parseDateKey(dateStr); + if (!date) { + return dateStr; + } return date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', @@ -1710,7 +1736,7 @@ class ControlCenter { console.log('šŸ” [Frontend Debug] Rendering tokens chart with data:', activityData.tokens); } - // Render combined chart with embedding and chat tokens + // Render combined chart with embedding, chat, and web search tokens this.renderCombinedTokensChart('tokensChart', activityData.tokens || {}); } @@ -1745,7 +1771,7 @@ class ControlCenter { this.tokensChart.destroy(); } - // Prepare data from tokens object (format: { "YYYY-MM-DD": { "embedding": count, "chat": count } }) + // Prepare data from tokens object (format: { "YYYY-MM-DD": { "embedding": count, "chat": count, "web_search": count } }) const allDates = Object.keys(tokensData).sort(); if (appSettings?.enable_debug_logging) { console.log('šŸ” [Frontend Debug] Token dates:', allDates); @@ -1753,17 +1779,21 @@ class ControlCenter { // Format labels for display const labels = allDates.map(dateStr => { - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const date = parseDateKey(dateStr); + return date + ? date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + : dateStr; }); - // Extract embedding and chat token counts + // Extract embedding, chat, and web search token counts const embeddingTokens = allDates.map(date => tokensData[date]?.embedding || 0); const chatTokens = allDates.map(date => tokensData[date]?.chat || 0); + const webSearchTokens = allDates.map(date => tokensData[date]?.web_search || 0); if (appSettings?.enable_debug_logging) { console.log('šŸ” [Frontend Debug] Embedding tokens:', embeddingTokens); console.log('šŸ” [Frontend Debug] Chat tokens:', chatTokens); + console.log('šŸ” [Frontend Debug] Web search tokens:', webSearchTokens); } // Create datasets @@ -1791,6 +1821,18 @@ class ControlCenter { pointRadius: 3, pointHoverRadius: 5, pointBackgroundColor: '#0dcaf0' + }, + { + label: 'Web Search Tokens', + data: webSearchTokens, + backgroundColor: 'rgba(32, 201, 151, 0.2)', + borderColor: '#20c997', + borderWidth: 2, + fill: false, + tension: 0.4, + pointRadius: 3, + pointHoverRadius: 5, + pointBackgroundColor: '#20c997' } ]; @@ -1823,7 +1865,10 @@ class ControlCenter { title: function(context) { const dataIndex = context[0].dataIndex; const dateStr = allDates[dataIndex]; - const date = new Date(dateStr); + const date = parseDateKey(dateStr); + if (!date) { + return dateStr; + } return date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', @@ -1919,8 +1964,10 @@ class ControlCenter { console.log(`šŸ” [Frontend Debug] ${chartType} date range:`, dates); const labels = dates.map(date => { - const dateObj = new Date(date); - return dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + const dateObj = parseDateKey(date); + return dateObj + ? dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + : date; }); const data = dates.map(date => chartData[date] || 0); @@ -1962,7 +2009,10 @@ class ControlCenter { title: function(context) { const dataIndex = context[0].dataIndex; const dateStr = dates[dataIndex]; - const date = new Date(dateStr); + const date = parseDateKey(dateStr); + if (!date) { + return dateStr; + } return date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', diff --git a/application/single_app/static/js/group/manage_group.js b/application/single_app/static/js/group/manage_group.js index 87a12b582..eef65d033 100644 --- a/application/single_app/static/js/group/manage_group.js +++ b/application/single_app/static/js/group/manage_group.js @@ -555,24 +555,45 @@ function rejectRequest(requestId) { }); } +// Search users for manual add // Search users for manual add function searchUsers() { const term = $("#userSearchTerm").val().trim(); if (!term) { - alert("Enter a name or email to search."); + // Show inline validation error + $("#searchStatus").text("āš ļø Please enter a name or email to search"); + $("#searchStatus").removeClass("text-muted text-success").addClass("text-warning"); + $("#userSearchTerm").addClass("is-invalid"); return; } + + // Clear any previous validation states + $("#userSearchTerm").removeClass("is-invalid"); + $("#searchStatus").removeClass("text-warning text-danger text-success").addClass("text-muted"); $("#searchStatus").text("Searching..."); $("#searchUsersBtn").prop("disabled", true); $.get("/api/userSearch", { query: term }) - .done(renderUserSearchResults) + .done(function(users) { + renderUserSearchResults(users); + // Show success status + if (users && users.length > 0) { + $("#searchStatus").text(`āœ“ Found ${users.length} user(s)`); + $("#searchStatus").removeClass("text-muted text-warning text-danger").addClass("text-success"); + } else { + $("#searchStatus").text("No users found"); + $("#searchStatus").removeClass("text-muted text-warning text-success").addClass("text-muted"); + } + }) .fail(function (jq) { const err = jq.responseJSON?.error || jq.statusText; - alert("User search failed: " + err); + // Show inline error + $("#searchStatus").text(`āŒ Search failed: ${err}`); + $("#searchStatus").removeClass("text-muted text-warning text-success").addClass("text-danger"); + // Also show toast for critical errors + showToast("User search failed: " + err, "danger"); }) .always(function () { - $("#searchStatus").text(""); $("#searchUsersBtn").prop("disabled", false); }); } diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index d017f4c28..890760765 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -16,9 +16,11 @@ export class PluginModalStepper { this.filteredTypes = []; this.originalPlugin = null; // Store original state for change tracking this.pluginSchemaCache = null; // Will hold plugin.schema.json + this.pluginDefinitionCache = {}; // Cache for per-type definition schemas this.additionalSettingsSchemaCache = {}; // Cache for additional settings schemas this.lastAdditionalFieldsType = null; // Track last type to avoid unnecessary redraws - this.defaultAuthTypes = ["key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"]; + this.defaultAuthTypes = ["NoAuth", "key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"]; + this.currentAllowedAuthTypes = null; // Active allowed auth types derived from definition this._loadPluginSchema().then(() => { // Load schema on initialization this._populateGenericAuthTypeDropdown(); // Dynamically populate generic auth type dropdown after schema loads (will be called again after schema loads) @@ -37,33 +39,58 @@ export class PluginModalStepper { } } + getAuthTypeEnumFromSchema() { + const authEnum = this.pluginSchemaCache?.definitions?.AuthType?.enum; + return Array.isArray(authEnum) && authEnum.length ? authEnum : null; + } + + async loadPluginDefinition(type) { + const safeType = this.getSafeType(type); + if (!safeType) return null; + + if (Object.prototype.hasOwnProperty.call(this.pluginDefinitionCache, safeType)) { + return this.pluginDefinitionCache[safeType]; + } + + try { + const res = await fetch(`/api/plugins/${encodeURIComponent(type)}/auth-types`); + if (!res.ok) throw new Error(`Auth types fetch failed with status ${res.status}`); + const json = await res.json(); + this.pluginDefinitionCache[safeType] = json; + return json; + } catch (err) { + console.warn(`Failed to load auth types for type '${safeType}':`, err.message || err); + this.pluginDefinitionCache[safeType] = null; + return null; + } + } + + async applyDefinitionForSelectedType(type = this.selectedType) { + this.currentAllowedAuthTypes = null; + + if (type) { + const definition = await this.loadPluginDefinition(type); + const allowed = definition?.allowedAuthTypes; + if (Array.isArray(allowed) && allowed.length) { + this.currentAllowedAuthTypes = allowed; + } + } + + this._populateGenericAuthTypeDropdown(); + } + _populateGenericAuthTypeDropdown() { // Only run if dropdown exists const dropdown = document.getElementById('plugin-auth-type-generic'); if (!dropdown) return; - // If schema not loaded, fallback to static options - if (!this.pluginSchemaCache) { - dropdown.innerHTML = ''; - this.defaultAuthTypes.forEach(type => { - const option = document.createElement('option'); - option.value = type; - option.textContent = this.formatAuthType(type); - dropdown.appendChild(option); - }); - return; - } - // Find the enum for generic auth type in the schema - let authTypeEnum = []; - if (this.pluginSchemaCache.properties && this.pluginSchemaCache.properties.authTypeGeneric) { - authTypeEnum = this.pluginSchemaCache.properties.authTypeGeneric.enum || []; - } - // Fallback: if not found, use a default - if (!authTypeEnum.length) { - authTypeEnum = this.defaultAuthTypes; - } + const fullAuthEnum = this.getAuthTypeEnumFromSchema() || this.defaultAuthTypes; + const allowedList = this.currentAllowedAuthTypes && this.currentAllowedAuthTypes.length + ? this.currentAllowedAuthTypes + : fullAuthEnum; + // Clear existing options dropdown.innerHTML = ''; - authTypeEnum.forEach(type => { + allowedList.forEach(type => { const option = document.createElement('option'); option.value = type; option.textContent = this.formatAuthType(type); @@ -137,6 +164,7 @@ export class PluginModalStepper { // Load available types and populate await this.loadAvailableTypes(); + await this.applyDefinitionForSelectedType(this.selectedType); if (this.isEditMode) { this.populateFormFromPlugin(plugin); @@ -301,6 +329,9 @@ export class PluginModalStepper { document.getElementById('plugin-description').value = typeData.description; } + // Apply auth definition overrides for this type + this.applyDefinitionForSelectedType(typeName).catch(err => console.error('Definition apply failed:', err)); + // Pre-configure for step 3 if needed this.showConfigSectionForType(); } @@ -1841,7 +1872,8 @@ export class PluginModalStepper { 'user': 'User', 'servicePrincipal': 'Service Principal', 'connection_string': 'Connection String', - 'basic': 'Basic' + 'basic': 'Basic', + 'NoAuth': 'No Authentication' }; return authTypeMap[authType] || authType; } @@ -2266,6 +2298,7 @@ export class PluginModalStepper { // Clear any type selection this.selectedType = null; + this.currentAllowedAuthTypes = null; // Hide all auth field sections (with safe calls) try { diff --git a/application/single_app/static/js/public/manage_public_workspace.js b/application/single_app/static/js/public/manage_public_workspace.js index ba1f5b091..3b31ce9b5 100644 --- a/application/single_app/static/js/public/manage_public_workspace.js +++ b/application/single_app/static/js/public/manage_public_workspace.js @@ -1292,3 +1292,4 @@ async function bulkRemoveMembers() { // Reload members and clear selection loadMembers(); } + diff --git a/application/single_app/static/js/public/public_workspace.js b/application/single_app/static/js/public/public_workspace.js index 751920db4..f48c096d3 100644 --- a/application/single_app/static/js/public/public_workspace.js +++ b/application/single_app/static/js/public/public_workspace.js @@ -75,15 +75,15 @@ document.addEventListener('DOMContentLoaded', ()=>{ if (btnChangePublic) btnChangePublic.onclick = onChangeActivePublic; // Upload functionality - handle both button click and drag-and-drop - if (uploadBtn) uploadBtn.onclick = onPublicUploadClick; + if (uploadBtn) uploadBtn.onclick = () => checkUserAgreementBeforePublicUpload(); // Add upload area functionality (drag-and-drop and click-to-browse) const uploadArea = document.getElementById('upload-area'); if (fileInput && uploadArea) { - // Auto-upload on file selection + // Auto-upload on file selection (with user agreement check) fileInput.addEventListener('change', () => { if (fileInput.files && fileInput.files.length > 0) { - onPublicUploadClick(); + checkUserAgreementBeforePublicUpload(); } }); @@ -113,9 +113,9 @@ document.addEventListener('DOMContentLoaded', ()=>{ uploadArea.classList.remove('dragover'); uploadArea.style.borderColor = ''; if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) { - // Set the files to the file input and trigger upload + // Set the files to the file input and trigger upload with user agreement check fileInput.files = e.dataTransfer.files; - onPublicUploadClick(); + checkUserAgreementBeforePublicUpload(); } }); } @@ -454,6 +454,32 @@ function renderPublicDocsPagination(page, pageSize, totalCount){ ul.append(make(page-1,'Ā«',page<=1,false)); let start=1,end=totalPages; if(totalPages>5){ const mid=2; if(page>mid) start=page-mid; end=start+4; if(end>totalPages){ end=totalPages; start=end-4; } } if(start>1){ ul.append(make(1,'1',false,false)); ul.append(make(0,'...',true,false)); } for(let p=start;p<=end;p++) ul.append(make(p,p,false,p===page)); if(end=totalPages,false)); container.append(ul); } +/** + * Check for user agreement before public workspace upload + * Wraps onPublicUploadClick with user agreement check + */ +function checkUserAgreementBeforePublicUpload() { + if (!fileInput || !fileInput.files || fileInput.files.length === 0) { + alert('Select files'); + return; + } + + // Check for user agreement before uploading + if (window.UserAgreementManager && activePublicId) { + window.UserAgreementManager.checkBeforeUpload( + fileInput.files, + 'public', + activePublicId, + function(files) { + // Proceed with upload + onPublicUploadClick(); + } + ); + } else { + onPublicUploadClick(); + } +} + async function onPublicUploadClick() { if (!fileInput) return alert('File input not found'); const files = fileInput.files; diff --git a/application/single_app/static/js/user-agreement.js b/application/single_app/static/js/user-agreement.js new file mode 100644 index 000000000..9cff525d5 --- /dev/null +++ b/application/single_app/static/js/user-agreement.js @@ -0,0 +1,244 @@ +// user-agreement.js +// Shared module for User Agreement prompts before file uploads + +/** + * User Agreement Manager + * Handles checking and prompting for user agreement acceptance before file uploads + */ +window.UserAgreementManager = (function() { + 'use strict'; + + let modal = null; + let pendingCallback = null; + let pendingFiles = null; + + /** + * Initialize the User Agreement Manager + * Sets up event listeners for the modal + */ + function init() { + // Get modal element + const modalEl = document.getElementById('userAgreementUploadModal'); + if (!modalEl) { + console.warn('[UserAgreement] Modal element not found'); + return; + } + + modal = new bootstrap.Modal(modalEl); + + // Accept button handler + const acceptBtn = document.getElementById('userAgreementUploadAcceptBtn'); + if (acceptBtn) { + acceptBtn.addEventListener('click', function() { + onAccept(); + }); + } + + // Cancel button handler - clear pending state + const cancelBtn = document.getElementById('userAgreementUploadCancelBtn'); + if (cancelBtn) { + cancelBtn.addEventListener('click', function() { + onCancel(); + }); + } + + // Modal close handler (X button or backdrop click) + modalEl.addEventListener('hidden.bs.modal', function() { + // If modal was closed without accepting, treat as cancel + if (pendingCallback) { + pendingCallback = null; + pendingFiles = null; + } + }); + + console.log('[UserAgreement] Manager initialized'); + } + + /** + * Check if user agreement is required for a workspace type + * @param {string} workspaceType - 'personal', 'group', 'public', or 'chat' + * @param {string} workspaceId - The workspace ID (can be empty for personal/chat) + * @returns {Promise} - { needsAgreement, agreementText, enableDailyAcceptance } + */ + async function checkAgreement(workspaceType, workspaceId) { + try { + const params = new URLSearchParams({ + workspace_type: workspaceType, + workspace_id: workspaceId || 'default', + action_context: 'file_upload' + }); + + const response = await fetch(`/api/user_agreement/check?${params.toString()}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + console.warn('[UserAgreement] Check failed:', response.status); + return { needsAgreement: false }; + } + + return await response.json(); + } catch (error) { + console.error('[UserAgreement] Error checking agreement:', error); + return { needsAgreement: false }; + } + } + + /** + * Record that user accepted the agreement + * @param {string} workspaceType - 'personal', 'group', 'public', or 'chat' + * @param {string} workspaceId - The workspace ID + * @returns {Promise} - Success status + */ + async function recordAcceptance(workspaceType, workspaceId) { + try { + const response = await fetch('/api/user_agreement/accept', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + workspace_type: workspaceType, + workspace_id: workspaceId || 'default', + action_context: 'file_upload' + }) + }); + + if (!response.ok) { + console.warn('[UserAgreement] Accept failed:', response.status); + return false; + } + + const data = await response.json(); + return data.success === true; + } catch (error) { + console.error('[UserAgreement] Error recording acceptance:', error); + return false; + } + } + + /** + * Show the user agreement modal + * @param {string} agreementText - The agreement text (markdown) + * @param {boolean} enableDailyAcceptance - Whether daily acceptance is enabled + */ + function showModal(agreementText, enableDailyAcceptance) { + const contentDiv = document.getElementById('userAgreementUploadContent'); + const dailyCheckDiv = document.getElementById('userAgreementUploadDailyCheck'); + + if (contentDiv) { + // Render markdown if marked is available + if (typeof marked !== 'undefined') { + let html = marked.parse(agreementText); + // Sanitize if DOMPurify is available + if (typeof DOMPurify !== 'undefined') { + html = DOMPurify.sanitize(html); + } + contentDiv.innerHTML = html; + } else { + // Fallback: preserve line breaks + contentDiv.textContent = agreementText; + } + } + + // Show/hide daily acceptance info + if (dailyCheckDiv) { + dailyCheckDiv.style.display = enableDailyAcceptance ? 'block' : 'none'; + } + + // Show modal + if (modal) { + modal.show(); + } + } + + /** + * Handle accept button click + */ + async function onAccept() { + if (!pendingCallback || !pendingFiles) { + if (modal) modal.hide(); + return; + } + + // Get workspace info from pending state + const workspaceType = pendingFiles.workspaceType; + const workspaceId = pendingFiles.workspaceId; + + // Record acceptance + await recordAcceptance(workspaceType, workspaceId); + + // Hide modal + if (modal) modal.hide(); + + // Execute the pending callback with the files + const callback = pendingCallback; + const files = pendingFiles.files; + + // Clear pending state + pendingCallback = null; + pendingFiles = null; + + // Execute upload + callback(files); + } + + /** + * Handle cancel button click + */ + function onCancel() { + pendingCallback = null; + pendingFiles = null; + if (modal) modal.hide(); + } + + /** + * Check for user agreement and prompt if needed before file upload + * @param {FileList|File[]} files - The files to upload + * @param {string} workspaceType - 'personal', 'group', 'public', or 'chat' + * @param {string} workspaceId - The workspace ID + * @param {Function} uploadCallback - Function to call with files if agreement is accepted + * @returns {Promise} - True if upload should proceed immediately, false if modal is shown + */ + async function checkBeforeUpload(files, workspaceType, workspaceId, uploadCallback) { + if (!files || files.length === 0) { + return false; + } + + // Check if agreement is needed + const result = await checkAgreement(workspaceType, workspaceId); + + if (!result.needsAgreement) { + // No agreement needed, proceed with upload + uploadCallback(files); + return true; + } + + // Agreement is needed - show modal + pendingCallback = uploadCallback; + pendingFiles = { + files: files, + workspaceType: workspaceType, + workspaceId: workspaceId + }; + + showModal(result.agreementText, result.enableDailyAcceptance); + return false; + } + + // Public API + return { + init: init, + checkBeforeUpload: checkBeforeUpload, + checkAgreement: checkAgreement, + recordAcceptance: recordAcceptance + }; +})(); + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', function() { + window.UserAgreementManager.init(); +}); diff --git a/application/single_app/static/js/validateAgent.mjs b/application/single_app/static/js/validateAgent.mjs deleted file mode 100644 index a65b75a94..000000000 --- a/application/single_app/static/js/validateAgent.mjs +++ /dev/null @@ -1 +0,0 @@ -"use strict";export const validate = validate10;export default validate10;const schema11 = {"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#/definitions/Agent","definitions":{"Agent":{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}","description":"Agent ID = GUID (UUID v4 pattern) and possible userId/groupId"},"user_id":{"type":"string","description":"User ID that owns this personal agent"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"display_name":{"type":"string"},"description":{"type":"string"},"azure_openai_gpt_endpoint":{"type":"string"},"azure_openai_gpt_key":{"type":"string"},"azure_openai_gpt_deployment":{"type":"string"},"azure_openai_gpt_api_version":{"type":"string"},"azure_agent_apim_gpt_endpoint":{"type":"string"},"azure_agent_apim_gpt_subscription_key":{"type":"string"},"azure_agent_apim_gpt_deployment":{"type":"string"},"azure_agent_apim_gpt_api_version":{"type":"string"},"enable_agent_gpt_apim":{"type":"boolean"},"default_agent":{"type":"boolean","description":"(deprecated) Use selected_agent for agent selection."},"is_global":{"type":"boolean","description":"True if this agent is a global agent; required for agent selection and UI badging.","default":false},"instructions":{"type":"string"},"actions_to_load":{"type":"array","items":{"type":"string"}},"other_settings":{"type":"object"},"max_completion_tokens":{"type":"integer","minimum":-1,"maximum":512000,"default":4096}},"required":["id","name","display_name","description","is_global","instructions","actions_to_load","other_settings","max_completion_tokens"],"title":"Agent"}}};const schema12 = {"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}","description":"Agent ID = GUID (UUID v4 pattern) and possible userId/groupId"},"user_id":{"type":"string","description":"User ID that owns this personal agent"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"display_name":{"type":"string"},"description":{"type":"string"},"azure_openai_gpt_endpoint":{"type":"string"},"azure_openai_gpt_key":{"type":"string"},"azure_openai_gpt_deployment":{"type":"string"},"azure_openai_gpt_api_version":{"type":"string"},"azure_agent_apim_gpt_endpoint":{"type":"string"},"azure_agent_apim_gpt_subscription_key":{"type":"string"},"azure_agent_apim_gpt_deployment":{"type":"string"},"azure_agent_apim_gpt_api_version":{"type":"string"},"enable_agent_gpt_apim":{"type":"boolean"},"default_agent":{"type":"boolean","description":"(deprecated) Use selected_agent for agent selection."},"is_global":{"type":"boolean","description":"True if this agent is a global agent; required for agent selection and UI badging.","default":false},"instructions":{"type":"string"},"actions_to_load":{"type":"array","items":{"type":"string"}},"other_settings":{"type":"object"},"max_completion_tokens":{"type":"integer","minimum":-1,"maximum":512000,"default":4096}},"required":["id","name","display_name","description","is_global","instructions","actions_to_load","other_settings","max_completion_tokens"],"title":"Agent"};const func2 = Object.prototype.hasOwnProperty;const pattern0 = new RegExp("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "u");const pattern1 = new RegExp("^[A-Za-z0-9_-]+$", "u");function validate10(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs0 = errors;if(errors === _errs0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((((((((((data.id === undefined) && (missing0 = "id")) || ((data.name === undefined) && (missing0 = "name"))) || ((data.display_name === undefined) && (missing0 = "display_name"))) || ((data.description === undefined) && (missing0 = "description"))) || ((data.is_global === undefined) && (missing0 = "is_global"))) || ((data.instructions === undefined) && (missing0 = "instructions"))) || ((data.actions_to_load === undefined) && (missing0 = "actions_to_load"))) || ((data.other_settings === undefined) && (missing0 = "other_settings"))) || ((data.max_completion_tokens === undefined) && (missing0 = "max_completion_tokens"))){validate10.errors = [{instancePath,schemaPath:"#/definitions/Agent/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {const _errs2 = errors;for(const key0 in data){if(!(func2.call(schema12.properties, key0))){validate10.errors = [{instancePath,schemaPath:"#/definitions/Agent/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs2 === errors){if(data.id !== undefined){let data0 = data.id;const _errs3 = errors;if(errors === _errs3){if(typeof data0 === "string"){if(!pattern0.test(data0)){validate10.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Agent/properties/id/pattern",keyword:"pattern",params:{pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"},message:"must match pattern \""+"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"+"\""}];return false;}}else {validate10.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Agent/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}}var valid1 = _errs3 === errors;}else {var valid1 = true;}if(valid1){if(data.user_id !== undefined){const _errs5 = errors;if(typeof data.user_id !== "string"){validate10.errors = [{instancePath:instancePath+"/user_id",schemaPath:"#/definitions/Agent/properties/user_id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs5 === errors;}else {var valid1 = true;}if(valid1){if(data.last_updated !== undefined){const _errs7 = errors;if(typeof data.last_updated !== "string"){validate10.errors = [{instancePath:instancePath+"/last_updated",schemaPath:"#/definitions/Agent/properties/last_updated/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs7 === errors;}else {var valid1 = true;}if(valid1){if(data.name !== undefined){let data3 = data.name;const _errs9 = errors;if(errors === _errs9){if(typeof data3 === "string"){if(!pattern1.test(data3)){validate10.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Agent/properties/name/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9_-]+$"},message:"must match pattern \""+"^[A-Za-z0-9_-]+$"+"\""}];return false;}}else {validate10.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Agent/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}}var valid1 = _errs9 === errors;}else {var valid1 = true;}if(valid1){if(data.display_name !== undefined){const _errs11 = errors;if(typeof data.display_name !== "string"){validate10.errors = [{instancePath:instancePath+"/display_name",schemaPath:"#/definitions/Agent/properties/display_name/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs11 === errors;}else {var valid1 = true;}if(valid1){if(data.description !== undefined){const _errs13 = errors;if(typeof data.description !== "string"){validate10.errors = [{instancePath:instancePath+"/description",schemaPath:"#/definitions/Agent/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs13 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_endpoint !== undefined){const _errs15 = errors;if(typeof data.azure_openai_gpt_endpoint !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_endpoint",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs15 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_key !== undefined){const _errs17 = errors;if(typeof data.azure_openai_gpt_key !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_key",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs17 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_deployment !== undefined){const _errs19 = errors;if(typeof data.azure_openai_gpt_deployment !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_deployment",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_deployment/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs19 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_openai_gpt_api_version !== undefined){const _errs21 = errors;if(typeof data.azure_openai_gpt_api_version !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_openai_gpt_api_version",schemaPath:"#/definitions/Agent/properties/azure_openai_gpt_api_version/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs21 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_endpoint !== undefined){const _errs23 = errors;if(typeof data.azure_agent_apim_gpt_endpoint !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_endpoint",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs23 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_subscription_key !== undefined){const _errs25 = errors;if(typeof data.azure_agent_apim_gpt_subscription_key !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_subscription_key",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_subscription_key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs25 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_deployment !== undefined){const _errs27 = errors;if(typeof data.azure_agent_apim_gpt_deployment !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_deployment",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_deployment/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs27 === errors;}else {var valid1 = true;}if(valid1){if(data.azure_agent_apim_gpt_api_version !== undefined){const _errs29 = errors;if(typeof data.azure_agent_apim_gpt_api_version !== "string"){validate10.errors = [{instancePath:instancePath+"/azure_agent_apim_gpt_api_version",schemaPath:"#/definitions/Agent/properties/azure_agent_apim_gpt_api_version/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs29 === errors;}else {var valid1 = true;}if(valid1){if(data.enable_agent_gpt_apim !== undefined){const _errs31 = errors;if(typeof data.enable_agent_gpt_apim !== "boolean"){validate10.errors = [{instancePath:instancePath+"/enable_agent_gpt_apim",schemaPath:"#/definitions/Agent/properties/enable_agent_gpt_apim/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs31 === errors;}else {var valid1 = true;}if(valid1){if(data.default_agent !== undefined){const _errs33 = errors;if(typeof data.default_agent !== "boolean"){validate10.errors = [{instancePath:instancePath+"/default_agent",schemaPath:"#/definitions/Agent/properties/default_agent/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs33 === errors;}else {var valid1 = true;}if(valid1){if(data.is_global !== undefined){const _errs35 = errors;if(typeof data.is_global !== "boolean"){validate10.errors = [{instancePath:instancePath+"/is_global",schemaPath:"#/definitions/Agent/properties/is_global/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"}];return false;}var valid1 = _errs35 === errors;}else {var valid1 = true;}if(valid1){if(data.instructions !== undefined){const _errs37 = errors;if(typeof data.instructions !== "string"){validate10.errors = [{instancePath:instancePath+"/instructions",schemaPath:"#/definitions/Agent/properties/instructions/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs37 === errors;}else {var valid1 = true;}if(valid1){if(data.actions_to_load !== undefined){let data18 = data.actions_to_load;const _errs39 = errors;if(errors === _errs39){if(Array.isArray(data18)){var valid2 = true;const len0 = data18.length;for(let i0=0; i0 512000 || isNaN(data21)){validate10.errors = [{instancePath:instancePath+"/max_completion_tokens",schemaPath:"#/definitions/Agent/properties/max_completion_tokens/maximum",keyword:"maximum",params:{comparison: "<=", limit: 512000},message:"must be <= 512000"}];return false;}else {if(data21 < -1 || isNaN(data21)){validate10.errors = [{instancePath:instancePath+"/max_completion_tokens",schemaPath:"#/definitions/Agent/properties/max_completion_tokens/minimum",keyword:"minimum",params:{comparison: ">=", limit: -1},message:"must be >= -1"}];return false;}}}}var valid1 = _errs45 === errors;}else {var valid1 = true;}}}}}}}}}}}}}}}}}}}}}}}}else {validate10.errors = [{instancePath,schemaPath:"#/definitions/Agent/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate10.errors = vErrors;return errors === 0;} \ No newline at end of file diff --git a/application/single_app/static/js/validatePlugin.mjs b/application/single_app/static/js/validatePlugin.mjs deleted file mode 100644 index ae4ad0168..000000000 --- a/application/single_app/static/js/validatePlugin.mjs +++ /dev/null @@ -1 +0,0 @@ -"use strict";export const validate = validate11;export default validate11;const schema13 = {"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#/definitions/Plugin","definitions":{"Plugin":{"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","description":"Plugin unique identifier (UUID)"},"user_id":{"type":"string","description":"User ID that owns this personal plugin"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"displayName":{"type":"string","description":"Human-readable display name for the plugin"},"type":{"type":"string"},"description":{"type":"string"},"endpoint":{"type":"string"},"auth":{"type":"object","properties":{"type":{"type":"string","enum":["NoAuth","key","identity","user","servicePrincipal","connection_string","basic","username_password"],"description":"Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'"},"key":{"type":"string","description":"The secret value for the plugin should be stored here, such as a SQL connection string, a password for a service principal or username/password combination"},"identity":{"type":"string","description":"This could be the Id of an (managed) identity, a user name, or similar to pair with the key, in most situations"},"tenantId":{"type":"string","description":"The Azure AD tenant ID used with Service Principal authentication"}},"additionalProperties":false,"allOf":[{"if":{"properties":{"type":{"const":"NoAuth"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"key"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"identity"}}},"then":{"required":["type","identity"]}},{"if":{"properties":{"type":{"const":"user"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"servicePrincipal"}}},"then":{"required":["type","tenantId","identity","key"]}},{"if":{"properties":{"type":{"const":"connection_string"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"basic"}}},"then":{"required":["type","key","identity"]}},{"if":{"properties":{"type":{"const":"username_password"}}},"then":{"required":["type","key","identity"]}},{"required":["type"]}]},"metadata":{"type":"object","description":"Arbitrary metadata","additionalProperties":true},"additionalFields":{"type":"object","description":"Additional fields for plugin configuration based on plugin type. See plugin documentation for details. Any fields named __Secret (double underscore) will be stored in key vault if the feature is enabled.","additionalProperties":true}},"required":["name","type","description","endpoint","auth","metadata","additionalFields"],"title":"Plugin"}}};const schema14 = {"type":"object","additionalProperties":false,"properties":{"id":{"type":"string","description":"Plugin unique identifier (UUID)"},"user_id":{"type":"string","description":"User ID that owns this personal plugin"},"last_updated":{"type":"string","description":"ISO timestamp of last update"},"name":{"type":"string","pattern":"^[A-Za-z0-9_-]+$","description":"Alphanumeric, underscore, and dash only"},"displayName":{"type":"string","description":"Human-readable display name for the plugin"},"type":{"type":"string"},"description":{"type":"string"},"endpoint":{"type":"string"},"auth":{"type":"object","properties":{"type":{"type":"string","enum":["NoAuth","key","identity","user","servicePrincipal","connection_string","basic","username_password"],"description":"Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'"},"key":{"type":"string","description":"The secret value for the plugin should be stored here, such as a SQL connection string, a password for a service principal or username/password combination"},"identity":{"type":"string","description":"This could be the Id of an (managed) identity, a user name, or similar to pair with the key, in most situations"},"tenantId":{"type":"string","description":"The Azure AD tenant ID used with Service Principal authentication"}},"additionalProperties":false,"allOf":[{"if":{"properties":{"type":{"const":"NoAuth"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"key"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"identity"}}},"then":{"required":["type","identity"]}},{"if":{"properties":{"type":{"const":"user"}}},"then":{"required":["type"]}},{"if":{"properties":{"type":{"const":"servicePrincipal"}}},"then":{"required":["type","tenantId","identity","key"]}},{"if":{"properties":{"type":{"const":"connection_string"}}},"then":{"required":["type","key"]}},{"if":{"properties":{"type":{"const":"basic"}}},"then":{"required":["type","key","identity"]}},{"if":{"properties":{"type":{"const":"username_password"}}},"then":{"required":["type","key","identity"]}},{"required":["type"]}]},"metadata":{"type":"object","description":"Arbitrary metadata","additionalProperties":true},"additionalFields":{"type":"object","description":"Additional fields for plugin configuration based on plugin type. See plugin documentation for details. Any fields named __Secret (double underscore) will be stored in key vault if the feature is enabled.","additionalProperties":true}},"required":["name","type","description","endpoint","auth","metadata","additionalFields"],"title":"Plugin"};const func2 = Object.prototype.hasOwnProperty;const pattern1 = new RegExp("^[A-Za-z0-9_-]+$", "u");function validate11(data, {instancePath="", parentData, parentDataProperty, rootData=data}={}){let vErrors = null;let errors = 0;const _errs0 = errors;if(errors === _errs0){if(data && typeof data == "object" && !Array.isArray(data)){let missing0;if((((((((data.name === undefined) && (missing0 = "name")) || ((data.type === undefined) && (missing0 = "type"))) || ((data.description === undefined) && (missing0 = "description"))) || ((data.endpoint === undefined) && (missing0 = "endpoint"))) || ((data.auth === undefined) && (missing0 = "auth"))) || ((data.metadata === undefined) && (missing0 = "metadata"))) || ((data.additionalFields === undefined) && (missing0 = "additionalFields"))){validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/required",keyword:"required",params:{missingProperty: missing0},message:"must have required property '"+missing0+"'"}];return false;}else {const _errs2 = errors;for(const key0 in data){if(!(func2.call(schema14.properties, key0))){validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"}];return false;break;}}if(_errs2 === errors){if(data.id !== undefined){const _errs3 = errors;if(typeof data.id !== "string"){validate11.errors = [{instancePath:instancePath+"/id",schemaPath:"#/definitions/Plugin/properties/id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs3 === errors;}else {var valid1 = true;}if(valid1){if(data.user_id !== undefined){const _errs5 = errors;if(typeof data.user_id !== "string"){validate11.errors = [{instancePath:instancePath+"/user_id",schemaPath:"#/definitions/Plugin/properties/user_id/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs5 === errors;}else {var valid1 = true;}if(valid1){if(data.last_updated !== undefined){const _errs7 = errors;if(typeof data.last_updated !== "string"){validate11.errors = [{instancePath:instancePath+"/last_updated",schemaPath:"#/definitions/Plugin/properties/last_updated/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs7 === errors;}else {var valid1 = true;}if(valid1){if(data.name !== undefined){let data3 = data.name;const _errs9 = errors;if(errors === _errs9){if(typeof data3 === "string"){if(!pattern1.test(data3)){validate11.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Plugin/properties/name/pattern",keyword:"pattern",params:{pattern: "^[A-Za-z0-9_-]+$"},message:"must match pattern \""+"^[A-Za-z0-9_-]+$"+"\""}];return false;}}else {validate11.errors = [{instancePath:instancePath+"/name",schemaPath:"#/definitions/Plugin/properties/name/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}}var valid1 = _errs9 === errors;}else {var valid1 = true;}if(valid1){if(data.displayName !== undefined){const _errs11 = errors;if(typeof data.displayName !== "string"){validate11.errors = [{instancePath:instancePath+"/displayName",schemaPath:"#/definitions/Plugin/properties/displayName/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs11 === errors;}else {var valid1 = true;}if(valid1){if(data.type !== undefined){const _errs13 = errors;if(typeof data.type !== "string"){validate11.errors = [{instancePath:instancePath+"/type",schemaPath:"#/definitions/Plugin/properties/type/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs13 === errors;}else {var valid1 = true;}if(valid1){if(data.description !== undefined){const _errs15 = errors;if(typeof data.description !== "string"){validate11.errors = [{instancePath:instancePath+"/description",schemaPath:"#/definitions/Plugin/properties/description/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs15 === errors;}else {var valid1 = true;}if(valid1){if(data.endpoint !== undefined){const _errs17 = errors;if(typeof data.endpoint !== "string"){validate11.errors = [{instancePath:instancePath+"/endpoint",schemaPath:"#/definitions/Plugin/properties/endpoint/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid1 = _errs17 === errors;}else {var valid1 = true;}if(valid1){if(data.auth !== undefined){let data8 = data.auth;const _errs19 = errors;const _errs21 = errors;const _errs22 = errors;let valid3 = true;const _errs23 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("NoAuth" !== data8.type){const err0 = {};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}}var _valid0 = _errs23 === errors;errors = _errs22;if(vErrors !== null){if(_errs22){vErrors.length = _errs22;}else {vErrors = null;}}if(_valid0){const _errs25 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing1;if((data8.type === undefined) && (missing1 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/0/then/required",keyword:"required",params:{missingProperty: missing1},message:"must have required property '"+missing1+"'"}];return false;}}var _valid0 = _errs25 === errors;valid3 = _valid0;}if(!valid3){const err1 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/0/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs21 === errors;if(valid2){const _errs26 = errors;const _errs27 = errors;let valid5 = true;const _errs28 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("key" !== data8.type){const err2 = {};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}}var _valid1 = _errs28 === errors;errors = _errs27;if(vErrors !== null){if(_errs27){vErrors.length = _errs27;}else {vErrors = null;}}if(_valid1){const _errs30 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing2;if(((data8.type === undefined) && (missing2 = "type")) || ((data8.key === undefined) && (missing2 = "key"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/1/then/required",keyword:"required",params:{missingProperty: missing2},message:"must have required property '"+missing2+"'"}];return false;}}var _valid1 = _errs30 === errors;valid5 = _valid1;}if(!valid5){const err3 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/1/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs26 === errors;if(valid2){const _errs31 = errors;const _errs32 = errors;let valid7 = true;const _errs33 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("identity" !== data8.type){const err4 = {};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}}var _valid2 = _errs33 === errors;errors = _errs32;if(vErrors !== null){if(_errs32){vErrors.length = _errs32;}else {vErrors = null;}}if(_valid2){const _errs35 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing3;if(((data8.type === undefined) && (missing3 = "type")) || ((data8.identity === undefined) && (missing3 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/2/then/required",keyword:"required",params:{missingProperty: missing3},message:"must have required property '"+missing3+"'"}];return false;}}var _valid2 = _errs35 === errors;valid7 = _valid2;}if(!valid7){const err5 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/2/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs31 === errors;if(valid2){const _errs36 = errors;const _errs37 = errors;let valid9 = true;const _errs38 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("user" !== data8.type){const err6 = {};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}}var _valid3 = _errs38 === errors;errors = _errs37;if(vErrors !== null){if(_errs37){vErrors.length = _errs37;}else {vErrors = null;}}if(_valid3){const _errs40 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing4;if((data8.type === undefined) && (missing4 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/3/then/required",keyword:"required",params:{missingProperty: missing4},message:"must have required property '"+missing4+"'"}];return false;}}var _valid3 = _errs40 === errors;valid9 = _valid3;}if(!valid9){const err7 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/3/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs36 === errors;if(valid2){const _errs41 = errors;const _errs42 = errors;let valid11 = true;const _errs43 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("servicePrincipal" !== data8.type){const err8 = {};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}var _valid4 = _errs43 === errors;errors = _errs42;if(vErrors !== null){if(_errs42){vErrors.length = _errs42;}else {vErrors = null;}}if(_valid4){const _errs45 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing5;if(((((data8.type === undefined) && (missing5 = "type")) || ((data8.tenantId === undefined) && (missing5 = "tenantId"))) || ((data8.identity === undefined) && (missing5 = "identity"))) || ((data8.key === undefined) && (missing5 = "key"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/4/then/required",keyword:"required",params:{missingProperty: missing5},message:"must have required property '"+missing5+"'"}];return false;}}var _valid4 = _errs45 === errors;valid11 = _valid4;}if(!valid11){const err9 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/4/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs41 === errors;if(valid2){const _errs46 = errors;const _errs47 = errors;let valid13 = true;const _errs48 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("connection_string" !== data8.type){const err10 = {};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}}var _valid5 = _errs48 === errors;errors = _errs47;if(vErrors !== null){if(_errs47){vErrors.length = _errs47;}else {vErrors = null;}}if(_valid5){const _errs50 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing6;if(((data8.type === undefined) && (missing6 = "type")) || ((data8.key === undefined) && (missing6 = "key"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/5/then/required",keyword:"required",params:{missingProperty: missing6},message:"must have required property '"+missing6+"'"}];return false;}}var _valid5 = _errs50 === errors;valid13 = _valid5;}if(!valid13){const err11 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/5/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs46 === errors;if(valid2){const _errs51 = errors;const _errs52 = errors;let valid15 = true;const _errs53 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("basic" !== data8.type){const err12 = {};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}var _valid6 = _errs53 === errors;errors = _errs52;if(vErrors !== null){if(_errs52){vErrors.length = _errs52;}else {vErrors = null;}}if(_valid6){const _errs55 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing7;if((((data8.type === undefined) && (missing7 = "type")) || ((data8.key === undefined) && (missing7 = "key"))) || ((data8.identity === undefined) && (missing7 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/6/then/required",keyword:"required",params:{missingProperty: missing7},message:"must have required property '"+missing7+"'"}];return false;}}var _valid6 = _errs55 === errors;valid15 = _valid6;}if(!valid15){const err13 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/6/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs51 === errors;if(valid2){const _errs56 = errors;const _errs57 = errors;let valid17 = true;const _errs58 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){if(data8.type !== undefined){if("username_password" !== data8.type){const err14 = {};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}}var _valid7 = _errs58 === errors;errors = _errs57;if(vErrors !== null){if(_errs57){vErrors.length = _errs57;}else {vErrors = null;}}if(_valid7){const _errs60 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing8;if((((data8.type === undefined) && (missing8 = "type")) || ((data8.key === undefined) && (missing8 = "key"))) || ((data8.identity === undefined) && (missing8 = "identity"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/7/then/required",keyword:"required",params:{missingProperty: missing8},message:"must have required property '"+missing8+"'"}];return false;}}var _valid7 = _errs60 === errors;valid17 = _valid7;}if(!valid17){const err15 = {instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/7/if",keyword:"if",params:{failingKeyword: "then"},message:"must match \"then\" schema"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;validate11.errors = vErrors;return false;}var valid2 = _errs56 === errors;if(valid2){const _errs61 = errors;if(data8 && typeof data8 == "object" && !Array.isArray(data8)){let missing9;if((data8.type === undefined) && (missing9 = "type")){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/allOf/8/required",keyword:"required",params:{missingProperty: missing9},message:"must have required property '"+missing9+"'"}];return false;}}var valid2 = _errs61 === errors;}}}}}}}}if(errors === _errs19){if(data8 && typeof data8 == "object" && !Array.isArray(data8)){const _errs62 = errors;for(const key1 in data8){if(!((((key1 === "type") || (key1 === "key")) || (key1 === "identity")) || (key1 === "tenantId"))){validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1},message:"must NOT have additional properties"}];return false;break;}}if(_errs62 === errors){if(data8.type !== undefined){let data17 = data8.type;const _errs63 = errors;if(typeof data17 !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/type",schemaPath:"#/definitions/Plugin/properties/auth/properties/type/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}if(!((((((((data17 === "NoAuth") || (data17 === "key")) || (data17 === "identity")) || (data17 === "user")) || (data17 === "servicePrincipal")) || (data17 === "connection_string")) || (data17 === "basic")) || (data17 === "username_password"))){validate11.errors = [{instancePath:instancePath+"/auth/type",schemaPath:"#/definitions/Plugin/properties/auth/properties/type/enum",keyword:"enum",params:{allowedValues: schema14.properties.auth.properties.type.enum},message:"must be equal to one of the allowed values"}];return false;}var valid19 = _errs63 === errors;}else {var valid19 = true;}if(valid19){if(data8.key !== undefined){const _errs65 = errors;if(typeof data8.key !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/key",schemaPath:"#/definitions/Plugin/properties/auth/properties/key/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid19 = _errs65 === errors;}else {var valid19 = true;}if(valid19){if(data8.identity !== undefined){const _errs67 = errors;if(typeof data8.identity !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/identity",schemaPath:"#/definitions/Plugin/properties/auth/properties/identity/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid19 = _errs67 === errors;}else {var valid19 = true;}if(valid19){if(data8.tenantId !== undefined){const _errs69 = errors;if(typeof data8.tenantId !== "string"){validate11.errors = [{instancePath:instancePath+"/auth/tenantId",schemaPath:"#/definitions/Plugin/properties/auth/properties/tenantId/type",keyword:"type",params:{type: "string"},message:"must be string"}];return false;}var valid19 = _errs69 === errors;}else {var valid19 = true;}}}}}}else {validate11.errors = [{instancePath:instancePath+"/auth",schemaPath:"#/definitions/Plugin/properties/auth/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs19 === errors;}else {var valid1 = true;}if(valid1){if(data.metadata !== undefined){let data21 = data.metadata;const _errs71 = errors;if(errors === _errs71){if(data21 && typeof data21 == "object" && !Array.isArray(data21)){}else {validate11.errors = [{instancePath:instancePath+"/metadata",schemaPath:"#/definitions/Plugin/properties/metadata/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs71 === errors;}else {var valid1 = true;}if(valid1){if(data.additionalFields !== undefined){let data22 = data.additionalFields;const _errs74 = errors;if(errors === _errs74){if(data22 && typeof data22 == "object" && !Array.isArray(data22)){}else {validate11.errors = [{instancePath:instancePath+"/additionalFields",schemaPath:"#/definitions/Plugin/properties/additionalFields/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}var valid1 = _errs74 === errors;}else {var valid1 = true;}}}}}}}}}}}}}}else {validate11.errors = [{instancePath,schemaPath:"#/definitions/Plugin/type",keyword:"type",params:{type: "object"},message:"must be object"}];return false;}}validate11.errors = vErrors;return errors === 0;} \ No newline at end of file diff --git a/application/single_app/static/js/workspace-manager.js b/application/single_app/static/js/workspace-manager.js index d109351f5..7b540af74 100644 --- a/application/single_app/static/js/workspace-manager.js +++ b/application/single_app/static/js/workspace-manager.js @@ -411,6 +411,9 @@ window.WorkspaceManager = { // Load workspace members for ownership transfer dropdown await WorkspaceManager.loadWorkspaceMembersForTransfer(workspaceId); + // Load retention settings if enabled + await WorkspaceManager.loadRetentionSettings(workspace); + // Store current workspace ID for saving changes document.getElementById('publicWorkspaceManagementModal').setAttribute('data-workspace-id', workspaceId); @@ -485,6 +488,54 @@ window.WorkspaceManager = { } }, + // Load retention settings for public workspace + loadRetentionSettings: async function(workspace) { + const convSelect = document.getElementById('publicConversationRetention'); + const docSelect = document.getElementById('publicDocumentRetention'); + + // Check if retention policy elements exist (feature might be disabled) + if (!convSelect || !docSelect) { + return; + } + + try { + // Fetch organization defaults for public workspace retention + const orgDefaultsResp = await fetch('/api/retention-policy/defaults/public'); + const orgData = await orgDefaultsResp.json(); + + if (orgData.success) { + const convDefaultOption = convSelect.querySelector('option[value="default"]'); + const docDefaultOption = docSelect.querySelector('option[value="default"]'); + + if (convDefaultOption) { + convDefaultOption.textContent = `Using organization default (${orgData.default_conversation_label})`; + } + if (docDefaultOption) { + docDefaultOption.textContent = `Using organization default (${orgData.default_document_label})`; + } + } + } catch (error) { + console.error('Error loading public retention defaults:', error); + } + + // Set current values from workspace + if (workspace.retention_policy) { + let convRetention = workspace.retention_policy.conversation_retention_days; + let docRetention = workspace.retention_policy.document_retention_days; + + // If undefined, use 'default' + if (convRetention === undefined) convRetention = 'default'; + if (docRetention === undefined) docRetention = 'default'; + + convSelect.value = convRetention; + docSelect.value = docRetention; + } else { + // Set to organization default if no retention policy set + convSelect.value = 'default'; + docSelect.value = 'default'; + } + }, + // Save workspace changes saveWorkspaceChanges: async function() { const workspaceId = document.getElementById('publicWorkspaceManagementModal').getAttribute('data-workspace-id'); @@ -556,6 +607,26 @@ window.WorkspaceManager = { } } + // Save retention policy settings if enabled + const convRetentionSelect = document.getElementById('publicConversationRetention'); + const docRetentionSelect = document.getElementById('publicDocumentRetention'); + + if (convRetentionSelect && docRetentionSelect) { + const retentionResponse = await fetch(`/api/retention-policy/public/${workspaceId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + conversation_retention_days: convRetentionSelect.value, + document_retention_days: docRetentionSelect.value + }) + }); + + if (!retentionResponse.ok) { + console.error('Failed to save retention policy'); + // Don't throw - allow other changes to succeed + } + } + showToast('Workspace updated successfully!', 'success'); // Close modal and refresh table diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js index 27f11bd25..f6695fa32 100644 --- a/application/single_app/static/js/workspace/workspace-documents.js +++ b/application/single_app/static/js/workspace/workspace-documents.js @@ -354,10 +354,22 @@ async function uploadWorkspaceFiles(files) { // Upload Button Handler const uploadArea = document.getElementById("upload-area"); if (fileInput && uploadArea && uploadStatusSpan) { - // Auto-upload on file selection + // Auto-upload on file selection (with user agreement check) fileInput.addEventListener("change", () => { if (fileInput.files && fileInput.files.length > 0) { - uploadWorkspaceFiles(fileInput.files); + // Check for user agreement before uploading + if (window.UserAgreementManager) { + window.UserAgreementManager.checkBeforeUpload( + fileInput.files, + 'personal', + 'default', + function(files) { + uploadWorkspaceFiles(files); + } + ); + } else { + uploadWorkspaceFiles(fileInput.files); + } } }); @@ -385,7 +397,19 @@ if (fileInput && uploadArea && uploadStatusSpan) { uploadArea.classList.remove("dragover"); uploadArea.style.borderColor = ""; if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) { - uploadWorkspaceFiles(e.dataTransfer.files); + // Check for user agreement before uploading (drag-and-drop) + if (window.UserAgreementManager) { + window.UserAgreementManager.checkBeforeUpload( + e.dataTransfer.files, + 'personal', + 'default', + function(files) { + uploadWorkspaceFiles(files); + } + ); + } else { + uploadWorkspaceFiles(e.dataTransfer.files); + } } }); } diff --git a/application/single_app/static/json/schemas/agent.schema.json b/application/single_app/static/json/schemas/agent.schema.json index 7ec0eaa6a..402304c8d 100644 --- a/application/single_app/static/json/schemas/agent.schema.json +++ b/application/single_app/static/json/schemas/agent.schema.json @@ -31,16 +31,19 @@ "type": "string" }, "azure_openai_gpt_endpoint": { - "type": "string" + "type": "string", + "description": "Endpoint for Azure OpenAI (local agents) or Azure AI Foundry (foundry agents)." }, "azure_openai_gpt_key": { "type": "string" }, "azure_openai_gpt_deployment": { - "type": "string" + "type": "string", + "description": "Model deployment for local SK agents or Foundry project/workspace identifier for Azure AI agents." }, "azure_openai_gpt_api_version": { - "type": "string" + "type": "string", + "description": "API version for Azure OpenAI (local) or Azure AI Agents (foundry)." }, "azure_agent_apim_gpt_endpoint": { "type": "string" @@ -78,8 +81,8 @@ }, "agent_type": { "type": "string", - "enum": ["local", "aifoundry", "copilot"], - "description": "Type of agent that needs to be instantiated." + "enum": ["local", "aifoundry"], + "description": "Type of agent to instantiate." }, "instructions": { "type": "string" @@ -89,7 +92,7 @@ "items": { "type": "string" } }, "other_settings": { - "type": "object" + "$ref": "#/definitions/OtherSettings" }, "max_completion_tokens": { "type": "integer", @@ -111,7 +114,67 @@ "max_completion_tokens", "agent_type" ], - "title": "Agent" + "title": "Agent", + "allOf": [ + { + "if": { + "properties": { + "agent_type": { "const": "aifoundry" } + } + }, + "then": { + "required": [ + "azure_openai_gpt_endpoint", + "azure_openai_gpt_deployment", + "azure_openai_gpt_api_version", + "other_settings" + ], + "properties": { + "actions_to_load": { + "type": "array", + "maxItems": 0, + "description": "Azure AI Foundry agents manage tools within Azure and must not specify local plugins." + }, + "other_settings": { + "$ref": "#/definitions/FoundrySettingsWrapper" + } + } + } + } + ] + }, + "OtherSettings": { + "type": "object", + "additionalProperties": true, + "properties": { + "azure_ai_foundry": { + "$ref": "#/definitions/AzureAIFoundrySettings" + } + } + }, + "FoundrySettingsWrapper": { + "type": "object", + "required": ["azure_ai_foundry"], + "properties": { + "azure_ai_foundry": { + "$ref": "#/definitions/AzureAIFoundrySettings" + } + } + }, + "AzureAIFoundrySettings": { + "type": "object", + "additionalProperties": true, + "properties": { + "agent_id": { + "type": "string", + "description": "Identifier of the Azure AI Foundry agent to invoke." + }, + "notes": { + "type": "string", + "description": "Optional helper text for administrators managing Foundry agents." + } + }, + "required": ["agent_id"] } } } diff --git a/application/single_app/static/json/schemas/blob_storage.definition.json b/application/single_app/static/json/schemas/blob_storage.definition.json new file mode 100644 index 000000000..317ef0da6 --- /dev/null +++ b/application/single_app/static/json/schemas/blob_storage.definition.json @@ -0,0 +1,7 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "identity", + "key" + ] +} diff --git a/application/single_app/static/json/schemas/databricks_table.definition.json b/application/single_app/static/json/schemas/databricks_table.definition.json new file mode 100644 index 000000000..80ee040c5 --- /dev/null +++ b/application/single_app/static/json/schemas/databricks_table.definition.json @@ -0,0 +1,6 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "key" + ] +} diff --git a/application/single_app/static/json/schemas/embedding_model.definition.json b/application/single_app/static/json/schemas/embedding_model.definition.json new file mode 100644 index 000000000..80ee040c5 --- /dev/null +++ b/application/single_app/static/json/schemas/embedding_model.definition.json @@ -0,0 +1,6 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "key" + ] +} diff --git a/application/single_app/static/json/schemas/log_analytics.definition.json b/application/single_app/static/json/schemas/log_analytics.definition.json new file mode 100644 index 000000000..c372bd155 --- /dev/null +++ b/application/single_app/static/json/schemas/log_analytics.definition.json @@ -0,0 +1,9 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "identity", + "servicePrincipal", + "user", + "key" + ] +} \ No newline at end of file diff --git a/application/single_app/static/json/schemas/msgraph.definition.json b/application/single_app/static/json/schemas/msgraph.definition.json new file mode 100644 index 000000000..94a3e16e1 --- /dev/null +++ b/application/single_app/static/json/schemas/msgraph.definition.json @@ -0,0 +1,6 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "user" + ] +} diff --git a/application/single_app/static/json/schemas/openapi.definition.json b/application/single_app/static/json/schemas/openapi.definition.json new file mode 100644 index 000000000..80ee040c5 --- /dev/null +++ b/application/single_app/static/json/schemas/openapi.definition.json @@ -0,0 +1,6 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "key" + ] +} diff --git a/application/single_app/static/json/schemas/plugin.definition.schema.json b/application/single_app/static/json/schemas/plugin.definition.schema.json index e69de29bb..a44342120 100644 --- a/application/single_app/static/json/schemas/plugin.definition.schema.json +++ b/application/single_app/static/json/schemas/plugin.definition.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "plugin.definition.schema.json", + "title": "Plugin Definition", + "description": "Controls plugin creation constraints such as allowed authentication types.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Optional schema reference for tooling." + }, + "allowedAuthTypes": { + "type": "array", + "description": "List of auth types this plugin supports. Values must match auth.type in plugin.schema.json.", + "items": { + "$ref": "plugin.schema.json#/definitions/AuthType" + }, + "uniqueItems": true, + "minItems": 1 + } + }, + "required": ["allowedAuthTypes"] +} \ No newline at end of file diff --git a/application/single_app/static/json/schemas/plugin.schema.json b/application/single_app/static/json/schemas/plugin.schema.json index c9b80f8b9..9add32ab2 100644 --- a/application/single_app/static/json/schemas/plugin.schema.json +++ b/application/single_app/static/json/schemas/plugin.schema.json @@ -2,6 +2,20 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "#/definitions/Plugin", "definitions": { + "AuthType": { + "type": "string", + "enum": [ + "NoAuth", + "key", + "identity", + "user", + "servicePrincipal", + "connection_string", + "basic", + "username_password" + ], + "description": "Supported authentication types for plugins." + }, "Plugin": { "type": "object", "additionalProperties": false, @@ -40,9 +54,8 @@ "type": "object", "properties": { "type": { - "type": "string", - "enum": ["NoAuth", "key", "identity", "user", "servicePrincipal", "connection_string", "basic", "username_password"], - "description": "Auth type must be 'key', 'user', 'identity', 'servicePrincipal', 'connection_string', 'basic', or 'username_password'" + "$ref": "#/definitions/AuthType", + "description": "Auth type must be one of the supported authentication modes." }, "key": { "type": "string", diff --git a/application/single_app/static/json/schemas/queue_storage.definition.json b/application/single_app/static/json/schemas/queue_storage.definition.json new file mode 100644 index 000000000..317ef0da6 --- /dev/null +++ b/application/single_app/static/json/schemas/queue_storage.definition.json @@ -0,0 +1,7 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "identity", + "key" + ] +} diff --git a/application/single_app/static/json/schemas/sql_query.definition.json b/application/single_app/static/json/schemas/sql_query.definition.json new file mode 100644 index 000000000..d38a41a88 --- /dev/null +++ b/application/single_app/static/json/schemas/sql_query.definition.json @@ -0,0 +1,6 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "connection_string" + ] +} diff --git a/application/single_app/static/json/schemas/sql_schema.definition.json b/application/single_app/static/json/schemas/sql_schema.definition.json new file mode 100644 index 000000000..d38a41a88 --- /dev/null +++ b/application/single_app/static/json/schemas/sql_schema.definition.json @@ -0,0 +1,6 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "connection_string" + ] +} diff --git a/application/single_app/static/json/schemas/ui_test.definition.json b/application/single_app/static/json/schemas/ui_test.definition.json new file mode 100644 index 000000000..2b5876d9c --- /dev/null +++ b/application/single_app/static/json/schemas/ui_test.definition.json @@ -0,0 +1,6 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "NoAuth" + ] +} diff --git a/application/single_app/templates/_agent_config_info.html b/application/single_app/templates/_agent_config_info.html new file mode 100644 index 000000000..a088e8f59 --- /dev/null +++ b/application/single_app/templates/_agent_config_info.html @@ -0,0 +1,299 @@ +{% from '_agent_examples.html' import agent_examples %} + + + + diff --git a/application/single_app/templates/_agent_examples.html b/application/single_app/templates/_agent_examples.html new file mode 100644 index 000000000..7e5237048 --- /dev/null +++ b/application/single_app/templates/_agent_examples.html @@ -0,0 +1,45 @@ +{% macro agent_examples(accordion_id='agentExamples', show_copy_buttons=True, show_create_buttons=False) %} + +{% endmacro %} diff --git a/application/single_app/templates/_agent_examples_modal.html b/application/single_app/templates/_agent_examples_modal.html new file mode 100644 index 000000000..52f95cdc8 --- /dev/null +++ b/application/single_app/templates/_agent_examples_modal.html @@ -0,0 +1,629 @@ + + + + + diff --git a/application/single_app/templates/_agent_modal.html b/application/single_app/templates/_agent_modal.html index 1f9775bb9..80f068cab 100644 --- a/application/single_app/templates/_agent_modal.html +++ b/application/single_app/templates/_agent_modal.html @@ -2,13 +2,37 @@ @@ -1661,13 +1661,18 @@
Retention Policy
- Configure automatic deletion of aged conversations and documents. Set to "No automatic deletion" to keep items indefinitely. + Configure automatic deletion of aged conversations and documents. You can use the organization default or set a custom retention period.
+ + + + + @@ -2259,6 +2269,59 @@
Member Management
+ + {% if app_settings.enable_retention_policy_public %} +
+
+
Retention Policy
+
+
+
+ + Configure automatic deletion of aged conversations and documents. You can use the organization default or set a custom retention period. +
+
+
+ + +
+
+ + +
+
+
+
+
+
+ {% endif %} +
@@ -3186,22 +3249,43 @@
Group Prompts
Group Agents
- +
+ + +
You do not have permission to manage group agents. @@ -1294,10 +1300,22 @@
Currently Shared With:
// Manual button click (fallback) const uploadArea = document.getElementById("upload-area"); if (groupFileInput && uploadArea && groupUploadStatusSpan) { - // Auto-upload on file selection + // Auto-upload on file selection (with user agreement check) groupFileInput.addEventListener("change", () => { if (groupFileInput.files && groupFileInput.files.length > 0) { - uploadGroupFiles(groupFileInput.files); + // Check for user agreement before uploading + if (window.UserAgreementManager && activeGroupId) { + window.UserAgreementManager.checkBeforeUpload( + groupFileInput.files, + 'group', + activeGroupId, + function(files) { + uploadGroupFiles(files); + } + ); + } else { + uploadGroupFiles(groupFileInput.files); + } } }); @@ -1324,7 +1342,19 @@
Currently Shared With:
uploadArea.classList.remove("dragover"); uploadArea.style.borderColor = ""; if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) { - uploadGroupFiles(e.dataTransfer.files); + // Check for user agreement before uploading (drag-and-drop) + if (window.UserAgreementManager && activeGroupId) { + window.UserAgreementManager.checkBeforeUpload( + e.dataTransfer.files, + 'group', + activeGroupId, + function(files) { + uploadGroupFiles(files); + } + ); + } else { + uploadGroupFiles(e.dataTransfer.files); + } } }); } diff --git a/application/single_app/templates/index.html b/application/single_app/templates/index.html index 2e182e671..7a146e0d5 100644 --- a/application/single_app/templates/index.html +++ b/application/single_app/templates/index.html @@ -16,7 +16,7 @@ height="100" class="mb-4 d-light-mode-only"> {% else %} - Logo @@ -33,16 +33,20 @@ height="100" class="mb-4 d-dark-mode-only"> {% else %} - Logo {% endif %} {% else %} - Logo + class="mb-4 d-light-mode-only"> + Logo {% endif %} {% endif %} diff --git a/application/single_app/templates/profile.html b/application/single_app/templates/profile.html index d444cf3be..e5a628873 100644 --- a/application/single_app/templates/profile.html +++ b/application/single_app/templates/profile.html @@ -308,7 +308,7 @@
Retention Policy Sett
- Default: Set to "No automatic deletion" means items are never automatically deleted. Choose a retention period to enable automatic cleanup. + Default: You can use the organization default or set your own retention period. Choose "No automatic deletion" to keep items indefinitely.
@@ -316,6 +316,7 @@
Retention Policy Sett
+ @@ -1055,24 +1057,52 @@
Your Prompts
Your Agents
- +
+ + +
diff --git a/deployers/azure.yaml b/deployers/azure.yaml index 90a40cc9a..4ab51aa9c 100644 --- a/deployers/azure.yaml +++ b/deployers/azure.yaml @@ -4,21 +4,37 @@ metadata: infra: provider: bicep path: bicep +services: + web: + project: ../application/single_app + language: python + host: appservice + docker: + context: ../../ + dockerfile: application/single_app/Dockerfile hooks: postprovision: + # this is run after the infrastructure has been provisioned but before services are deployed. + # primary use is to configure application settings and permissions posix: shell: sh run: | + set -e + + echo "========================================" + echo "POST-PROVISION: Starting configuration" + echo "========================================" + # Set up variables - + export var_acrName=${var_acrname} export var_configureApplication=${var_configureApplication} export var_cosmosDb_uri=${var_cosmosDb_uri} + export var_cosmosDb_accountName=${var_cosmosDb_accountName} export var_subscriptionId=${AZURE_SUBSCRIPTION_ID} export var_rgName=${var_rgName} export var_keyVaultUri=${var_keyVaultUri} - + export var_keyVaultName=${var_keyVaultName} export var_authenticationType=${var_authenticationType} - export var_openAIEndpoint=${var_openAIEndpoint} export var_openAIResourceGroup=${var_openAIResourceGroup} export var_openAIGPTModel=${var_openAIGPTModel} @@ -27,6 +43,7 @@ hooks: export var_contentSafetyEndpoint=${var_contentSafetyEndpoint} export var_searchServiceEndpoint=${var_searchServiceEndpoint} export var_documentIntelligenceServiceEndpoint=${var_documentIntelligenceServiceEndpoint} + export var_redisCacheHostName=${var_redisCacheHostName} export var_videoIndexerName=${var_videoIndexerName} export var_deploymentLocation=${var_deploymentLocation} export var_videoIndexerAccountId=${var_videoIndexerAccountId} @@ -34,43 +51,219 @@ hooks: # Execute post-configuration script if enabled if [ "${var_configureApplication}" = "true" ]; then - echo "Grant permissions to CosmosDB for post deployment steps..." - bash ./bicep/cosmosDb-postDeployPerms.sh - echo "Running post-deployment configuration..." - python3 -m pip install --user -r ./bicep/requirements.txt - python3 ./bicep/postconfig.py - echo "Post-deployment configuration completed." - echo "Restarting web service to apply new settings..." - az webapp restart --name ${var_webService} --resource-group ${var_rgName} - echo "Web service restarted." + echo "" + echo "[1/4] Granting permissions to CosmosDB..." + if bash ./bicep/cosmosDb-postDeployPerms.sh; then + echo "āœ“ CosmosDB permissions granted successfully" + else + echo "āœ— ERROR: Failed to grant CosmosDB permissions" >&2 + exit 1 + fi + + echo "" + echo "[2/4] Installing Python dependencies..." + if python3 -m pip install --user -r ./bicep/requirements.txt > /dev/null 2>&1; then + echo "āœ“ Dependencies installed successfully" + else + echo "āœ— ERROR: Failed to install Python dependencies" >&2 + exit 1 + fi + + echo "" + echo "[3/4] Running post-deployment configuration..." + if python3 ./bicep/postconfig.py; then + echo "āœ“ Post-deployment configuration completed" + else + echo "āœ— ERROR: Post-deployment configuration failed" >&2 + exit 1 + fi + + echo "" + echo "[4/4] Restarting web service to apply settings..." + if az webapp restart --name ${var_webService} --resource-group ${var_rgName}; then + echo "āœ“ Web service restarted successfully" + else + echo "āœ— ERROR: Failed to restart web service" >&2 + exit 1 + fi + + echo "" + echo "========================================" + echo "POST-PROVISION: Completed successfully" + echo "========================================" else - echo "Skipping post-deployment configuration (var_configureApplication is not true)" + echo "" + echo "ℹ Skipping post-deployment configuration (var_configureApplication is not true)" + echo "" + echo "========================================" + echo "POST-PROVISION: Completed (skipped)" + echo "========================================" fi predeploy: + # this is run after infrastructure and postprovisioning but before service deployment + # primary use is to build and push container images posix: shell: sh run: | - # Build and push Docker image to ACR + set -e + + # Error handling function + cleanup_on_error() { + local exit_code=$? + echo "" + echo "āœ— ERROR: Deployment failed at step: $1" >&2 + echo "Attempting to restart web service..." >&2 + az webapp start --name ${var_webService} --resource-group ${var_rgName} 2>/dev/null || true + exit ${exit_code} + } + + echo "========================================" + echo "PRE-DEPLOY: Building and pushing image" + echo "========================================" + cd .. timestamp="$(date +"%Y%m%d-%H%M%S")" - echo "Stopping web service prior to deployment..." - az webapp stop --name ${var_webService} --resource-group ${var_rgName} - echo "Building Docker image..." - docker build -f application/single_app/Dockerfile -t ${var_containerRegistry}/${var_imageName}:${timestamp} . - docker tag ${var_containerRegistry}/${var_imageName}:${timestamp} ${var_containerRegistry}/${var_imageName}:latest - echo "Logging in to ACR..." - az acr login --name ${var_acrName} - echo "Pushing image to ACR..." - docker push ${var_containerRegistry}/${var_imageName}:latest - docker push ${var_containerRegistry}/${var_imageName}:${timestamp} - echo "Restarting web service..." - az webapp start --name ${var_webService} --resource-group ${var_rgName} -services: - web: - project: ../application/single_app - language: python - host: appservice - docker: - context: ../../ - dockerfile: application/single_app/Dockerfile \ No newline at end of file + echo "" + echo "Deployment timestamp: ${timestamp}" + echo "Image: ${var_containerRegistry}/${var_imageName}:${timestamp}" + + echo "" + echo "[1/6] Stopping web service..." + if az webapp stop --name ${var_webService} --resource-group ${var_rgName}; then + echo "āœ“ Web service stopped successfully" + else + echo "āœ— ERROR: Failed to stop web service" >&2 + exit 1 + fi + + echo "" + echo "[2/6] Building Docker image..." + echo "Context: $(pwd)" + echo "Dockerfile: application/single_app/Dockerfile" + if docker build -f application/single_app/Dockerfile \ + -t ${var_containerRegistry}/${var_imageName}:${timestamp} . ; then + echo "āœ“ Docker image built successfully" + else + cleanup_on_error "Docker build" + fi + + echo "" + echo "[3/6] Tagging image as latest..." + if docker tag ${var_containerRegistry}/${var_imageName}:${timestamp} \ + ${var_containerRegistry}/${var_imageName}:latest ; then + echo "āœ“ Image tagged successfully" + else + cleanup_on_error "Docker tag" + fi + + echo "" + echo "[4/6] Logging in to ACR (${var_acrName})..." + if az acr login --name ${var_acrName}; then + echo "āœ“ ACR login successful" + else + cleanup_on_error "ACR login" + fi + + echo "" + echo "[5/6] Pushing images to ACR..." + echo " → Pushing latest tag..." + if docker push ${var_containerRegistry}/${var_imageName}:latest; then + echo " āœ“ Latest tag pushed successfully" + else + cleanup_on_error "Docker push (latest)" + fi + + echo " → Pushing timestamped tag..." + if docker push ${var_containerRegistry}/${var_imageName}:${timestamp}; then + echo " āœ“ Timestamped tag pushed successfully" + else + cleanup_on_error "Docker push (timestamp)" + fi + + echo "" + echo "[6/6] Restarting web service..." + if az webapp start --name ${var_webService} --resource-group ${var_rgName}; then + echo "āœ“ Web service restarted successfully" + else + echo "āœ— ERROR: Failed to restart web service" >&2 + exit 1 + fi + + echo "" + echo "========================================" + echo "PRE-DEPLOY: Completed successfully" + echo "========================================" + + postup: + # this is the final step to run after everything else is done + # primary use is disable public network access if private endpoints are used + posix: + shell: sh + run: | + set -e + + echo "========================================" + echo "POST-UP: Final configuration" + echo "========================================" + + if [ "${var_enablePrivateNetworking}" = "true" ]; then + echo "" + echo "Configuring private networking..." + + echo "" + echo "[1/4] Disabling public network access for CosmosDB..." + if az cosmosdb update --name ${var_cosmosDb_accountName} \ + --resource-group ${var_rgName} \ + --public-network-access Disabled > /dev/null; then + echo "āœ“ CosmosDB public access disabled" + else + echo "āœ— ERROR: Failed to disable CosmosDB public access" >&2 + exit 1 + fi + + echo "" + echo "[2/4] Disabling public network access for Key Vault..." + if az keyvault update --name ${var_keyVaultName} \ + --resource-group ${var_rgName} \ + --public-network-access Disabled > /dev/null; then + echo "āœ“ Key Vault public access disabled" + else + echo "āœ— ERROR: Failed to disable Key Vault public access" >&2 + exit 1 + fi + + echo "" + echo "[3/4] Disabling public network access for Azure Container Registry..." + if az acr update --name ${var_acrName} \ + --resource-group ${var_rgName} \ + --public-network-enabled false > /dev/null; then + echo "āœ“ ACR public access disabled" + else + echo "āœ— ERROR: Failed to disable ACR public access" >&2 + exit 1 + fi + + echo "" + echo "[4/4] Disabling public network access for Web Application..." + if az resource update --name ${var_webService} \ + --resource-group ${var_rgName} \ + --resource-type "Microsoft.Web/sites" \ + --set properties.publicNetworkAccess=Disabled; then + echo "āœ“ Web Application public access disabled" + else + echo "āœ— ERROR: Failed to disable Web Application public access" >&2 + exit 1 + fi + + echo "" + echo "āœ“ Private networking configured successfully" + else + echo "" + echo "ℹ Skipping private networking configuration (var_enablePrivateNetworking is not true)" + fi + + echo "" + echo "========================================" + echo "āœ“ DEPLOYMENT COMPLETED SUCCESSFULLY" + echo "========================================" \ No newline at end of file diff --git a/deployers/azurecli/appRegistrationRoles.json b/deployers/azurecli/appRegistrationRoles.json index 5fb198cdb..233a5b98e 100644 --- a/deployers/azurecli/appRegistrationRoles.json +++ b/deployers/azurecli/appRegistrationRoles.json @@ -46,5 +46,22 @@ "id": "b0288440-5195-4264-9a33-cf9a4635d634", "isEnabled": true, "value": "ExternalApi" + }, + { + "allowedMemberTypes": [ "User" ], + "description": "Full administrative access to Control Center features", + "displayName": "Control Center Admin", + "id": "fad9b386-9392-4f15-b6df-6b47d8f1e75c", + "isEnabled": true, + "value": "ControlCenterAdmin" + }, + { + "allowedMemberTypes": [ "User" ], + "description": "Read-only access to Control Center dashboard and metrics", + "displayName": "Control Center Dashboard Reader", + "id": "6399b062-9114-49ec-a291-c445a0b2b33e", + "isEnabled": true, + "value": "ControlCenterDashboardReader" } + ] diff --git a/deployers/bicep/OneClickDeploy.md b/deployers/bicep/OneClickDeploy.md index 0c5c931b6..d89c7dd65 100644 --- a/deployers/bicep/OneClickDeploy.md +++ b/deployers/bicep/OneClickDeploy.md @@ -8,9 +8,9 @@ There are pre-deploy manual steps that must be completed first. After you have deployed, there are additional manual steps that will need to be completed as well. -[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoft%2Fsimplechat%2Frefs%2Fheads%2Finfra-deployer-gunger%2Fdeployers%2Fbicep%2Fmain.json) +[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoft%2Fsimplechat%2Frefs%2Fheads%2Fmain%2Fdeployers%2Fbicep%2Fmain.json) -[![Deploy to Azure](https://aka.ms/deploytoazuregovbutton)](https://portal.azure.us/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoft%2Fsimplechat%2Frefs%2Fheads%2Finfra-deployer-gunger%2Fdeployers%2Fbicep%2Fmain.json) +[![Deploy to Azure](https://aka.ms/deploytoazuregovbutton)](https://portal.azure.us/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fmicrosoft%2Fsimplechat%2Frefs%2Fheads%2Fmain%2Fdeployers%2Fbicep%2Fmain.json) ## How to Use diff --git a/deployers/bicep/README.md b/deployers/bicep/README.md index c2c51bf08..0cf883afa 100644 --- a/deployers/bicep/README.md +++ b/deployers/bicep/README.md @@ -3,27 +3,57 @@ >Strongly encourage administrators to use Visual Studio Code and Dev Containers for this deployment type. ## Table of Contents
-- [Deployment Variables](##Deployment_Variables) -- [Deployment Process](##Deployment_Process) - - [Pre-Configuration](###Pre-Configuration) - - [Create the application registration](####Create_the_application_registration) - - [Deployment Process](###Deployment_Process) - - [Configure AZD Environment](####Configure_AZD_Environment) - - [Deployment Prompts](####Deployment_Prompts) - - [Post Deployment Tasks](###Post_Deployment_Tasks) -- [Cleanup / Deprovision](##Cleanup_/_Deprovisioning) -- [Workarounds](##Workarounds) +- [Deployment Variables](#Deployment-Variables) +- [Prerequisites](#Prerequisites) +- [Deployment Process](#Deployment-Process) + - [Pre-Configuration](#Pre-Configuration) + - [Create the application registration](#Create-the-application-registration) + - [Deployment Process](#Deployment-Process-1) + - [Configure AZD Environment](#Configure-AZD-Environment) + - [Deployment Prompts](#Deployment-Prompts) + - [Post Deployment Tasks](#Post-Deployment-Tasks) +- [Cleanup / Deprovision](#Cleanup-/-Deprovisioning) +- [Helpful Info](#Helpful-Info) + - [Private Networking](#Private-Networking) +- [Azure Government (USGov) Considerations](#Azure-Government-USGov-Considerations) +- [Frequently Asked Questions](#Frequently-Asked-Questions) +- [Troubleshooting](#Troubleshooting) --- ## Deployment Variables -The folloiwng variables will be used within this document: +The following variables will be used within this document: - *\* - This will become the beginning of each of the objects created. Minimum of 3 characters, maximum of 12 characters. No Spaces or special characters. - *\* - This will be used as part of the object names as well as with the AZD environments. **Example:** *dev/qa/prod*. - *\* - Options will be *AzureCloud | AzureUSGovernment* - *\* - Should be presented in the form *imageName:label* **Example:** *simple-chat:latest* +--- + +## Prerequisites + +Before deploying, ensure you have: + +1. **Azure Subscription** with Owner or Contributor permissions +2. **Azure CLI** (version 2.50.0 or later) +3. **Azure Developer CLI (azd)** (version 1.5.0 or later) +4. **Docker** installed and running (for container builds) +5. **PowerShell** (for the Entra app registration script) +6. **Permissions to create an Entra ID Application Registration** (or coordinate with your Entra admin) + +### Required Azure Resource Providers +Ensure the following resource providers are registered in your subscription: +- `Microsoft.Web` +- `Microsoft.DocumentDB` +- `Microsoft.CognitiveServices` +- `Microsoft.Search` +- `Microsoft.Storage` +- `Microsoft.KeyVault` +- `Microsoft.ContainerRegistry` +- `Microsoft.Insights` +- `Microsoft.OperationalInsights` + ## Deployment Process @@ -31,7 +61,7 @@ The below steps cover the process to deploy the Simple Chat application to an Az ### Pre-Configuration: -The following procedure must be completed with a user that has permissions to create an application registration in the users Entra tenanat. If this procedure is to be completed by a different user, the following files should be provided: +The following procedure must be completed with a user that has permissions to create an application registration in the users Entra tenant. If this procedure is to be completed by a different user, the following files should be provided: `./deployers/Initialize-EntraApplication.ps1`
`./deployers/azurecli/appRegistrationRoles.json` @@ -95,17 +125,63 @@ Using the bash terminal in Visual Studio Code `azd env new ` - Use the same value for the \ that was used in the application registration. -`azd env select ` - select the new environment +`azd env select ` - select the new environment. + +`azd provision --preview` - identify what will be deployed with the current configuration. `azd up` - This step will begin the deployment process. +#### Service Limitations of USGovCloud + +> āš ļø **Important:** Review this section carefully before deploying to Azure Government. + +- **Services NOT available in Azure Government:** + - Azure Video Indexer - Set `deployVideoIndexerService` to `false` + +- **SKU Restrictions:** + - **GlobalStandard SKU is NOT available** - Azure OpenAI models must use `Standard` SKU instead + - Default deployment uses `GlobalStandard` - override `gptModels` and `embeddingModels` parameters + +- **Model Availability:** + - Verify the `gptModels` and `embeddingModels` model names and versions are available in your target USGov region + - Model availability may differ from Azure Commercial - check [Azure OpenAI Service models](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models) + +- **Limited Regional Availability:** + - ContentSafety - typically only USGov Virginia, USGov Arizona + - SpeechService - verify feature availability (Neural voices may be limited) + - DocumentIntelligence - prebuilt models may differ + +**Example USGov Model Configuration Override:** +```json +{ + "gptModels": [ + { + "modelName": "gpt-4o", + "modelVersion": "2024-05-13", + "skuName": "Standard", + "skuCapacity": 100 + } + ], + "embeddingModels": [ + { + "modelName": "text-embedding-ada-002", + "modelVersion": "2", + "skuName": "Standard", + "skuCapacity": 100 + } + ] +} +``` + #### Deployment Prompts > For each of the following parameters ensure the value noted in *\* matches settings as noted above. +> If you are unsure what a parameter is used for, see specific help for each parameter by entering "?" at that prompt. - Select an Azure Subscription to use: *\* -Provisioning may take between 10-40 minutes depending on the options selected. +Provisioning may take between 5-40 minutes depending on the options selected. On the completion of the deployment, a URL will be presented, the user may use to access the site. @@ -127,33 +205,29 @@ On the completion of the deployment, a URL will be presented, the user may use t ### Post Deployment Tasks: -Once logged in to the newly deployed application with admin credentials, the application will need to be set up with several configurations: +Once logged in to the newly deployed application with admin credentials, review the application configuration in the Admin Settings: -1. AI Models > GPT Configuration & Embeddings Configuration. Application is pre-configured with the chosen security model (key / managed identity). Select "Test GPT Connection" and "Test Embedding Connection" to verify connection. +1. Admin Settings > AI Models > GPT Configuration & Embeddings Configuration. Application is pre-configured with the chosen security model (key / managed identity). Select "Test GPT Connection" and "Test Embedding Connection" to verify connection. - > Known Bug: User will be unable to Fetch GPT or Embedding models.
-Workaround: Set configurations in CosmosDB. For details see [Workarounds](##Workarounds) below. +1. Admin Settings > Scale > Redis Cache (if enabled) - Select "Test Redis Connection" + +1. Admin Settings > Workspaces > Multi-Modal Vision Analysis - Select "Test Vision Analysis" + +1. Admin Settings > Search & Extract > Azure AI Search + > Known Bug: Unable to test "Managed Identity" authentication type. Must use "Key" for validation but application will run under Managed Identity" -1. Logging > Application Insights Logging > "Enable Application Insights Global Logging - Set to "ON" -1. Citations > Ehnahced Citations > "Enable Enhanced Citations" - Set to "ON" - - Configure "All Filetypes" - - "Storage Account Authentication Type" = Managed Identity - - "Storage Account Blob Endpoint" = "https://\\sa.blob.core.windows.net" (or appropiate domain if in Azure Gov.) -1. Safety > Conversation Archiving > "Enable Conversation Archiving" - Set to "ON" -1. Search & Extract > Azure AI Search - - "Search Endpoint" = "https://\-\-search.search.windows.net" (or appropiate domain if in Azure Gov.) - > Known Bug: Unable to configure "Managed Identity" authentication type. Must use "Key" - "Authentication Type" - Key - "Search Key" - *Pre-populated from key vault value*. - At the top of the Admin Page you'll see warning boxes indicating Index Schema Mismatch. - Click "Create user Index" - Click "Create group Index" - - Click "Create public Index" -1. Search & Extract > Document Intelligence - - "Document Intelligence Endpoint" = "https://\-\-docintel.cognitiveservices.azure.com/" (or appropiate domain if in Azure Gov.) - - "Authentication Type" - Managed Identity + - Click "Create public Index" + - Select "Test Azure AI Search Connection" + +1. Search & Extract > Document Intelligence - Select "Test Document Intelligence Connection" + -User shoud now be able to fully use Simple Chat application. +User should now be able to fully use Simple Chat application. --- ## Cleanup / Deprovisioning @@ -163,67 +237,178 @@ User shoud now be able to fully use Simple Chat application. `cd ./deployers`
`azd down --purge` - This will delete all deployed resource for this solution and purge key vault, document intelligence, OpenAI services. +--- +## Helpful Info + +- If Key based authentication is selected, ensure keys are rotated per organizational requirements. + +- If a deployment failure is encountered, often times, rerunning the deployment will clear the temporary error. + +- When private networking is selected, 0.0.0.0 (representing the internal Azure Services) is added to the CosmosDB firewall in addition to any IP's added to the 'allowedIpAddresses' parameter. Users are encouraged to include the IP address of the deployment server in the 'allowedIpAddresses. This becomes not-applicable on completion of the deployment when CosmosDB, Key Vault, Azure Container Registry and the Web Application is configured for private networking only. If the 'allowedIpAddresses parameter is not used, the administrator can manually add in the deployment server IP address to the Settings > Networking section of the coresponding service(s) and rerun the deployment. + +- To evaluate any infrastructure changes between versions, with AZD the user can run: +`azd provision --preview` + +### Private Networking + +When private networking is configured, access from the developers workstation to push updates and new Azure configurations will be blocked. In addition, testing the web application when not on a VPN attached to the private network subnet is expected to be blocked. + +During initial deployment, if post an error is raised "failed running post hooks: 'postprovision'" the deployment is being blocked from executing scripts against the CosmosDB service. Ensure the deployment workstation IP address is added to the "allowedIPAddresses" parameter. Similar messages may be seen from the Azure Container Registry Service. + +When private networking is enabled, to test the web applicaiton, users may configure a VPN into the deployed vNet (space is provided for this) or the administration may adjust the networking limitations to the deployed website. This may be accomplished with the following script: + +`az webapp update --name --app --resource-group --rg --public-network-access Enabled;` + +To permit redeployment of Azure infrastructure services, the following script may be used to enable access when private networking is enabled. + +``` +az cosmosdb update --name --cosmos --resource-group --rg --public-network-access enabled +az keyvault update --name --kv --resource-group --rg --public-network-access enabled +az acr update --name acr --resource-group --rg --public-network-enabled true +az resource update --name --app --resource-group --rg --resource-type "Microsoft.Web/sites" --set properties.publicNetworkAccess=Enabled +``` + +--- + +## Azure Government (USGov) Considerations + +### Services Deployed + +| Service | Azure Commercial | Azure Government | Notes | +|---------|------------------|------------------|-------| +| App Service | āœ… | āœ… | Premium V3 tier | +| Cosmos DB | āœ… | āœ… | Serverless mode | +| Azure OpenAI | āœ… | āœ… | Standard SKU only in USGov | +| Azure AI Search | āœ… | āœ… | Basic tier | +| Document Intelligence | āœ… | āœ… | Limited regions | +| Storage Account | āœ… | āœ… | Standard LRS | +| Key Vault | āœ… | āœ… | Standard tier | +| Container Registry | āœ… | āœ… | Basic tier | +| Application Insights | āœ… | āœ… | | +| Log Analytics | āœ… | āœ… | | +| Content Safety | āœ… | āš ļø Limited | Not all regions | +| Speech Service | āœ… | āš ļø Limited | Feature restrictions | +| Video Indexer | āœ… | āŒ Not Available | | +| Redis Cache | āœ… | āœ… | Standard tier | + +### Endpoint Differences + +The deployment automatically handles the following endpoint differences: +- ACR Domain: `.azurecr.io` → `.azurecr.us` +- Entra Login: `login.microsoftonline.com` → `login.microsoftonline.us` +- OpenID Issuer: `sts.windows.net` → `login.microsoftonline.us` +- Private DNS Zones: Automatically configured for USGov --- -## Workarounds - -- Fetching GPT and Embedding Models. - - Grant the current user data access to Cosmos DB from a BASH command shell - - `PRINCIPAL_ID=$(az ad signed-in-user show --query id --output tsv)` - - `az cosmosdb sql role assignment create --account-name --cosmos --resource-group --rg --principal-id $PRINCIPAL_ID --scope "/" --role-definition-id 00000000-0000-0000-0000-000000000002` - - Open CosmosDB in Azure Portal and connect to the `--cosmos` service. - - Data Explorer > SimpleChat > settings > items - - Replace the following values: - ``` - "gpt_model": { - "selected": [], - "all": [] - }, - ``` - - with - - ``` - "gpt_model": { - "selected": [ - { - "deploymentName": "gpt-4o", - "modelName": "gpt-4o" - } - ], - "all": [ - { - "deploymentName": "gpt-4o", - "modelName": "gpt-4o" - } - ] - }, - ``` - - and - - ``` - "embedding_model": { - "selected": [], - "all": [] - }, - ``` - - with - - ``` - "embedding_model": { - "selected": [ - "deploymentName": "text-embedding-3-small", - "modelName": "text-embedding-3-small" - ], - "all": [ - "deploymentName": "text-embedding-3-small", - "modelName": "text-embedding-3-small" - ] - }, - ``` - - - Update settings in the Cosmos UI and click Save. - - Refresh web page and you shound now be able to Test the GPT and Embedding models. + +## Frequently Asked Questions + +### General Questions + +**Q: How long does deployment take?** +A: Initial deployment typically takes 15-40 minutes depending on options selected. Subsequent deployments are faster. + +**Q: What Azure permissions do I need?** +A: You need Owner or Contributor role on the target subscription, plus ability to create Entra ID app registrations (or work with your Entra admin). + +**Q: Can I deploy to an existing resource group?** +A: No, the deployment creates a new resource group named `--rg`. + +**Q: What is the default authentication type?** +A: You can choose between `key` (API keys stored in Key Vault) or `managed_identity` (recommended for production). + +### Model Configuration + +**Q: How do I customize which GPT models are deployed?** +A: Override the `gptModels` parameter with your desired configuration: +```json +[ + { + "modelName": "gpt-4o", + "modelVersion": "2024-11-20", + "skuName": "GlobalStandard", + "skuCapacity": 100 + } +] +``` + +**Q: What's the difference between GlobalStandard and Standard SKU?** +A: `GlobalStandard` provides access to Azure's global AI infrastructure with higher availability but is not available in Azure Government. `Standard` is region-specific and is required for USGov deployments. + +### Networking + +**Q: Can I deploy without private networking initially and add it later?** +A: Yes, set `enablePrivateNetworking` to `false` initially. You can enable it later but this requires re-running the deployment. + +**Q: Why do I need to add my IP address to allowedIpAddresses?** +A: During deployment, scripts need to access Cosmos DB and other services. Your IP must be allowed through the firewall temporarily. + +### Costs + +**Q: What's the estimated monthly cost?** +A: Base infrastructure (without optional services) costs approximately: +- App Service Plan (P1v3): ~$150/month +- Cosmos DB (Serverless): Pay-per-request +- Azure OpenAI: Pay-per-token +- Azure AI Search (Basic): ~$70/month +- Other services: Variable based on usage + +### Upgrading + +**Q: How do I upgrade to a new version?** +A: Run `azd up` again from the updated codebase. Use `azd provision --preview` to review changes first. + +--- + +## Troubleshooting + +### Common Deployment Errors + +**Error: "failed running post hooks: 'postprovision'"** +- **Cause:** Deployment scripts cannot access Cosmos DB or other services +- **Solution:** Add your IP address to the `allowedIpAddresses` parameter and redeploy + +**Error: "The subscription is not registered to use namespace 'Microsoft.CognitiveServices'"** +- **Cause:** Required resource provider not registered +- **Solution:** Run `az provider register --namespace Microsoft.CognitiveServices` + +**Error: "Quota exceeded for deployment"** +- **Cause:** Azure OpenAI quota limits reached +- **Solution:** Request quota increase or reduce `skuCapacity` in model configuration + +**Error: "InvalidTemplateDeployment - GlobalStandard SKU not available"** +- **Cause:** Attempting USGov deployment with GlobalStandard SKU +- **Solution:** Use `Standard` SKU for all models in Azure Government + +**Error: "Resource 'Microsoft.VideoIndexer/accounts' not found"** +- **Cause:** Video Indexer not available in region (especially USGov) +- **Solution:** Set `deployVideoIndexerService` to `false` + +### Post-Deployment Issues + +**Issue: Cannot access the web application** +- Verify the Entra app registration is configured correctly +- Check that admin consent was granted for API permissions +- Ensure users are assigned to the enterprise application + +**Issue: "Test Connection" fails in Admin Settings** +- For Managed Identity: Wait 5-10 minutes for role assignments to propagate +- For Key Authentication: Verify secrets exist in Key Vault +- Check Application Insights for detailed error logs + +**Issue: AI Search shows "Index Schema Mismatch"** +- This is expected on first deployment +- Click "Create user Index", "Create group Index", "Create public Index" in Admin Settings + +### Logs and Diagnostics + +Enable diagnostic logging by setting `enableDiagLogging` to `true`. Logs are sent to: +- Log Analytics Workspace: `--logs` +- Application Insights: `--ai` + +View application logs: +```bash +az webapp log tail --name --app --resource-group --rg +``` + diff --git a/deployers/bicep/main.bicep b/deployers/bicep/main.bicep index 86e0cfaa4..b336aee94 100644 --- a/deployers/bicep/main.bicep +++ b/deployers/bicep/main.bicep @@ -76,6 +76,10 @@ param specialTags object = {} - Default is false''') param enableDiagLogging bool +@description('''Enable private endpoints and virtual network integration for deployed resources. +- Default is false''') +param enablePrivateNetworking bool + @description('''Array of GPT model names to deploy to the OpenAI resource.''') param gptModels array = [ { @@ -107,7 +111,20 @@ param embeddingModels array = [ skuCapacity: 150 } ] + //---------------- +// allowed IP addresses for resources +@description('''Comma separated list of IP addresses or ranges to allow access to resources when private networking is enabled. +Leave blank if not using private networking. +- Format for single IP: 'x.x.x.x' +- Format for range: 'x.x.x.x/y' +- Example: 1.2.3.4, 2.3.4.5/32 +''') +param allowedIpAddresses string +var allowedIpAddressesSplit = empty(allowedIpAddresses) ? [] : split(allowedIpAddresses!, ',') +var allowedIpAddressesArray = [for ip in allowedIpAddressesSplit: trim(ip)] +//---------------- + // optional services @description('''Enable deployment of Content Safety service and related resources. @@ -136,6 +153,15 @@ var acrCloudSuffix = cloudEnvironment == 'AzureCloud' ? '.azurecr.io' : '.azurec var acrName = toLower('${appName}${environment}acr') var containerRegistry = '${acrName}${acrCloudSuffix}' var containerImageName = '${containerRegistry}/${imageName}' +var vNetName = '${appName}-${environment}-vnet' +var allowedIpsForCosmos = union(['0.0.0.0'], allowedIpAddressesArray) +var cosmosDbIpRules = [for ip in allowedIpsForCosmos: { + ipAddressOrRange: ip +}] +var acrIpRules = [for ip in allowedIpAddressesArray: { + action: 'Allow' + value: ip +}] //========================================================= // Resource group deployment @@ -146,6 +172,34 @@ resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = { tags: tags } +//========================================================= +// Create Virtual Network if private networking is enabled +//========================================================= +module virtualNetwork 'modules/virtualNetwork.bicep' = if (enablePrivateNetworking) { + scope: rg + name: 'virtualNetwork' + params: { + location: location + vNetName: vNetName + addressSpaces: ['10.0.0.0/21'] + subnetConfigs: [ + { + name: 'AppServiceIntegration' // this subnet name must be present for app service vnet integration + addressPrefix: '10.0.0.0/24' + enablePrivateEndpointNetworkPolicies: true + enablePrivateLinkServiceNetworkPolicies: true + } + { + name: 'PrivateEndpoints' // this subnet name must be present if private endpoints are to be used + addressPrefix: '10.0.2.0/24' + enablePrivateEndpointNetworkPolicies: true + enablePrivateLinkServiceNetworkPolicies: true + } + ] + tags: tags + } +} + //========================================================= // Create log analytics workspace //========================================================= @@ -221,6 +275,8 @@ module cosmosDB 'modules/cosmosDb.bicep' = { keyVault: keyVault.outputs.keyVaultName authenticationType: authenticationType configureApplicationPermissions: configureApplicationPermissions + enablePrivateNetworking: enablePrivateNetworking + allowedIpAddresses: cosmosDbIpRules } } @@ -240,6 +296,8 @@ module acr 'modules/azureContainerRegistry.bicep' = { keyVault: keyVault.outputs.keyVaultName authenticationType: authenticationType configureApplicationPermissions: configureApplicationPermissions + enablePrivateNetworking: enablePrivateNetworking + allowedIpAddresses: acrIpRules } } @@ -260,6 +318,8 @@ module searchService 'modules/search.bicep' = { keyVault: keyVault.outputs.keyVaultName authenticationType: authenticationType configureApplicationPermissions: configureApplicationPermissions + + enablePrivateNetworking: enablePrivateNetworking } } @@ -280,6 +340,8 @@ module docIntel 'modules/documentIntelligence.bicep' = { keyVault: keyVault.outputs.keyVaultName authenticationType: authenticationType configureApplicationPermissions: configureApplicationPermissions + + enablePrivateNetworking: enablePrivateNetworking } } @@ -300,6 +362,8 @@ module storageAccount 'modules/storageAccount.bicep' = { keyVault: keyVault.outputs.keyVaultName authenticationType: authenticationType configureApplicationPermissions: configureApplicationPermissions + + enablePrivateNetworking: enablePrivateNetworking } } @@ -323,6 +387,8 @@ module openAI 'modules/openAI.bicep' = { gptModels: gptModels embeddingModels: embeddingModels + + enablePrivateNetworking: enablePrivateNetworking } } @@ -369,6 +435,10 @@ module appService 'modules/appService.bicep' = { enterpriseAppClientSecret: enterpriseAppClientSecret authenticationType: authenticationType keyVaultUri: keyVault.outputs.keyVaultUri + + enablePrivateNetworking: enablePrivateNetworking + #disable-next-line BCP318 // expect one value to be null if private networking is disabled + appServiceSubnetId: enablePrivateNetworking? virtualNetwork.outputs.appServiceSubnetId : '' } } @@ -393,6 +463,8 @@ module contentSafety 'modules/contentSafety.bicep' = if (deployContentSafety) { keyVault: keyVault.outputs.keyVaultName authenticationType: authenticationType configureApplicationPermissions: configureApplicationPermissions + + enablePrivateNetworking: enablePrivateNetworking } } @@ -413,6 +485,8 @@ module redisCache 'modules/redisCache.bicep' = if (deployRedisCache) { keyVault: keyVault.outputs.keyVaultName authenticationType: authenticationType configureApplicationPermissions: configureApplicationPermissions + + //enablePrivateNetworking: enablePrivateNetworking } } @@ -433,6 +507,8 @@ module speechService 'modules/speechService.bicep' = if (deploySpeechService) { keyVault: keyVault.outputs.keyVaultName authenticationType: authenticationType configureApplicationPermissions: configureApplicationPermissions + + enablePrivateNetworking: enablePrivateNetworking } } @@ -452,6 +528,8 @@ module videoIndexerService 'modules/videoIndexer.bicep' = if (deployVideoIndexer storageAccount: storageAccount.outputs.name openAiServiceName: openAI.outputs.openAIName + + enablePrivateNetworking: enablePrivateNetworking } } @@ -472,52 +550,96 @@ module setPermissions 'modules/setPermissions.bicep' = if (configureApplicationP openAIName: openAI.outputs.openAIName docIntelName: docIntel.outputs.documentIntelligenceServiceName storageAccountName: storageAccount.outputs.name + searchServiceName: searchService.outputs.searchServiceName + #disable-next-line BCP318 // expect one value to be null speechServiceName: deploySpeechService ? speechService.outputs.speechServiceName : '' + #disable-next-line BCP318 // expect one value to be null + redisCacheName: deployRedisCache ? redisCache.outputs.redisCacheName : '' + #disable-next-line BCP318 // expect one value to be null + contentSafetyName: deployContentSafety ? contentSafety.outputs.contentSafetyName : '' + #disable-next-line BCP318 // expect one value to be null + videoIndexerName: deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerServiceName : '' + } +} + +//========================================================= +// configure private networking +//========================================================= +module privateNetworking 'modules/privateNetworking.bicep' = if (enablePrivateNetworking) { + name: 'privateNetworking' + scope: rg + params: { + + #disable-next-line BCP318 // value can't be null based on enablePrivateNetworking condition + virtualNetworkId: virtualNetwork.outputs.vNetId + #disable-next-line BCP318 // value can't be null based on enablePrivateNetworking condition + privateEndpointSubnetId: virtualNetwork.outputs.privateNetworkSubnetId + + location: location + appName: appName + environment: environment + tags: tags + + keyVaultName: keyVault.outputs.keyVaultName + cosmosDBName: cosmosDB.outputs.cosmosDbName + acrName: acr.outputs.acrName searchServiceName: searchService.outputs.searchServiceName + docIntelName: docIntel.outputs.documentIntelligenceServiceName + storageAccountName: storageAccount.outputs.name + openAIName: openAI.outputs.openAIName + webAppName: appService.outputs.name + #disable-next-line BCP318 // expect one value to be null contentSafetyName: deployContentSafety ? contentSafety.outputs.contentSafetyName : '' #disable-next-line BCP318 // expect one value to be null + speechServiceName: deploySpeechService ? speechService.outputs.speechServiceName : '' + #disable-next-line BCP318 // expect one value to be null videoIndexerName: deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerServiceName : '' } } + //========================================================= // output values //========================================================= -// output required for both predeploy and postprovision scripts in azure.yaml -output var_rgName string = rgName -// output values required for predeploy script in azure.yaml -output var_webService string = appService.outputs.name -output var_imageName string = contains(imageName, ':') ? split(imageName, ':')[0] : imageName -output var_imageTag string = split(imageName, ':')[1] -output var_containerRegistry string = containerRegistry -output var_acrName string = toLower('${appName}${environment}acr') // output values required for postprovision script in azure.yaml +output var_acrName string = toLower('${appName}${environment}acr') +output var_authenticationType string = toLower(authenticationType) +output var_blobStorageEndpoint string = storageAccount.outputs.endpoint output var_configureApplication bool = configureApplicationPermissions -output var_keyVaultUri string = keyVault.outputs.keyVaultUri +#disable-next-line BCP318 // expect one value to be null +output var_contentSafetyEndpoint string = deployContentSafety ? contentSafety.outputs.contentSafetyEndpoint : '' +output var_cosmosDb_accountName string = cosmosDB.outputs.cosmosDbName output var_cosmosDb_uri string = cosmosDB.outputs.cosmosDbUri -output var_subscriptionId string = subscription().subscriptionId -output var_authenticationType string = toLower(authenticationType) +output var_deploymentLocation string = rg.location +output var_documentIntelligenceServiceEndpoint string = docIntel.outputs.documentIntelligenceServiceEndpoint +output var_keyVaultName string = keyVault.outputs.keyVaultName +output var_keyVaultUri string = keyVault.outputs.keyVaultUri output var_openAIEndpoint string = openAI.outputs.openAIEndpoint -output var_openAIResourceGroup string = openAI.outputs.openAIResourceGroup //may be able to remove output var_openAIGPTModels array = gptModels +output var_openAIResourceGroup string = openAI.outputs.openAIResourceGroup //may be able to remove output var_openAIEmbeddingModels array = embeddingModels -output var_blobStorageEndpoint string = storageAccount.outputs.endpoint #disable-next-line BCP318 // expect one value to be null -output var_contentSafetyEndpoint string = deployContentSafety ? contentSafety.outputs.contentSafetyEndpoint : '' -output var_deploymentLocation string = rg.location +output var_redisCacheHostName string = deployRedisCache ? redisCache.outputs.redisCacheHostName : '' +output var_rgName string = rgName output var_searchServiceEndpoint string = searchService.outputs.searchServiceEndpoint -output var_documentIntelligenceServiceEndpoint string = docIntel.outputs.documentIntelligenceServiceEndpoint -output var_videoIndexerName string = deployVideoIndexerService #disable-next-line BCP318 // expect one value to be null - ? videoIndexerService.outputs.videoIndexerServiceName - : '' -output var_videoIndexerAccountId string = deployVideoIndexerService +output var_speechServiceEndpoint string = deploySpeechService ? speechService.outputs.speechServiceEndpoint : '' +output var_subscriptionId string = subscription().subscriptionId #disable-next-line BCP318 // expect one value to be null - ? videoIndexerService.outputs.videoIndexerAccountId - : '' +output var_videoIndexerAccountId string = deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerAccountId : '' #disable-next-line BCP318 // expect one value to be null -output var_speechServiceEndpoint string = deploySpeechService ? speechService.outputs.speechServiceEndpoint : '' +output var_videoIndexerName string = deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerServiceName : '' + +// output values required for predeploy script in azure.yaml +output var_containerRegistry string = containerRegistry +output var_imageName string = contains(imageName, ':') ? split(imageName, ':')[0] : imageName +output var_imageTag string = split(imageName, ':')[1] +output var_webService string = appService.outputs.name + +// output values required for postup script in azure.yaml +output var_enablePrivateNetworking bool = enablePrivateNetworking + diff --git a/deployers/bicep/main.json b/deployers/bicep/main.json new file mode 100644 index 000000000..a3e17b225 --- /dev/null +++ b/deployers/bicep/main.json @@ -0,0 +1,7879 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "12894563845993036077" + } + }, + "parameters": { + "location": { + "type": "string", + "minLength": 1, + "metadata": { + "description": "The Azure region where resources will be deployed. \n- Region must align to the target cloud environment" + } + }, + "cloudEnvironment": { + "type": "string", + "allowedValues": [ + "AzureCloud", + "AzureUSGovernment" + ], + "metadata": { + "description": "The target Azure Cloud environment.\n- Accepted values are: AzureCloud, AzureUSGovernment\n- Default is AzureCloud" + } + }, + "appName": { + "type": "string", + "minLength": 3, + "maxLength": 12, + "metadata": { + "description": "The name of the application to be deployed. \n- Name may only contain letters and numbers\n- Between 3 and 12 characters in length \n- No spaces or special characters" + } + }, + "environment": { + "type": "string", + "minLength": 2, + "maxLength": 10, + "metadata": { + "description": "The dev/qa/prod environment or as named in your environment. This will be used to create resource group names and tags.\n- Must be between 2 and 10 characters in length\n- No spaces or special characters" + } + }, + "azdEnvironmentName": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "metadata": { + "description": "Name of the AZD environment" + } + }, + "imageName": { + "type": "string", + "metadata": { + "description": "The name of the container image to deploy to the web app.\n- should be in the format :" + } + }, + "enterpriseAppClientId": { + "type": "string", + "metadata": { + "description": "Azure AD Application Client ID for enterprise authentication.\n- Should be the client ID of the registered Azure AD application" + } + }, + "enterpriseAppServicePrincipalId": { + "type": "string", + "metadata": { + "description": "Azure AD Application Service Principal Id for the enterprise application.\n- Should be the Service Principal ID of the registered Azure AD application" + } + }, + "enterpriseAppClientSecret": { + "type": "securestring", + "metadata": { + "description": "Azure AD Application Client Secret for enterprise authentication.\n- Required if enableEnterpriseApp is true\n- Should be created in Azure AD App Registration and passed via environment variable\n- Will be stored securely in Azure Key Vault during deployment" + } + }, + "authenticationType": { + "type": "string", + "allowedValues": [ + "key", + "managed_identity" + ], + "metadata": { + "description": "Authentication type for resources that support Managed Identity or Key authentication.\n- Key: Use access keys for authentication (application keys will be stored in Key Vault)\n- managed_identity: Use Managed Identity for authentication" + } + }, + "configureApplicationPermissions": { + "type": "bool", + "metadata": { + "description": "Configure permissions (based on authenticationType) for the deployed web application to access required resources.\n" + } + }, + "specialTags": { + "type": "object", + "defaultValue": {}, + "metadata": { + "description": "Optional object containing additional tags to apply to all resources." + } + }, + "enableDiagLogging": { + "type": "bool", + "metadata": { + "description": "Enable diagnostic logging for resources deployed in the resource group. \n- All content will be sent to the deployed Log Analytics workspace\n- Default is false" + } + }, + "enablePrivateNetworking": { + "type": "bool", + "metadata": { + "description": "Enable private endpoints and virtual network integration for deployed resources. \n- Default is false" + } + }, + "gptModels": { + "type": "array", + "defaultValue": [ + { + "modelName": "gpt-4.1", + "modelVersion": "2025-04-14", + "skuName": "GlobalStandard", + "skuCapacity": 150 + }, + { + "modelName": "gpt-4o", + "modelVersion": "2024-11-20", + "skuName": "GlobalStandard", + "skuCapacity": 100 + } + ], + "metadata": { + "description": "Array of GPT model names to deploy to the OpenAI resource." + } + }, + "embeddingModels": { + "type": "array", + "defaultValue": [ + { + "modelName": "text-embedding-3-small", + "modelVersion": "1", + "skuName": "GlobalStandard", + "skuCapacity": 150 + }, + { + "modelName": "text-embedding-3-large", + "modelVersion": "1", + "skuName": "GlobalStandard", + "skuCapacity": 150 + } + ], + "metadata": { + "description": "Array of embedding model names to deploy to the OpenAI resource." + } + }, + "allowedIpAddresses": { + "type": "array", + "defaultValue": [ + { + "ipAddressOrRange": "0.0.0.0" + } + ] + }, + "deployContentSafety": { + "type": "bool", + "metadata": { + "description": "Enable deployment of Content Safety service and related resources.\n- Default is false" + } + }, + "deployRedisCache": { + "type": "bool", + "metadata": { + "description": "Enable deployment of Azure Cache for Redis and related resources.\n- Default is false" + } + }, + "deploySpeechService": { + "type": "bool", + "metadata": { + "description": "Enable deployment of Azure Speech service and related resources.\n- Default is false" + } + }, + "deployVideoIndexerService": { + "type": "bool", + "metadata": { + "description": "Enable deployment of Azure Video Indexer service and related resources.\n- Default is false" + } + } + }, + "variables": { + "rgName": "[format('{0}-{1}-rg', parameters('appName'), parameters('environment'))]", + "requiredTags": { + "application": "[parameters('appName')]", + "environment": "[parameters('environment')]", + "azd-env-name": "[parameters('azdEnvironmentName')]" + }, + "tags": "[union(variables('requiredTags'), parameters('specialTags'))]", + "acrCloudSuffix": "[if(equals(parameters('cloudEnvironment'), 'AzureCloud'), '.azurecr.io', '.azurecr.us')]", + "acrName": "[toLower(format('{0}{1}acr', parameters('appName'), parameters('environment')))]", + "containerRegistry": "[format('{0}{1}', variables('acrName'), variables('acrCloudSuffix'))]", + "containerImageName": "[format('{0}/{1}', variables('containerRegistry'), parameters('imageName'))]", + "vNetName": "[format('{0}-{1}-vnet', parameters('appName'), parameters('environment'))]" + }, + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2022-09-01", + "name": "[variables('rgName')]", + "location": "[parameters('location')]", + "tags": "[variables('tags')]" + }, + { + "condition": "[parameters('enablePrivateNetworking')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "virtualNetwork", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "vNetName": { + "value": "[variables('vNetName')]" + }, + "addressSpaces": { + "value": [ + "10.0.0.0/21" + ] + }, + "subnetConfigs": { + "value": [ + { + "name": "AppServiceIntegration", + "addressPrefix": "10.0.0.0/24", + "enablePrivateEndpointNetworkPolicies": true, + "enablePrivateLinkServiceNetworkPolicies": true + }, + { + "name": "PrivateEndpoints", + "addressPrefix": "10.0.2.0/24", + "enablePrivateEndpointNetworkPolicies": true, + "enablePrivateLinkServiceNetworkPolicies": true + } + ] + }, + "tags": { + "value": "[variables('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "10221795613826494860" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "vNetName": { + "type": "string" + }, + "addressSpaces": { + "type": "array" + }, + "subnetConfigs": { + "type": "array" + }, + "tags": { + "type": "object" + } + }, + "variables": { + "copy": [ + { + "name": "subnetIds", + "count": "[length(parameters('subnetConfigs'))]", + "input": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vNetName'), parameters('subnetConfigs')[copyIndex('subnetIds')].name)]" + }, + { + "name": "subnetNames", + "count": "[length(parameters('subnetConfigs'))]", + "input": "[parameters('subnetConfigs')[copyIndex('subnetNames')].name]" + } + ], + "appServiceIntegrationSubnetIndex": "[indexOf(variables('subnetNames'), 'AppServiceIntegration')]", + "privateEndpointIndex": "[indexOf(variables('subnetNames'), 'PrivateEndpoints')]" + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2021-05-01", + "name": "[parameters('vNetName')]", + "location": "[parameters('location')]", + "properties": { + "copy": [ + { + "name": "subnets", + "count": "[length(parameters('subnetConfigs'))]", + "input": { + "name": "[parameters('subnetConfigs')[copyIndex('subnets')].name]", + "properties": { + "addressPrefix": "[parameters('subnetConfigs')[copyIndex('subnets')].addressPrefix]", + "privateEndpointNetworkPolicies": "[if(parameters('subnetConfigs')[copyIndex('subnets')].enablePrivateEndpointNetworkPolicies, 'Enabled', 'Disabled')]", + "privateLinkServiceNetworkPolicies": "[if(parameters('subnetConfigs')[copyIndex('subnets')].enablePrivateLinkServiceNetworkPolicies, 'Enabled', 'Disabled')]", + "delegations": "[if(equals(parameters('subnetConfigs')[copyIndex('subnets')].name, 'AppServiceIntegration'), createArray(createObject('name', 'delegation', 'properties', createObject('serviceName', 'Microsoft.Web/serverFarms'))), createArray())]" + } + } + } + ], + "addressSpace": { + "addressPrefixes": "[parameters('addressSpaces')]" + } + }, + "tags": "[parameters('tags')]" + } + ], + "outputs": { + "vNetId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/virtualNetworks', parameters('vNetName'))]" + }, + "privateNetworkSubnetId": { + "type": "string", + "value": "[if(equals(variables('privateEndpointIndex'), -1), '', variables('subnetIds')[variables('privateEndpointIndex')])]" + }, + "appServiceSubnetId": { + "type": "string", + "value": "[if(equals(variables('appServiceIntegrationSubnetIndex'), -1), '', variables('subnetIds')[variables('appServiceIntegrationSubnetIndex')])]" + } + } + } + }, + "dependsOn": [ + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "logAnalytics", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "16638534490731333297" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2022-10-01", + "name": "[toLower(format('{0}-{1}-la', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "properties": { + "sku": { + "name": "PerGB2018" + } + }, + "tags": "[parameters('tags')]" + } + ], + "outputs": { + "logAnalyticsId": { + "type": "string", + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', toLower(format('{0}-{1}-la', parameters('appName'), parameters('environment'))))]" + } + } + } + }, + "dependsOn": [ + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "applicationInsights", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "16543999957549068652" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "logAnalyticsId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[toLower(format('{0}-{1}-ai', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[parameters('logAnalyticsId')]" + }, + "tags": "[parameters('tags')]" + } + ], + "outputs": { + "appInsightsName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-ai', parameters('appName'), parameters('environment')))]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "keyVault", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "3503710577838836741" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2024-11-01", + "name": "[toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "properties": { + "tenantId": "[subscription().tenantId]", + "sku": { + "family": "A", + "name": "standard" + }, + "accessPolicies": [], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "publicNetworkAccess": "Enabled", + "enableRbacAuthorization": true + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.KeyVault/vaults/{0}', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + } + ], + "outputs": { + "keyVaultId": { + "type": "string", + "value": "[resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment'))))]" + }, + "keyVaultName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))]" + }, + "keyVaultUri": { + "type": "string", + "value": "[reference(resourceId('Microsoft.KeyVault/vaults', toLower(format('{0}-{1}-kv', parameters('appName'), parameters('environment')))), '2024-11-01').vaultUri]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "condition": "[not(empty(parameters('enterpriseAppClientSecret')))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeEnterpriseAppSecret", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "secretName": { + "value": "enterprise-app-client-secret" + }, + "secretValue": { + "value": "[parameters('enterpriseAppClientSecret')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "cosmosDB", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "keyVault": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "configureApplicationPermissions": { + "value": "[parameters('configureApplicationPermissions')]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + }, + "allowedIpAddresses": { + "value": "[parameters('allowedIpAddresses')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "15046922718277168026" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "keyVault": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "configureApplicationPermissions": { + "type": "bool" + }, + "enablePrivateNetworking": { + "type": "bool" + }, + "allowedIpAddresses": { + "type": "array", + "defaultValue": [] + } + }, + "resources": [ + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "apiVersion": "2023-04-15", + "name": "[toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "kind": "GlobalDocumentDB", + "properties": { + "publicNetworkAccess": "Enabled", + "databaseAccountOfferType": "Standard", + "capabilities": [ + { + "name": "EnableServerless" + } + ], + "isVirtualNetworkFilterEnabled": "[if(parameters('enablePrivateNetworking'), true(), false())]", + "ipRules": "[if(parameters('enablePrivateNetworking'), parameters('allowedIpAddresses'), createArray())]", + "locations": [ + { + "locationName": "[parameters('location')]", + "failoverPriority": 0, + "isZoneRedundant": false + } + ], + "consistencyPolicy": { + "defaultConsistencyLevel": "Session" + } + }, + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases", + "apiVersion": "2023-04-15", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))), 'SimpleChat')]", + "properties": { + "resource": { + "id": "SimpleChat" + }, + "options": {} + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", + "apiVersion": "2023-04-15", + "name": "[format('{0}/{1}/{2}', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))), 'SimpleChat', 'settings')]", + "properties": { + "resource": { + "id": "settings", + "partitionKey": { + "paths": [ + "/id" + ] + } + }, + "options": {} + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))), 'SimpleChat')]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.DocumentDB/databaseAccounts/{0}', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": [] + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]", + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + }, + { + "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeCosmosDbSecret", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[parameters('keyVault')]" + }, + "secretName": { + "value": "cosmos-db-key" + }, + "secretValue": { + "value": "[listKeys(resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))), '2023-04-15').primaryMasterKey]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment'))))]" + ] + } + ], + "outputs": { + "cosmosDbName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))]" + }, + "cosmosDbUri": { + "type": "string", + "value": "[reference(resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('{0}-{1}-cosmos', parameters('appName'), parameters('environment')))), '2023-04-15').documentEndpoint]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "azureContainerRegistry", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "acrName": { + "value": "[variables('acrName')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "keyVault": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "configureApplicationPermissions": { + "value": "[parameters('configureApplicationPermissions')]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "7200761421625936333" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "acrName": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "keyVault": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "configureApplicationPermissions": { + "type": "bool" + }, + "enablePrivateNetworking": { + "type": "bool" + } + }, + "resources": [ + { + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2025-04-01", + "name": "[parameters('acrName')]", + "location": "[parameters('location')]", + "sku": { + "name": "[if(parameters('enablePrivateNetworking'), 'Premium', 'Standard')]" + }, + "properties": { + "adminUserEnabled": true, + "publicNetworkAccess": "Enabled" + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.ContainerRegistry/registries/{0}', parameters('acrName'))]", + "name": "[toLower(format('{0}-diagnostics', parameters('acrName')))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + }, + { + "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeContainerRegistrySecret", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[parameters('keyVault')]" + }, + "secretName": { + "value": "container-registry-key" + }, + "secretValue": { + "value": "[listCredentials(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), '2025-04-01').passwords[0].value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" + ] + } + ], + "outputs": { + "acrName": { + "type": "string", + "value": "[parameters('acrName')]" + }, + "acrResourceGroup": { + "type": "string", + "value": "[resourceGroup().name]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "searchService", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "keyVault": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "configureApplicationPermissions": { + "value": "[parameters('configureApplicationPermissions')]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "12409981559885193891" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "keyVault": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "configureApplicationPermissions": { + "type": "bool" + }, + "enablePrivateNetworking": { + "type": "bool" + } + }, + "resources": [ + { + "type": "Microsoft.Search/searchServices", + "apiVersion": "2025-05-01", + "name": "[toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "sku": { + "name": "basic" + }, + "properties": { + "hostingMode": "default", + "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", + "replicaCount": 1, + "partitionCount": 1, + "authOptions": { + "aadOrApiKey": { + "aadAuthFailureMode": "http403" + } + }, + "disableLocalAuth": false + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.Search/searchServices/{0}', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + }, + { + "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeSearchServiceSecret", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[parameters('keyVault')]" + }, + "secretName": { + "value": "search-service-key" + }, + "secretValue": { + "value": "[listAdminKeys(resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))), '2025-05-01').primaryKey]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment'))))]" + ] + } + ], + "outputs": { + "searchServiceName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))]" + }, + "searchServiceEndpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Search/searchServices', toLower(format('{0}-{1}-search', parameters('appName'), parameters('environment')))), '2025-05-01').endpoint]" + }, + "searchServiceAuthencationType": { + "type": "string", + "value": "[parameters('authenticationType')]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "docIntel", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "keyVault": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "configureApplicationPermissions": { + "value": "[parameters('configureApplicationPermissions')]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1403194626393613544" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "keyVault": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "configureApplicationPermissions": { + "type": "bool" + }, + "enablePrivateNetworking": { + "type": "bool" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-06-01", + "name": "[toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "kind": "FormRecognizer", + "sku": { + "name": "S0" + }, + "properties": { + "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", + "customSubDomainName": "[toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))]" + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + }, + { + "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeDocumentIntelligenceSecret", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[parameters('keyVault')]" + }, + "secretName": { + "value": "document-intelligence-key" + }, + "secretValue": { + "value": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))), '2025-06-01').key1]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment'))))]" + ] + } + ], + "outputs": { + "documentIntelligenceServiceName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))]" + }, + "diagnosticLoggingEnabled": { + "type": "bool", + "value": "[parameters('enableDiagLogging')]" + }, + "documentIntelligenceServiceEndpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-docintel', parameters('appName'), parameters('environment')))), '2025-06-01').endpoint]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storageAccount", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "keyVault": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "configureApplicationPermissions": { + "value": "[parameters('configureApplicationPermissions')]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1065428217831578633" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "keyVault": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "configureApplicationPermissions": { + "type": "bool" + }, + "enablePrivateNetworking": { + "type": "bool" + } + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2022-09-01", + "name": "[toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": { + "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", + "accessTier": "Hot", + "allowBlobPublicAccess": false, + "allowSharedKeyAccess": true, + "isHnsEnabled": true + }, + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices", + "apiVersion": "2023-01-01", + "name": "[format('{0}/{1}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "apiVersion": "2023-01-01", + "name": "[format('{0}/{1}/{2}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default', 'user-documents')]", + "properties": { + "publicAccess": "None" + }, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]" + ] + }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "apiVersion": "2023-01-01", + "name": "[format('{0}/{1}/{2}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default', 'group-documents')]", + "properties": { + "publicAccess": "None" + }, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.Storage/storageAccounts/{0}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": [], + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.transactionMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.Storage/storageAccounts/{0}/blobServices/{1}', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]", + "name": "[toLower(format('{0}-blob-diagnostics', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.transactionMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/blobServices', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))), 'default')]", + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + }, + { + "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeStorageAccountSecret", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[parameters('keyVault')]" + }, + "secretName": { + "value": "storage-account-key" + }, + "secretValue": { + "value": "[listKeys(resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))), '2022-09-01').keys[0].value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment'))))]" + ] + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))]" + }, + "endpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Storage/storageAccounts', toLower(format('{0}{1}sa', parameters('appName'), parameters('environment')))), '2022-09-01').primaryEndpoints.blob]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "openAI", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "keyVault": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "configureApplicationPermissions": { + "value": "[parameters('configureApplicationPermissions')]" + }, + "gptModels": { + "value": "[parameters('gptModels')]" + }, + "embeddingModels": { + "value": "[parameters('embeddingModels')]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "17726947983911725769" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "keyVault": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "configureApplicationPermissions": { + "type": "bool" + }, + "gptModels": { + "type": "array" + }, + "embeddingModels": { + "type": "array" + }, + "enablePrivateNetworking": { + "type": "bool" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2024-10-01", + "name": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "kind": "OpenAI", + "sku": { + "name": "S0" + }, + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", + "customSubDomainName": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]" + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + }, + { + "copy": { + "name": "aiModel", + "count": "[length(concat(parameters('gptModels'), parameters('embeddingModels')))]", + "mode": "serial", + "batchSize": 1 + }, + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('model-{0}-{1}', replace(concat(parameters('gptModels'), parameters('embeddingModels'))[copyIndex()].modelName, '.', '-'), copyIndex())]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "parent": { + "value": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]" + }, + "modelName": { + "value": "[concat(parameters('gptModels'), parameters('embeddingModels'))[copyIndex()].modelName]" + }, + "modelVersion": { + "value": "[concat(parameters('gptModels'), parameters('embeddingModels'))[copyIndex()].modelVersion]" + }, + "skuName": { + "value": "[concat(parameters('gptModels'), parameters('embeddingModels'))[copyIndex()].skuName]" + }, + "skuCapacity": { + "value": "[concat(parameters('gptModels'), parameters('embeddingModels'))[copyIndex()].skuCapacity]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "3668428745262748302" + } + }, + "parameters": { + "parent": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "modelVersion": { + "type": "string" + }, + "skuName": { + "type": "string" + }, + "skuCapacity": { + "type": "int" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-06-01", + "name": "[format('{0}/{1}', parameters('parent'), parameters('modelName'))]", + "properties": { + "model": { + "format": "OpenAI", + "name": "[parameters('modelName')]", + "version": "[parameters('modelVersion')]" + } + }, + "sku": { + "name": "[parameters('skuName')]", + "capacity": "[parameters('skuCapacity')]" + } + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeOpenAISecret", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[parameters('keyVault')]" + }, + "secretName": { + "value": "openAi-key" + }, + "secretValue": { + "value": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))), '2024-10-01').key1]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment'))))]" + ] + } + ], + "outputs": { + "openAIName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))]" + }, + "openAIResourceGroup": { + "type": "string", + "value": "[resourceGroup().name]" + }, + "openAIEndpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-openai', parameters('appName'), parameters('environment')))), '2024-10-01').endpoint]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "appServicePlan", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "16108907418935593505" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2022-03-01", + "name": "[toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "sku": { + "name": "P1v3", + "tier": "PremiumV3", + "size": "P1v3", + "capacity": 1 + }, + "kind": "app,linux,container", + "properties": { + "reserved": true, + "perSiteScaling": false, + "maximumElasticWorkerCount": 1, + "hyperV": false, + "targetWorkerCount": 0, + "targetWorkerSizeId": 0 + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.Web/serverfarms/{0}', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": [], + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment'))))]", + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + } + ], + "outputs": { + "appServicePlanId": { + "type": "string", + "value": "[resourceId('Microsoft.Web/serverfarms', toLower(format('{0}-{1}-asp', parameters('appName'), parameters('environment'))))]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "appService", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "acrName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'azureContainerRegistry'), '2025-04-01').outputs.acrName.value]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "appServicePlanId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'appServicePlan'), '2025-04-01').outputs.appServicePlanId.value]" + }, + "containerImageName": { + "value": "[variables('containerImageName')]" + }, + "azurePlatform": { + "value": "[parameters('cloudEnvironment')]" + }, + "cosmosDbName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'cosmosDB'), '2025-04-01').outputs.cosmosDbName.value]" + }, + "searchServiceName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'searchService'), '2025-04-01').outputs.searchServiceName.value]" + }, + "openAiServiceName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI'), '2025-04-01').outputs.openAIName.value]" + }, + "openAiResourceGroupName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI'), '2025-04-01').outputs.openAIResourceGroup.value]" + }, + "documentIntelligenceServiceName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'docIntel'), '2025-04-01').outputs.documentIntelligenceServiceName.value]" + }, + "appInsightsName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'applicationInsights'), '2025-04-01').outputs.appInsightsName.value]" + }, + "enterpriseAppClientId": { + "value": "[parameters('enterpriseAppClientId')]" + }, + "enterpriseAppClientSecret": { + "value": "[parameters('enterpriseAppClientSecret')]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "keyVaultUri": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultUri.value]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + }, + "appServiceSubnetId": "[if(parameters('enablePrivateNetworking'), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'virtualNetwork'), '2025-04-01').outputs.appServiceSubnetId.value), createObject('value', ''))]" + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "5779075922193615045" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "acrName": { + "type": "string" + }, + "appServicePlanId": { + "type": "string" + }, + "containerImageName": { + "type": "string" + }, + "azurePlatform": { + "type": "string" + }, + "cosmosDbName": { + "type": "string" + }, + "searchServiceName": { + "type": "string" + }, + "openAiServiceName": { + "type": "string" + }, + "openAiResourceGroupName": { + "type": "string" + }, + "documentIntelligenceServiceName": { + "type": "string" + }, + "appInsightsName": { + "type": "string" + }, + "enterpriseAppClientId": { + "type": "string", + "defaultValue": "" + }, + "authenticationType": { + "type": "string" + }, + "enterpriseAppClientSecret": { + "type": "securestring", + "defaultValue": "" + }, + "keyVaultUri": { + "type": "string" + }, + "enablePrivateNetworking": { + "type": "bool" + }, + "appServiceSubnetId": { + "type": "string", + "defaultValue": "" + } + }, + "variables": { + "acrDomain": "[if(equals(parameters('azurePlatform'), 'AzureUSGovernment'), '.azurecr.us', '.azurecr.io')]" + }, + "resources": [ + { + "type": "Microsoft.Web/sites", + "apiVersion": "2022-03-01", + "name": "[toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "kind": "app,linux,container", + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "virtualNetworkSubnetId": "[if(not(equals(parameters('appServiceSubnetId'), '')), parameters('appServiceSubnetId'), null())]", + "publicNetworkAccess": "Enabled", + "vnetImagePullEnabled": "[if(parameters('enablePrivateNetworking'), true(), false())]", + "siteConfig": { + "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImageName'))]", + "acrUseManagedIdentityCreds": true, + "acrUserManagedIdentityID": "", + "alwaysOn": true, + "ftpsState": "Disabled", + "healthCheckPath": "/external/healthcheck", + "appSettings": "[flatten(createArray(createArray(createObject('name', 'AZURE_ENDPOINT', 'value', if(equals(parameters('azurePlatform'), 'AzureUSGovernment'), 'usgovernment', 'public')), createObject('name', 'SCM_DO_BUILD_DURING_DEPLOYMENT', 'value', 'false'), createObject('name', 'AZURE_COSMOS_ENDPOINT', 'value', reference(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDbName')), '2023-04-15').documentEndpoint), createObject('name', 'AZURE_COSMOS_AUTHENTICATION_TYPE', 'value', toLower(parameters('authenticationType')))), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'AZURE_COSMOS_KEY', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/cosmos-db-key)', parameters('keyVaultUri')))), createArray()), createArray(createObject('name', 'TENANT_ID', 'value', tenant().tenantId), createObject('name', 'CLIENT_ID', 'value', parameters('enterpriseAppClientId')), createObject('name', 'SECRET_KEY', 'value', if(not(empty(parameters('enterpriseAppClientSecret'))), parameters('enterpriseAppClientSecret'), format('@Microsoft.KeyVault(SecretUri={0}secrets/enterprise-app-client-secret)', parameters('keyVaultUri')))), createObject('name', 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/enterprise-app-client-secret)', parameters('keyVaultUri'))), createObject('name', 'DOCKER_REGISTRY_SERVER_URL', 'value', format('https://{0}{1}', parameters('acrName'), variables('acrDomain')))), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'DOCKER_REGISTRY_SERVER_USERNAME', 'value', listCredentials(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), '2025-04-01').username)), createArray()), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'DOCKER_REGISTRY_SERVER_PASSWORD', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/container-registry-key)', parameters('keyVaultUri')))), createArray()), createArray(createObject('name', 'WEBSITE_AUTH_AAD_ALLOWED_TENANTS', 'value', tenant().tenantId), createObject('name', 'AZURE_OPENAI_RESOURCE_NAME', 'value', parameters('openAiServiceName')), createObject('name', 'AZURE_OPENAI_RESOURCE_GROUP_NAME', 'value', parameters('openAiResourceGroupName')), createObject('name', 'AZURE_OPENAI_URL', 'value', reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAiServiceName')), '2024-10-01').endpoint), createObject('name', 'AZURE_SEARCH_SERVICE_NAME', 'value', parameters('searchServiceName'))), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'AZURE_SEARCH_API_KEY', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/search-service-key)', parameters('keyVaultUri')))), createArray()), createArray(createObject('name', 'AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT', 'value', reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('documentIntelligenceServiceName')), '2025-06-01').endpoint)), if(equals(parameters('authenticationType'), 'key'), createArray(createObject('name', 'AZURE_DOCUMENT_INTELLIGENCE_API_KEY', 'value', format('@Microsoft.KeyVault(SecretUri={0}secrets/document-intelligence-key)', parameters('keyVaultUri')))), createArray()), createArray(createObject('name', 'APPINSIGHTS_INSTRUMENTATIONKEY', 'value', reference(resourceId('Microsoft.Insights/components', parameters('appInsightsName')), '2020-02-02').InstrumentationKey), createObject('name', 'APPLICATIONINSIGHTS_CONNECTION_STRING', 'value', reference(resourceId('Microsoft.Insights/components', parameters('appInsightsName')), '2020-02-02').ConnectionString), createObject('name', 'APPINSIGHTS_PROFILERFEATURE_VERSION', 'value', '1.0.0'), createObject('name', 'APPINSIGHTS_SNAPSHOTFEATURE_VERSION', 'value', '1.0.0'), createObject('name', 'APPLICATIONINSIGHTS_CONFIGURATION_CONTENT', 'value', ''), createObject('name', 'ApplicationInsightsAgent_EXTENSION_VERSION', 'value', '~3'), createObject('name', 'DiagnosticServices_EXTENSION_VERSION', 'value', '~3'), createObject('name', 'InstrumentationEngine_EXTENSION_VERSION', 'value', 'disabled'), createObject('name', 'SnapshotDebugger_EXTENSION_VERSION', 'value', 'disabled'), createObject('name', 'XDT_MicrosoftApplicationInsights_BaseExtensions', 'value', 'disabled'), createObject('name', 'XDT_MicrosoftApplicationInsights_Mode', 'value', 'recommended'), createObject('name', 'XDT_MicrosoftApplicationInsights_PreemptSdk', 'value', 'disabled'))))]" + }, + "clientAffinityEnabled": false, + "httpsOnly": true + }, + "identity": { + "type": "SystemAssigned" + }, + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'web'))]" + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2022-03-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))), 'logs')]", + "properties": { + "httpLogs": { + "fileSystem": { + "enabled": true, + "retentionInDays": 7, + "retentionInMb": 35 + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.Web/sites/{0}', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.webAppLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.Web/sites', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "type": "Microsoft.Web/sites/config", + "apiVersion": "2022-03-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))), 'authsettingsV2')]", + "properties": { + "globalValidation": { + "requireAuthentication": true, + "unauthenticatedClientAction": "RedirectToLoginPage", + "redirectToProvider": "azureActiveDirectory" + }, + "identityProviders": { + "azureActiveDirectory": { + "enabled": true, + "registration": { + "openIdIssuer": "[if(equals(parameters('azurePlatform'), 'AzureUSGovernment'), format('https://login.microsoftonline.us/{0}/', tenant().tenantId), format('https://sts.windows.net/{0}/', tenant().tenantId))]", + "clientId": "[parameters('enterpriseAppClientId')]", + "clientSecretSettingName": "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET" + }, + "validation": { + "jwtClaimChecks": {}, + "allowedAudiences": [ + "[format('api://{0}', parameters('enterpriseAppClientId'))]", + "[parameters('enterpriseAppClientId')]" + ] + }, + "isAutoProvisioned": false + } + }, + "login": { + "routes": { + "logoutEndpoint": "/.auth/logout" + }, + "tokenStore": { + "enabled": true, + "tokenRefreshExtensionHours": 72, + "fileSystem": { + "directory": "/home/data/.auth" + } + }, + "preserveUrlFragmentsForLogins": false, + "allowedExternalRedirectUrls": [], + "cookieExpiration": { + "convention": "FixedTime", + "timeToExpiration": "08:00:00" + }, + "nonce": { + "validateNonce": true, + "nonceExpirationInterval": "00:05:00" + } + }, + "httpSettings": { + "requireHttps": true, + "routes": { + "apiPrefix": "/.auth" + }, + "forwardProxy": { + "convention": "NoProxy" + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))]" + }, + "defaultHostName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment')))), '2022-03-01').defaultHostName]" + }, + "resourceId": { + "type": "string", + "value": "[resourceId('Microsoft.Web/sites', toLower(format('{0}-{1}-app', parameters('appName'), parameters('environment'))))]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'azureContainerRegistry')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'applicationInsights')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'appServicePlan')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'cosmosDB')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'docIntel')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'searchService')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'virtualNetwork')]" + ] + }, + { + "condition": "[parameters('deployContentSafety')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "contentSafety", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "keyVault": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "configureApplicationPermissions": { + "value": "[parameters('configureApplicationPermissions')]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "15792786587982505631" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "keyVault": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "configureApplicationPermissions": { + "type": "bool" + }, + "enablePrivateNetworking": { + "type": "bool" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-06-01", + "name": "[toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "kind": "ContentSafety", + "sku": { + "name": "S0" + }, + "properties": { + "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", + "customSubDomainName": "[toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))]" + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment'))))]", + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + }, + { + "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeContentSafetySecret", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[parameters('keyVault')]" + }, + "secretName": { + "value": "content-safety-key" + }, + "secretValue": { + "value": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))), '2025-06-01').key1]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment'))))]" + ] + } + ], + "outputs": { + "contentSafetyName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))]" + }, + "contentSafetyEndpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-contentsafety', parameters('appName'), parameters('environment')))), '2025-06-01').endpoint]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "condition": "[parameters('deployRedisCache')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "redisCache", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "keyVault": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "configureApplicationPermissions": { + "value": "[parameters('configureApplicationPermissions')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "10855483054816904335" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "keyVault": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "configureApplicationPermissions": { + "type": "bool" + } + }, + "resources": [ + { + "type": "Microsoft.Cache/redis", + "apiVersion": "2024-11-01", + "name": "[toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "properties": { + "sku": { + "name": "Standard", + "family": "C", + "capacity": 0 + }, + "enableNonSslPort": false, + "minimumTlsVersion": "1.2", + "redisConfiguration": { + "aad-enabled": "true" + } + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.Cache/redis/{0}', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + }, + { + "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeRedisCacheSecret", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[parameters('keyVault')]" + }, + "secretName": { + "value": "redis-cache-key" + }, + "secretValue": { + "value": "[listKeys(resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))), '2024-11-01').primaryKey]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment'))))]" + ] + } + ], + "outputs": { + "redisCacheName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))]" + }, + "redisCacheHostName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Cache/redis', toLower(format('{0}-{1}-redis', parameters('appName'), parameters('environment')))), '2024-11-01').hostName]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "condition": "[parameters('deploySpeechService')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "speechService", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "keyVault": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "configureApplicationPermissions": { + "value": "[parameters('configureApplicationPermissions')]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "12749834719068646180" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "keyVault": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "configureApplicationPermissions": { + "type": "bool" + }, + "enablePrivateNetworking": { + "type": "bool" + } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2024-10-01", + "name": "[toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "kind": "SpeechServices", + "sku": { + "name": "S0" + }, + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", + "customSubDomainName": "[toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))]" + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardLogCategories.value]", + "metrics": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.standardMetricsCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + }, + { + "condition": "[and(equals(parameters('authenticationType'), 'key'), parameters('configureApplicationPermissions'))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storeSpeechServiceSecret", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "keyVaultName": { + "value": "[parameters('keyVault')]" + }, + "secretName": { + "value": "speech-service-key" + }, + "secretValue": { + "value": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))), '2024-10-01').key1]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "18077870757859157550" + } + }, + "parameters": { + "keyVaultName": { + "type": "string" + }, + "secretName": { + "type": "string" + }, + "secretValue": { + "type": "securestring" + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults/secrets", + "apiVersion": "2025-05-01", + "name": "[format('{0}/{1}', parameters('keyVaultName'), parameters('secretName'))]", + "properties": { + "value": "[parameters('secretValue')]" + } + } + ], + "outputs": { + "SecretUri": { + "type": "string", + "value": "[format('{0}secrets/{1}', reference(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), '2025-05-01').vaultUri, parameters('secretName'))]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment'))))]" + ] + } + ], + "outputs": { + "speechServiceName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))]" + }, + "speechServiceEndpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts', toLower(format('{0}-{1}-speech', parameters('appName'), parameters('environment')))), '2024-10-01').endpoint]" + }, + "speechServiceAuthenticationType": { + "type": "string", + "value": "[parameters('authenticationType')]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]" + ] + }, + { + "condition": "[parameters('deployVideoIndexerService')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "videoIndexerService", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "enableDiagLogging": { + "value": "[parameters('enableDiagLogging')]" + }, + "logAnalyticsId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics'), '2025-04-01').outputs.logAnalyticsId.value]" + }, + "storageAccount": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'storageAccount'), '2025-04-01').outputs.name.value]" + }, + "openAiServiceName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI'), '2025-04-01').outputs.openAIName.value]" + }, + "enablePrivateNetworking": { + "value": "[parameters('enablePrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "5008986833957108258" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "enableDiagLogging": { + "type": "bool" + }, + "logAnalyticsId": { + "type": "string" + }, + "storageAccount": { + "type": "string" + }, + "openAiServiceName": { + "type": "string" + }, + "enablePrivateNetworking": { + "type": "bool" + } + }, + "resources": [ + { + "type": "Microsoft.VideoIndexer/accounts", + "apiVersion": "2025-04-01", + "name": "[toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))]", + "location": "[parameters('location')]", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "publicNetworkAccess": "[if(parameters('enablePrivateNetworking'), 'Disabled', 'Enabled')]", + "storageServices": { + "resourceId": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccount'))]" + }, + "openAiServices": { + "resourceId": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAiServiceName'))]" + } + }, + "tags": "[parameters('tags')]" + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Insights/diagnosticSettings", + "apiVersion": "2021-05-01-preview", + "scope": "[format('Microsoft.VideoIndexer/accounts/{0}', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment'))))]", + "name": "[toLower(format('{0}-diagnostics', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))))]", + "properties": { + "workspaceId": "[parameters('logAnalyticsId')]", + "logs": "[reference(resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs'), '2025-04-01').outputs.limitedLogCategories.value]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'diagnosticConfigs')]", + "[resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment'))))]" + ] + }, + { + "condition": "[parameters('enableDiagLogging')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "diagnosticConfigs", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "14992132232820252472" + } + }, + "variables": { + "standardRetentionPolicy": { + "enabled": false, + "days": 0 + }, + "standardLogCategories": [ + { + "categoryGroup": "Audit", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "limitedLogCategories": [ + { + "categoryGroup": "allLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "standardMetricsCategories": [ + { + "category": "AllMetrics", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "transactionMetricsCategories": [ + { + "category": "Transaction", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ], + "webAppLogCategories": [ + { + "category": "AppServiceAntivirusScanAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceHTTPLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceConsoleLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAppLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceFileAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceIPSecAuditLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServicePlatformLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + }, + { + "category": "AppServiceAuthenticationLogs", + "enabled": true, + "retentionPolicy": "[variables('standardRetentionPolicy')]" + } + ] + }, + "resources": [], + "outputs": { + "limitedLogCategories": { + "type": "array", + "value": "[variables('limitedLogCategories')]" + }, + "standardRetentionPolicy": { + "type": "object", + "value": "[variables('standardRetentionPolicy')]" + }, + "standardLogCategories": { + "type": "array", + "value": "[variables('standardLogCategories')]" + }, + "standardMetricsCategories": { + "type": "array", + "value": "[variables('standardMetricsCategories')]" + }, + "transactionMetricsCategories": { + "type": "array", + "value": "[variables('transactionMetricsCategories')]" + }, + "webAppLogCategories": { + "type": "array", + "value": "[variables('webAppLogCategories')]" + } + } + } + } + } + ], + "outputs": { + "videoIndexerServiceName": { + "type": "string", + "value": "[toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))]" + }, + "videoIndexerAccountId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.VideoIndexer/accounts', toLower(format('{0}-{1}-video', parameters('appName'), parameters('environment')))), '2025-04-01').accountId]" + } + } + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'logAnalytics')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'storageAccount')]" + ] + }, + { + "condition": "[parameters('configureApplicationPermissions')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "setPermissions", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "webAppName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'appService'), '2025-04-01').outputs.name.value]" + }, + "authenticationType": { + "value": "[parameters('authenticationType')]" + }, + "enterpriseAppServicePrincipalId": { + "value": "[parameters('enterpriseAppServicePrincipalId')]" + }, + "keyVaultName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "cosmosDBName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'cosmosDB'), '2025-04-01').outputs.cosmosDbName.value]" + }, + "acrName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'azureContainerRegistry'), '2025-04-01').outputs.acrName.value]" + }, + "openAIName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI'), '2025-04-01').outputs.openAIName.value]" + }, + "docIntelName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'docIntel'), '2025-04-01').outputs.documentIntelligenceServiceName.value]" + }, + "storageAccountName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'storageAccount'), '2025-04-01').outputs.name.value]" + }, + "searchServiceName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'searchService'), '2025-04-01').outputs.searchServiceName.value]" + }, + "speechServiceName": "[if(parameters('deploySpeechService'), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'speechService'), '2025-04-01').outputs.speechServiceName.value), createObject('value', ''))]", + "redisCacheName": "[if(parameters('deployRedisCache'), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'redisCache'), '2025-04-01').outputs.redisCacheName.value), createObject('value', ''))]", + "contentSafetyName": "[if(parameters('deployContentSafety'), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'contentSafety'), '2025-04-01').outputs.contentSafetyName.value), createObject('value', ''))]", + "videoIndexerName": "[if(parameters('deployVideoIndexerService'), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'videoIndexerService'), '2025-04-01').outputs.videoIndexerServiceName.value), createObject('value', ''))]" + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "17165122895470513967" + } + }, + "parameters": { + "webAppName": { + "type": "string" + }, + "authenticationType": { + "type": "string" + }, + "keyVaultName": { + "type": "string" + }, + "enterpriseAppServicePrincipalId": { + "type": "string" + }, + "cosmosDBName": { + "type": "string" + }, + "acrName": { + "type": "string" + }, + "openAIName": { + "type": "string" + }, + "docIntelName": { + "type": "string" + }, + "storageAccountName": { + "type": "string" + }, + "speechServiceName": { + "type": "string" + }, + "searchServiceName": { + "type": "string" + }, + "redisCacheName": { + "type": "string" + }, + "contentSafetyName": { + "type": "string" + }, + "videoIndexerName": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.KeyVault/vaults/{0}', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'kv-secrets-user')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.DocumentDB/databaseAccounts/{0}', parameters('cosmosDBName'))]", + "name": "[guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'cosmos-contributor')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", + "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments", + "apiVersion": "2023-04-15", + "name": "[format('{0}/{1}', parameters('cosmosDBName'), guid(resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'cosmos-data-contributor'))]", + "properties": { + "roleDefinitionId": "[format('{0}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002', resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName')))]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.ContainerRegistry/registries/{0}', parameters('acrName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'acr-pull-role')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('openAIName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'openai-user')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('openAIName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'enterpriseApp-CognitiveServicesOpenAIUserRole')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd')]", + "principalId": "[parameters('enterpriseAppServicePrincipalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('docIntelName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('docIntelName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'doc-intel-user')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('storageAccountName'))]", + "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'storage-blob-data-contributor')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[and(not(equals(parameters('speechServiceName'), '')), equals(parameters('authenticationType'), 'managed_identity'))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('speechServiceName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('speechServiceName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'speech-service-user')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Search/searchServices/{0}', parameters('searchServiceName'))]", + "name": "[guid(resourceId('Microsoft.Search/searchServices', parameters('searchServiceName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'search-index-data-contributor')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[equals(parameters('authenticationType'), 'managed_identity')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Search/searchServices/{0}', parameters('searchServiceName'))]", + "name": "[guid(resourceId('Microsoft.Search/searchServices', parameters('searchServiceName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'search-service-contributor')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[and(not(equals(parameters('contentSafetyName'), '')), equals(parameters('authenticationType'), 'managed_identity'))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('contentSafetyName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('contentSafetyName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'content-safety-user')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[not(equals(parameters('videoIndexerName'), ''))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Storage/storageAccounts/{0}', parameters('storageAccountName'))]", + "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-storage-blob-data-contributor')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe')]", + "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[not(equals(parameters('videoIndexerName'), ''))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('openAIName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-cog-services-contributor')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68')]", + "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[not(equals(parameters('videoIndexerName'), ''))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.CognitiveServices/accounts/{0}', parameters('openAIName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName')), resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), 'video-indexer-cog-services-user')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908')]", + "principalId": "[reference(resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName')), '2025-04-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[not(equals(parameters('redisCacheName'), ''))]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[format('Microsoft.Cache/redis/{0}', parameters('redisCacheName'))]", + "name": "[guid(resourceId('Microsoft.Cache/redis', parameters('redisCacheName')), resourceId('Microsoft.Web/sites', parameters('webAppName')), 'redis-cache-contributor')]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'e0f68234-74aa-48ed-b826-c38b57376e17')]", + "principalId": "[reference(resourceId('Microsoft.Web/sites', parameters('webAppName')), '2022-03-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal" + } + } + ] + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'azureContainerRegistry')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'appService')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'contentSafety')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'cosmosDB')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'docIntel')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'redisCache')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'searchService')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'speechService')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'storageAccount')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'videoIndexerService')]" + ] + }, + { + "condition": "[parameters('enablePrivateNetworking')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "privateNetworking", + "resourceGroup": "[variables('rgName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "virtualNetworkId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'virtualNetwork'), '2025-04-01').outputs.vNetId.value]" + }, + "privateEndpointSubnetId": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'virtualNetwork'), '2025-04-01').outputs.privateNetworkSubnetId.value]" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "keyVaultName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "cosmosDBName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'cosmosDB'), '2025-04-01').outputs.cosmosDbName.value]" + }, + "acrName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'azureContainerRegistry'), '2025-04-01').outputs.acrName.value]" + }, + "searchServiceName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'searchService'), '2025-04-01').outputs.searchServiceName.value]" + }, + "docIntelName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'docIntel'), '2025-04-01').outputs.documentIntelligenceServiceName.value]" + }, + "storageAccountName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'storageAccount'), '2025-04-01').outputs.name.value]" + }, + "openAIName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI'), '2025-04-01').outputs.openAIName.value]" + }, + "webAppName": { + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'appService'), '2025-04-01').outputs.name.value]" + }, + "contentSafetyName": "[if(parameters('deployContentSafety'), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'contentSafety'), '2025-04-01').outputs.contentSafetyName.value), createObject('value', ''))]", + "speechServiceName": "[if(parameters('deploySpeechService'), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'speechService'), '2025-04-01').outputs.speechServiceName.value), createObject('value', ''))]", + "videoIndexerName": "[if(parameters('deployVideoIndexerService'), createObject('value', reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'videoIndexerService'), '2025-04-01').outputs.videoIndexerServiceName.value), createObject('value', ''))]" + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "3584007603660948740" + } + }, + "parameters": { + "virtualNetworkId": { + "type": "string" + }, + "privateEndpointSubnetId": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "keyVaultName": { + "type": "string" + }, + "cosmosDBName": { + "type": "string" + }, + "acrName": { + "type": "string" + }, + "searchServiceName": { + "type": "string" + }, + "docIntelName": { + "type": "string" + }, + "storageAccountName": { + "type": "string" + }, + "openAIName": { + "type": "string" + }, + "webAppName": { + "type": "string" + }, + "contentSafetyName": { + "type": "string" + }, + "speechServiceName": { + "type": "string" + }, + "videoIndexerName": { + "type": "string" + } + }, + "variables": { + "$fxv#0": { + "azurecloud": { + "aisearch": "privatelink.search.windows.net", + "blobStorage": "privatelink.blob.core.windows.net", + "cognitiveServices": "privatelink.cognitiveservices.azure.com", + "containerRegistry": "privatelink.azurecr.io", + "cosmosDb": "privatelink.documents.azure.com", + "keyVault": "privatelink.vaultcore.azure.net", + "openAi": "privatelink.openai.azure.com", + "webSites": "privatelink.azurewebsites.net" + }, + "azureusgovernment": { + "aisearch": "privatelink.search.azure.us", + "blobStorage": "privatelink.blob.core.usgovcloudapi.net", + "cognitiveServices": "privatelink.cognitiveservices.azure.us", + "containerRegistry": "privatelink.azurecr.us", + "cosmosDb": "privatelink.documents.azure.us", + "keyVault": "privatelink.vaultcore.azure.us", + "openAi": "privatelink.openai.azure.us", + "webSites": "privatelink.azurewebsites.us" + } + }, + "cloudName": "[toLower(environment().name)]", + "privateDnsZoneData": "[variables('$fxv#0')]", + "aiSearchDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].aisearch]", + "blobStorageDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].blobStorage]", + "cognitiveServicesDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].cognitiveServices]", + "containerRegistryDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].containerRegistry]", + "cosmosDbDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].cosmosDb]", + "keyVaultDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].keyVault]", + "openAiDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].openAi]", + "webSitesDnsZoneName": "[variables('privateDnsZoneData')[variables('cloudName')].webSites]" + }, + "resources": [ + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "keyVaultDNSZone", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "zoneName": { + "value": "[variables('keyVaultDnsZoneName')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "name": { + "value": "kv" + }, + "vNetId": { + "value": "[parameters('virtualNetworkId')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "6963784194716310503" + } + }, + "parameters": { + "zoneName": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vNetId": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('zoneName')]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', parameters('zoneName'), toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name'))))]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[parameters('vNetId')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + ] + } + ], + "outputs": { + "privateDnsZoneId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + }, + "privateDnsZoneName": { + "type": "string", + "value": "[parameters('zoneName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "keyVaultPE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "kv" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "vault" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'keyVaultDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'keyVaultDNSZone')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "cosmosDbDNSZone", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "zoneName": { + "value": "[variables('cosmosDbDnsZoneName')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "name": { + "value": "cosmosDb" + }, + "vNetId": { + "value": "[parameters('virtualNetworkId')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "6963784194716310503" + } + }, + "parameters": { + "zoneName": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vNetId": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('zoneName')]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', parameters('zoneName'), toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name'))))]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[parameters('vNetId')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + ] + } + ], + "outputs": { + "privateDnsZoneId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + }, + "privateDnsZoneName": { + "type": "string", + "value": "[parameters('zoneName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "cosmosDbPE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "cosmosDb" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('cosmosDBName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "sql" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'cosmosDbDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'cosmosDbDNSZone')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "acrDNSZone", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "zoneName": { + "value": "[variables('containerRegistryDnsZoneName')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "name": { + "value": "acr" + }, + "vNetId": { + "value": "[parameters('virtualNetworkId')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "6963784194716310503" + } + }, + "parameters": { + "zoneName": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vNetId": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('zoneName')]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', parameters('zoneName'), toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name'))))]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[parameters('vNetId')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + ] + } + ], + "outputs": { + "privateDnsZoneId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + }, + "privateDnsZoneName": { + "type": "string", + "value": "[parameters('zoneName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "acrPE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "acr" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "registry" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'acrDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'acrDNSZone')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "searchServiceDNSZone", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "zoneName": { + "value": "[variables('aiSearchDnsZoneName')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "name": { + "value": "searchService" + }, + "vNetId": { + "value": "[parameters('virtualNetworkId')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "6963784194716310503" + } + }, + "parameters": { + "zoneName": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vNetId": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('zoneName')]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', parameters('zoneName'), toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name'))))]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[parameters('vNetId')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + ] + } + ], + "outputs": { + "privateDnsZoneId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + }, + "privateDnsZoneName": { + "type": "string", + "value": "[parameters('zoneName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "searchServicePE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "searchService" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.Search/searchServices', parameters('searchServiceName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "searchService" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'searchServiceDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'searchServiceDNSZone')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "docIntelDNSZone", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "zoneName": { + "value": "[variables('cognitiveServicesDnsZoneName')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "name": { + "value": "docIntelService" + }, + "vNetId": { + "value": "[parameters('virtualNetworkId')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "6963784194716310503" + } + }, + "parameters": { + "zoneName": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vNetId": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('zoneName')]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', parameters('zoneName'), toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name'))))]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[parameters('vNetId')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + ] + } + ], + "outputs": { + "privateDnsZoneId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + }, + "privateDnsZoneName": { + "type": "string", + "value": "[parameters('zoneName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "docIntelPE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "docIntelService" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('docIntelName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "account" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storageAccountDNSZone", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "zoneName": { + "value": "[variables('blobStorageDnsZoneName')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "name": { + "value": "storage" + }, + "vNetId": { + "value": "[parameters('virtualNetworkId')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "6963784194716310503" + } + }, + "parameters": { + "zoneName": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vNetId": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('zoneName')]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', parameters('zoneName'), toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name'))))]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[parameters('vNetId')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + ] + } + ], + "outputs": { + "privateDnsZoneId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + }, + "privateDnsZoneName": { + "type": "string", + "value": "[parameters('zoneName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storageAccountPE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "storageAccount" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "blob" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'storageAccountDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'storageAccountDNSZone')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "openAiDNSZone", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "zoneName": { + "value": "[variables('openAiDnsZoneName')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "name": { + "value": "openAiService" + }, + "vNetId": { + "value": "[parameters('virtualNetworkId')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "6963784194716310503" + } + }, + "parameters": { + "zoneName": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vNetId": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('zoneName')]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', parameters('zoneName'), toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name'))))]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[parameters('vNetId')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + ] + } + ], + "outputs": { + "privateDnsZoneId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + }, + "privateDnsZoneName": { + "type": "string", + "value": "[parameters('zoneName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "openAiPE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "openAiService" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('openAIName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "account" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'openAiDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'openAiDNSZone')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "webAppDNSZone", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "zoneName": { + "value": "[variables('webSitesDnsZoneName')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "name": { + "value": "webApp" + }, + "vNetId": { + "value": "[parameters('virtualNetworkId')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "6963784194716310503" + } + }, + "parameters": { + "zoneName": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "name": { + "type": "string" + }, + "vNetId": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('zoneName')]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', parameters('zoneName'), toLower(format('{0}-{1}-{2}-pe-dnszonelink', parameters('appName'), parameters('environment'), parameters('name'))))]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[parameters('vNetId')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + ] + } + ], + "outputs": { + "privateDnsZoneId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/privateDnsZones', parameters('zoneName'))]" + }, + "privateDnsZoneName": { + "type": "string", + "value": "[parameters('zoneName')]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "webAppPE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "webApp" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.Web/sites', parameters('webAppName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "sites" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'webAppDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'webAppDNSZone')]" + ] + }, + { + "condition": "[not(equals(parameters('contentSafetyName'), ''))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "contentSafetyPE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "contentSafety" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('contentSafetyName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "account" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" + ] + }, + { + "condition": "[not(equals(parameters('speechServiceName'), ''))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "speechServicePE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "speechService" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('speechServiceName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "account" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" + ] + }, + { + "condition": "[not(equals(parameters('videoIndexerName'), ''))]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "videoIndexerPE", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "videoIndexerService" + }, + "location": { + "value": "[parameters('location')]" + }, + "appName": { + "value": "[parameters('appName')]" + }, + "environment": { + "value": "[parameters('environment')]" + }, + "serviceResourceID": { + "value": "[resourceId('Microsoft.VideoIndexer/accounts', parameters('videoIndexerName'))]" + }, + "subnetId": { + "value": "[parameters('privateEndpointSubnetId')]" + }, + "groupIDs": { + "value": [ + "account" + ] + }, + "privateDnsZoneIds": { + "value": [ + "[reference(resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone'), '2025-04-01').outputs.privateDnsZoneId.value]" + ] + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.38.33.27573", + "templateHash": "1838240609393686445" + } + }, + "parameters": { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "serviceResourceID": { + "type": "string" + }, + "subnetId": { + "type": "string" + }, + "groupIDs": { + "type": "array" + }, + "privateDnsZoneIds": { + "type": "array", + "defaultValue": [] + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "condition": "[greater(length(parameters('privateDnsZoneIds')), 0)]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2021-05-01", + "name": "[format('{0}/{1}', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))), 'default')]", + "properties": { + "copy": [ + { + "name": "privateDnsZoneConfigs", + "count": "[length(parameters('privateDnsZoneIds'))]", + "input": { + "name": "[last(split(parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')], '/'))]", + "properties": { + "privateDnsZoneId": "[parameters('privateDnsZoneIds')[copyIndex('privateDnsZoneConfigs')]]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateEndpoints', toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name'))))]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2021-05-01", + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[parameters('subnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[toLower(format('{0}-{1}-{2}-pe', parameters('appName'), parameters('environment'), parameters('name')))]", + "properties": { + "privateLinkServiceId": "[parameters('serviceResourceID')]", + "groupIds": "[parameters('groupIDs')]" + } + } + ], + "customNetworkInterfaceName": "[toLower(format('{0}-{1}-{2}-nic', parameters('appName'), parameters('environment'), parameters('name')))]" + }, + "tags": "[parameters('tags')]" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'docIntelDNSZone')]" + ] + } + ] + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'azureContainerRegistry')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'appService')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'contentSafety')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'cosmosDB')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'docIntel')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI')]", + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName'))]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'searchService')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'speechService')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'storageAccount')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'videoIndexerService')]", + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'virtualNetwork')]" + ] + } + ], + "outputs": { + "var_acrName": { + "type": "string", + "value": "[toLower(format('{0}{1}acr', parameters('appName'), parameters('environment')))]" + }, + "var_authenticationType": { + "type": "string", + "value": "[toLower(parameters('authenticationType'))]" + }, + "var_blobStorageEndpoint": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'storageAccount'), '2025-04-01').outputs.endpoint.value]" + }, + "var_configureApplication": { + "type": "bool", + "value": "[parameters('configureApplicationPermissions')]" + }, + "var_contentSafetyEndpoint": { + "type": "string", + "value": "[if(parameters('deployContentSafety'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'contentSafety'), '2025-04-01').outputs.contentSafetyEndpoint.value, '')]" + }, + "var_cosmosDb_accountName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'cosmosDB'), '2025-04-01').outputs.cosmosDbName.value]" + }, + "var_cosmosDb_uri": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'cosmosDB'), '2025-04-01').outputs.cosmosDbUri.value]" + }, + "var_deploymentLocation": { + "type": "string", + "value": "[reference(subscriptionResourceId('Microsoft.Resources/resourceGroups', variables('rgName')), '2022-09-01', 'full').location]" + }, + "var_documentIntelligenceServiceEndpoint": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'docIntel'), '2025-04-01').outputs.documentIntelligenceServiceEndpoint.value]" + }, + "var_keyVaultName": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultName.value]" + }, + "var_keyVaultUri": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'keyVault'), '2025-04-01').outputs.keyVaultUri.value]" + }, + "var_openAIEndpoint": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI'), '2025-04-01').outputs.openAIEndpoint.value]" + }, + "var_openAIGPTModels": { + "type": "array", + "value": "[parameters('gptModels')]" + }, + "var_openAIResourceGroup": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'openAI'), '2025-04-01').outputs.openAIResourceGroup.value]" + }, + "var_openAIEmbeddingModels": { + "type": "array", + "value": "[parameters('embeddingModels')]" + }, + "var_redisCacheHostName": { + "type": "string", + "value": "[if(parameters('deployRedisCache'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'redisCache'), '2025-04-01').outputs.redisCacheHostName.value, '')]" + }, + "var_rgName": { + "type": "string", + "value": "[variables('rgName')]" + }, + "var_searchServiceEndpoint": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'searchService'), '2025-04-01').outputs.searchServiceEndpoint.value]" + }, + "var_speechServiceEndpoint": { + "type": "string", + "value": "[if(parameters('deploySpeechService'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'speechService'), '2025-04-01').outputs.speechServiceEndpoint.value, '')]" + }, + "var_subscriptionId": { + "type": "string", + "value": "[subscription().subscriptionId]" + }, + "var_videoIndexerAccountId": { + "type": "string", + "value": "[if(parameters('deployVideoIndexerService'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'videoIndexerService'), '2025-04-01').outputs.videoIndexerAccountId.value, '')]" + }, + "var_videoIndexerName": { + "type": "string", + "value": "[if(parameters('deployVideoIndexerService'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'videoIndexerService'), '2025-04-01').outputs.videoIndexerServiceName.value, '')]" + }, + "var_containerRegistry": { + "type": "string", + "value": "[variables('containerRegistry')]" + }, + "var_imageName": { + "type": "string", + "value": "[if(contains(parameters('imageName'), ':'), split(parameters('imageName'), ':')[0], parameters('imageName'))]" + }, + "var_imageTag": { + "type": "string", + "value": "[split(parameters('imageName'), ':')[1]]" + }, + "var_webService": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, variables('rgName')), 'Microsoft.Resources/deployments', 'appService'), '2025-04-01').outputs.name.value]" + }, + "var_enablePrivateNetworking": { + "type": "bool", + "value": "[parameters('enablePrivateNetworking')]" + } + } +} \ No newline at end of file diff --git a/deployers/bicep/main.parameters.json b/deployers/bicep/main.parameters.json index e819bca90..dbd83d22d 100644 --- a/deployers/bicep/main.parameters.json +++ b/deployers/bicep/main.parameters.json @@ -2,6 +2,7 @@ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", "contentVersion": "1.0.0.0", "parameters": { + "location": { "value": "${AZURE_LOCATION}" }, @@ -11,26 +12,61 @@ "appName": { "value": "${env_DEPLOYMENT_APPNAME}" }, + "environment": { + "value": "${environment}" + }, "azdEnvironmentName": { "value": "${AZURE_ENV_NAME}" }, - "specialTags": { - "value": { - "Project": "SimpleChat", - "SystemOwner": "Steve Carroll" - } + "imageName": { + "value": "${CONTAINER_IMAGE_NAME}" }, "enterpriseAppClientId": { "value": "${ENTERPRISE_APP_CLIENT_ID}" }, + "enterpriseAppServicePrincipalId": { + "value": "${ENTERPRISE_APP_SERVICE_PRINCIPAL_ID}" + }, "enterpriseAppClientSecret": { "value": "${ENTERPRISE_APP_CLIENT_SECRET}" }, - "imageName": { - "value": "${CONTAINER_IMAGE_NAME}" - }, "authenticationType": { "value": "${AUTHENTICATION_TYPE}" + }, + "configureApplicationPermissions": { + "value": "${CONFIGURE_APPLICATION_PERMISSIONS}" + }, + "specialTags": { + "value": { + "Project": "SimpleChat" + } + }, + "enableDiagLogging": { + "value": "${ENABLE_DIAG_LOGGING}" + }, + "enablePrivateNetworking": { + "value": "${ENABLE_PRIVATE_NETWORKING}" + }, + "gptModels": { + "value": "${GPT_MODELS}" + }, + "embeddingModels": { + "value": "${EMBEDDING_MODELS}" + }, + "allowedIpAddresses": { + "value": "${ALLOWED_IP_RANGES}" + }, + "deployContentSafety": { + "value": "${DEPLOY_CONTENT_SAFETY}" + }, + "deployRedisCache": { + "value": "${DEPLOY_REDIS_CACHE}" + }, + "deploySpeechService": { + "value": "${DEPLOY_SPEECH_SERVICE}" + }, + "deployVideoIndexerService": { + "value": "${DEPLOY_VIDEO_INDEXER_SERVICE}" } } } \ No newline at end of file diff --git a/deployers/bicep/modules/appService.bicep b/deployers/bicep/modules/appService.bicep index 4f70f03a4..5d9aa471b 100644 --- a/deployers/bicep/modules/appService.bicep +++ b/deployers/bicep/modules/appService.bicep @@ -24,6 +24,8 @@ param authenticationType string @secure() param enterpriseAppClientSecret string = '' param keyVaultUri string +param enablePrivateNetworking bool +param appServiceSubnetId string = '' // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { @@ -62,6 +64,11 @@ resource webApp 'Microsoft.Web/sites@2022-03-01' = { kind: 'app,linux,container' properties: { serverFarmId: appServicePlanId + + virtualNetworkSubnetId: appServiceSubnetId != '' ? appServiceSubnetId : null + publicNetworkAccess: 'Enabled' // configuration is set in post provision step in azure.yaml with post deployment script + vnetImagePullEnabled: enablePrivateNetworking ? true : false + siteConfig: { linuxFxVersion: 'DOCKER|${containerImageName}' acrUseManagedIdentityCreds: true @@ -198,7 +205,7 @@ resource authSettings 'Microsoft.Web/sites/config@2022-03-01' = { azureActiveDirectory: { enabled: true registration: { - openIdIssuer: 'https://sts.windows.net/${tenant().tenantId}/' + openIdIssuer: azurePlatform == 'AzureUSGovernment' ? 'https://login.microsoftonline.us/${tenant().tenantId}/' : 'https://sts.windows.net/${tenant().tenantId}/' clientId: enterpriseAppClientId clientSecretSettingName: 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET' } diff --git a/deployers/bicep/modules/appServicePlan.bicep b/deployers/bicep/modules/appServicePlan.bicep index 15e5a1ceb..e6be76e34 100644 --- a/deployers/bicep/modules/appServicePlan.bicep +++ b/deployers/bicep/modules/appServicePlan.bicep @@ -32,6 +32,7 @@ resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = { targetWorkerCount: 0 targetWorkerSizeId: 0 } + tags: tags } diff --git a/deployers/bicep/modules/azureContainerRegistry.bicep b/deployers/bicep/modules/azureContainerRegistry.bicep index 4023447e0..d4ed9b6d9 100644 --- a/deployers/bicep/modules/azureContainerRegistry.bicep +++ b/deployers/bicep/modules/azureContainerRegistry.bicep @@ -11,6 +11,9 @@ param keyVault string param authenticationType string param configureApplicationPermissions bool +param enablePrivateNetworking bool +param allowedIpAddresses array = [] + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -22,11 +25,15 @@ resource acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { location: location sku: { - name: 'Standard' + name: enablePrivateNetworking ? 'Premium' : 'Standard' } properties: { adminUserEnabled: true - publicNetworkAccess: 'Enabled' + publicNetworkAccess: 'Enabled' // configuration is set in post provision step in azure.yaml with post deployment script + networkRuleSet: enablePrivateNetworking ? { + defaultAction: 'Deny' + ipRules: allowedIpAddresses + } : null } tags: tags } diff --git a/deployers/bicep/modules/contentSafety.bicep b/deployers/bicep/modules/contentSafety.bicep index 59c40125f..02ba06c7f 100644 --- a/deployers/bicep/modules/contentSafety.bicep +++ b/deployers/bicep/modules/contentSafety.bicep @@ -12,6 +12,8 @@ param keyVault string param authenticationType string param configureApplicationPermissions bool +param enablePrivateNetworking bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -26,7 +28,7 @@ resource contentSafety 'Microsoft.CognitiveServices/accounts@2025-06-01' = { name: 'S0' } properties: { - publicNetworkAccess: 'Enabled' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' customSubDomainName: toLower('${appName}-${environment}-contentsafety') } tags: tags diff --git a/deployers/bicep/modules/cosmosDb.bicep b/deployers/bicep/modules/cosmosDb.bicep index 67ca669a0..abb6f6748 100644 --- a/deployers/bicep/modules/cosmosDb.bicep +++ b/deployers/bicep/modules/cosmosDb.bicep @@ -12,6 +12,9 @@ param keyVault string param authenticationType string param configureApplicationPermissions bool +param enablePrivateNetworking bool +param allowedIpAddresses array = [] + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -23,12 +26,16 @@ resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = { location: location kind: 'GlobalDocumentDB' properties: { + publicNetworkAccess: 'Enabled' // configuration is set in post provision step in azure.yaml with post deployment script databaseAccountOfferType: 'Standard' capabilities: [ { name: 'EnableServerless' } ] + isVirtualNetworkFilterEnabled: enablePrivateNetworking ? true : false + ipRules: enablePrivateNetworking ? allowedIpAddresses : [] + locations: [ { locationName: location @@ -85,8 +92,8 @@ resource cosmosDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-pre //========================================================= // store cosmos db keys in key vault if using key authentication and configure app permissions = true //========================================================= -module storeEnterpriseAppSecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { - name: 'storeEnterpriseAppSecret' +module storeCosmosDbSecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { + name: 'storeCosmosDbSecret' params: { keyVaultName: keyVault secretName: 'cosmos-db-key' diff --git a/deployers/bicep/modules/documentIntelligence.bicep b/deployers/bicep/modules/documentIntelligence.bicep index 70b343e34..b340c7950 100644 --- a/deployers/bicep/modules/documentIntelligence.bicep +++ b/deployers/bicep/modules/documentIntelligence.bicep @@ -12,6 +12,8 @@ param keyVault string param authenticationType string param configureApplicationPermissions bool +param enablePrivateNetworking bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -26,7 +28,7 @@ resource docIntel 'Microsoft.CognitiveServices/accounts@2025-06-01' = { name: 'S0' } properties: { - publicNetworkAccess: 'Enabled' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' customSubDomainName: toLower('${appName}-${environment}-docintel') } tags: tags diff --git a/deployers/bicep/modules/keyVault.bicep b/deployers/bicep/modules/keyVault.bicep index 6b2786def..384fe3980 100644 --- a/deployers/bicep/modules/keyVault.bicep +++ b/deployers/bicep/modules/keyVault.bicep @@ -27,7 +27,7 @@ resource kv 'Microsoft.KeyVault/vaults@2024-11-01' = { enabledForDeployment: false enabledForDiskEncryption: false enabledForTemplateDeployment: false - publicNetworkAccess: 'Enabled' + publicNetworkAccess: 'Enabled' // configuration is set in post provision step in azure.yaml with post deployment script enableRbacAuthorization: true } tags: tags diff --git a/deployers/bicep/modules/openAI.bicep b/deployers/bicep/modules/openAI.bicep index 9a04b79d6..a5d2e1b62 100644 --- a/deployers/bicep/modules/openAI.bicep +++ b/deployers/bicep/modules/openAI.bicep @@ -15,6 +15,8 @@ param configureApplicationPermissions bool param gptModels array param embeddingModels array +param enablePrivateNetworking bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -32,7 +34,7 @@ resource openAI 'Microsoft.CognitiveServices/accounts@2024-10-01' = { type: 'SystemAssigned' } properties: { - publicNetworkAccess: 'Enabled' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' customSubDomainName: toLower('${appName}-${environment}-openai') } tags: tags diff --git a/deployers/bicep/modules/privateDNS.bicep b/deployers/bicep/modules/privateDNS.bicep new file mode 100644 index 000000000..9306b6cc6 --- /dev/null +++ b/deployers/bicep/modules/privateDNS.bicep @@ -0,0 +1,29 @@ +targetScope = 'resourceGroup' + +param zoneName string +param appName string +param environment string +param name string +param vNetId string +param tags object + +resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { + name: zoneName + location: 'global' + tags: tags +} + +resource privateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { + name: toLower('${appName}-${environment}-${name}-pe-dnszonelink') + parent: privateDnsZone + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vNetId + } + } +} + +output privateDnsZoneId string = privateDnsZone.id +output privateDnsZoneName string = privateDnsZone.name diff --git a/deployers/bicep/modules/privateDNSZones.json b/deployers/bicep/modules/privateDNSZones.json new file mode 100644 index 000000000..66d22738b --- /dev/null +++ b/deployers/bicep/modules/privateDNSZones.json @@ -0,0 +1,22 @@ +{ + "azurecloud": { + "aisearch": "privatelink.search.windows.net", + "blobStorage": "privatelink.blob.core.windows.net", + "cognitiveServices": "privatelink.cognitiveservices.azure.com", + "containerRegistry": "privatelink.azurecr.io", + "cosmosDb": "privatelink.documents.azure.com", + "keyVault": "privatelink.vaultcore.azure.net", + "openAi": "privatelink.openai.azure.com", + "webSites": "privatelink.azurewebsites.net" + }, + "azureusgovernment": { + "aisearch": "privatelink.search.azure.us", + "blobStorage": "privatelink.blob.core.usgovcloudapi.net", + "cognitiveServices": "privatelink.cognitiveservices.azure.us", + "containerRegistry": "privatelink.azurecr.us", + "cosmosDb": "privatelink.documents.azure.us", + "keyVault": "privatelink.vaultcore.azure.us", + "openAi": "privatelink.openai.azure.us", + "webSites": "privatelink.azurewebsites.us" + } +} \ No newline at end of file diff --git a/deployers/bicep/modules/privateEndpoint.bicep b/deployers/bicep/modules/privateEndpoint.bicep new file mode 100644 index 000000000..aaa52da07 --- /dev/null +++ b/deployers/bicep/modules/privateEndpoint.bicep @@ -0,0 +1,56 @@ +targetScope = 'resourceGroup' + +param name string +param location string +param appName string +param environment string +param serviceResourceID string +param subnetId string +param groupIDs array +param privateDnsZoneIds array = [] +param tags object + +resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2021-05-01' = if (length(privateDnsZoneIds) > 0) { + name: 'default' + parent: privateEndpoint + properties: { + privateDnsZoneConfigs: [ + for zoneId in privateDnsZoneIds: { + name: last(split(zoneId, '/')) + properties: { + privateDnsZoneId: zoneId + } + } + ] + } +} + +resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = { + name: toLower('${appName}-${environment}-${name}-pe') + location: location + properties: { + subnet: { + id: subnetId + } + privateLinkServiceConnections: [ + { + name: toLower('${appName}-${environment}-${name}-pe') + properties: { + privateLinkServiceId: serviceResourceID + groupIds: groupIDs + } + } + ] + customNetworkInterfaceName: toLower('${appName}-${environment}-${name}-nic') + } + tags: tags +} + +// @description('Private endpoint resource ID') +// output privateEndpointId string = privateEndpoint.id + +// @description('Private endpoint name') +// output privateEndpointName string = privateEndpoint.name + +// @description('Private IP address assigned to the private endpoint') +// output privateIpAddress string = privateEndpoint.properties.customDnsConfigs[0].ipAddresses[0] diff --git a/deployers/bicep/modules/privateNetworking.bicep b/deployers/bicep/modules/privateNetworking.bicep new file mode 100644 index 000000000..abf5ac39e --- /dev/null +++ b/deployers/bicep/modules/privateNetworking.bicep @@ -0,0 +1,449 @@ +targetScope = 'resourceGroup' + +param virtualNetworkId string +param privateEndpointSubnetId string + +param location string +param appName string +param environment string +param tags object + +param keyVaultName string +param cosmosDBName string +param acrName string +param searchServiceName string +param docIntelName string +param storageAccountName string +param openAIName string +param webAppName string + + +// // redis cache +param contentSafetyName string +param speechServiceName string +param videoIndexerName string + +//========================================================= +// privateDNSZoneNames +var cloudName = toLower(az.environment().name) +var privateDnsZoneData = loadJsonContent('privateDNSZones.json') + +var aiSearchDnsZoneName = privateDnsZoneData[cloudName].aisearch +var blobStorageDnsZoneName = privateDnsZoneData[cloudName].blobStorage +var cognitiveServicesDnsZoneName = privateDnsZoneData[cloudName].cognitiveServices +var containerRegistryDnsZoneName = privateDnsZoneData[cloudName].containerRegistry +var cosmosDbDnsZoneName = privateDnsZoneData[cloudName].cosmosDb +var keyVaultDnsZoneName = privateDnsZoneData[cloudName].keyVault +var openAiDnsZoneName = privateDnsZoneData[cloudName].openAi +var webSitesDnsZoneName = privateDnsZoneData[cloudName].webSites + +//========================================================= +// key vault +//========================================================= +resource kv 'Microsoft.KeyVault/vaults@2025-05-01' existing = { + name: keyVaultName +} + +module keyVaultDNSZone 'privateDNS.bicep' = { + name: 'keyVaultDNSZone' + params: { + zoneName: keyVaultDnsZoneName + appName: appName + environment: environment + name: 'kv' + vNetId: virtualNetworkId + tags: tags + } +} + +module keyVaultPE 'privateEndpoint.bicep' = { + name: 'keyVaultPE' + dependsOn: [ + kv + keyVaultDNSZone + ] + params: { + name: 'kv' + location: location + appName: appName + environment: environment + serviceResourceID: kv.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'vault' + ] + privateDnsZoneIds: [ + keyVaultDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +// cosmos db +//========================================================= +resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' existing = { + name: cosmosDBName +} + +module cosmosDbDNSZone 'privateDNS.bicep' = { + name: 'cosmosDbDNSZone' + params: { + zoneName: cosmosDbDnsZoneName + appName: appName + environment: environment + name: 'cosmosDb' + vNetId: virtualNetworkId + tags: tags + } +} + +module cosmosDbPE 'privateEndpoint.bicep' = { + name: 'cosmosDbPE' + dependsOn: [ + cosmosDb + cosmosDbDNSZone + ] + params: { + name: 'cosmosDb' + location: location + appName: appName + environment: environment + serviceResourceID: cosmosDb.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'sql' + ] + privateDnsZoneIds: [ + cosmosDbDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +// azure container registry +//========================================================= +resource acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: acrName +} + +module acrDNSZone 'privateDNS.bicep' = { + name: 'acrDNSZone' + params: { + zoneName: containerRegistryDnsZoneName + appName: appName + environment: environment + name: 'acr' + vNetId: virtualNetworkId + tags: tags + } +} + +module acrPE 'privateEndpoint.bicep' = { + name: 'acrPE' + dependsOn: [ + acr + acrDNSZone + ] + params: { + name: 'acr' + location: location + appName: appName + environment: environment + serviceResourceID: acr.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'registry' + ] + privateDnsZoneIds: [ + acrDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +// search service +//========================================================= +resource searchService 'Microsoft.Search/searchServices@2025-05-01' existing = { + name: searchServiceName +} + +module searchServiceDNSZone 'privateDNS.bicep' = { + name: 'searchServiceDNSZone' + params: { + zoneName: aiSearchDnsZoneName + appName: appName + environment: environment + name: 'searchService' + vNetId: virtualNetworkId + tags: tags + } +} + +module searchServicePE 'privateEndpoint.bicep' = { + name: 'searchServicePE' + dependsOn: [ + searchService + searchServiceDNSZone + ] + params: { + name: 'searchService' + location: location + appName: appName + environment: environment + serviceResourceID: searchService.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'searchService' + ] + privateDnsZoneIds: [ + searchServiceDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +// document intelligence service +//========================================================= +resource docIntelService 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = if (docIntelName != '') { + name: docIntelName +} + +module docIntelDNSZone 'privateDNS.bicep' = { + name: 'docIntelDNSZone' + params: { + zoneName: cognitiveServicesDnsZoneName + appName: appName + environment: environment + name: 'docIntelService' + vNetId: virtualNetworkId + tags: tags + } +} + +module docIntelPE 'privateEndpoint.bicep' = { + name: 'docIntelPE' + dependsOn: [ + docIntelService + docIntelDNSZone + ] + params: { + name: 'docIntelService' + location: location + appName: appName + environment: environment + serviceResourceID: docIntelService.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'account' + ] + privateDnsZoneIds: [ + docIntelDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +// storage account +//========================================================= +resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { + name: storageAccountName +} + +module storageAccountDNSZone 'privateDNS.bicep' = { + name: 'storageAccountDNSZone' + params: { + zoneName: blobStorageDnsZoneName + appName: appName + environment: environment + name: 'storage' + vNetId: virtualNetworkId + tags: tags + } +} + +module storageAccountPE 'privateEndpoint.bicep' = { + name: 'storageAccountPE' + dependsOn: [ + storageAccount + storageAccountDNSZone + ] + params: { + name: 'storageAccount' + location: location + appName: appName + environment: environment + serviceResourceID: storageAccount.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'blob' + ] + privateDnsZoneIds: [ + storageAccountDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +resource openAiService 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { + name: openAIName +} + +module openAiDNSZone 'privateDNS.bicep' = { + name: 'openAiDNSZone' + params: { + zoneName: openAiDnsZoneName + appName: appName + environment: environment + name: 'openAiService' + vNetId: virtualNetworkId + tags: tags + } +} + +module openAiPE 'privateEndpoint.bicep' = { + name: 'openAiPE' + dependsOn: [ + openAiService + openAiDNSZone + ] + params: { + name: 'openAiService' + location: location + appName: appName + environment: environment + serviceResourceID: openAiService.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'account' + ] + privateDnsZoneIds: [ + openAiDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +// web app +//========================================================= +resource webApp 'Microsoft.Web/sites@2022-03-01' existing = { + name: webAppName +} + +module webAppDNSZone 'privateDNS.bicep' = { + name: 'webAppDNSZone' + params: { + zoneName: webSitesDnsZoneName + appName: appName + environment: environment + name: 'webApp' + vNetId: virtualNetworkId + tags: tags + } +} + +module webAppPE 'privateEndpoint.bicep' = { + name: 'webAppPE' + dependsOn: [ + webApp + webAppDNSZone + ] + params: { + name: 'webApp' + location: location + appName: appName + environment: environment + serviceResourceID: webApp.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'sites' + ] + privateDnsZoneIds: [ + webAppDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +// content safety service - Optional +//========================================================= +resource contentSafety 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = if (contentSafetyName != '') { + name: contentSafetyName +} + +module contentSafetyPE 'privateEndpoint.bicep' = if (contentSafetyName != '') { + name: 'contentSafetyPE' + dependsOn: [ + contentSafety + ] + params: { + name: 'contentSafety' + location: location + appName: appName + environment: environment + serviceResourceID: contentSafety.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'account' + ] + privateDnsZoneIds: [ + docIntelDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +// speech service - Optional +//========================================================= +resource speechService 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = if (speechServiceName != '') { + name: speechServiceName +} + +module speechServicePE 'privateEndpoint.bicep' = if (speechServiceName != '') { + name: 'speechServicePE' + dependsOn: [ + speechService + ] + params: { + name: 'speechService' + location: location + appName: appName + environment: environment + serviceResourceID: speechService.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'account' + ] + privateDnsZoneIds: [ + docIntelDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} +//========================================================= +// video indexer service - Optional +//========================================================= +resource videoIndexerService 'Microsoft.VideoIndexer/accounts@2025-04-01' existing = if (videoIndexerName != '') { + name: videoIndexerName +} + +module videoIndexerPE 'privateEndpoint.bicep' = if (videoIndexerName != '') { + name: 'videoIndexerPE' + dependsOn: [ + videoIndexerService + ] + params: { + name: 'videoIndexerService' + location: location + appName: appName + environment: environment + serviceResourceID: videoIndexerService.id + subnetId: privateEndpointSubnetId + groupIDs: [ + 'account' + ] + privateDnsZoneIds: [ + docIntelDNSZone.outputs.privateDnsZoneId + ] + tags: tags + } +} diff --git a/deployers/bicep/modules/redisCache.bicep b/deployers/bicep/modules/redisCache.bicep index faab2e236..e782318ae 100644 --- a/deployers/bicep/modules/redisCache.bicep +++ b/deployers/bicep/modules/redisCache.bicep @@ -62,3 +62,4 @@ module redisCacheSecret 'keyVault-Secrets.bicep' = if (authenticationType == 'ke } output redisCacheName string = redisCache.name +output redisCacheHostName string = redisCache.properties.hostName diff --git a/deployers/bicep/modules/search.bicep b/deployers/bicep/modules/search.bicep index 2786b91e2..c474ade57 100644 --- a/deployers/bicep/modules/search.bicep +++ b/deployers/bicep/modules/search.bicep @@ -12,6 +12,8 @@ param keyVault string param authenticationType string param configureApplicationPermissions bool +param enablePrivateNetworking bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -27,7 +29,7 @@ resource searchService 'Microsoft.Search/searchServices@2025-05-01' = { properties: { #disable-next-line BCP036 // template is incorrect hostingMode: 'default' - publicNetworkAccess: 'Enabled' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' replicaCount: 1 partitionCount: 1 authOptions: { @@ -54,7 +56,7 @@ resource searchDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-pre //========================================================= // store search Service keys in key vault if using key authentication and configure app permissions = true //========================================================= -module searchServiceSecret 'keyVault-Secrets.bicep' = if (configureApplicationPermissions) { +module searchServiceSecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { name: 'storeSearchServiceSecret' params: { keyVaultName: keyVault diff --git a/deployers/bicep/modules/setPermissions.bicep b/deployers/bicep/modules/setPermissions.bicep index 33564d8ed..675c12b94 100644 --- a/deployers/bicep/modules/setPermissions.bicep +++ b/deployers/bicep/modules/setPermissions.bicep @@ -11,6 +11,7 @@ param docIntelName string param storageAccountName string param speechServiceName string param searchServiceName string +param redisCacheName string param contentSafetyName string param videoIndexerName string @@ -50,6 +51,10 @@ resource searchService 'Microsoft.Search/searchServices@2025-05-01' existing = { name: searchServiceName } +resource redisCache 'Microsoft.Cache/Redis@2024-11-01' existing = if (redisCacheName != '') { + name: redisCacheName +} + resource contentSafety 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = if (contentSafetyName != '') { name: contentSafetyName } @@ -269,3 +274,17 @@ resource videoIndexerStorageCogServicesUserRole 'Microsoft.Authorization/roleAss principalType: 'ServicePrincipal' } } + +// grant the managed identity access to redis cache as a Redis Cache Contributor +resource redisCacheContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (redisCacheName != '') { + name: guid(redisCache.id, webApp.id, 'redis-cache-contributor') + scope: redisCache + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + 'e0f68234-74aa-48ed-b826-c38b57376e17' + ) + principalId: webApp.identity.principalId + principalType: 'ServicePrincipal' + } +} diff --git a/deployers/bicep/modules/speechService.bicep b/deployers/bicep/modules/speechService.bicep index 17391ac76..d7f1de1fd 100644 --- a/deployers/bicep/modules/speechService.bicep +++ b/deployers/bicep/modules/speechService.bicep @@ -12,6 +12,8 @@ param keyVault string param authenticationType string param configureApplicationPermissions bool +param enablePrivateNetworking bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -29,7 +31,7 @@ resource speechService 'Microsoft.CognitiveServices/accounts@2024-10-01' = { type: 'SystemAssigned' } properties: { - publicNetworkAccess: 'Enabled' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' customSubDomainName: toLower('${appName}-${environment}-speech') } tags: tags diff --git a/deployers/bicep/modules/storageAccount.bicep b/deployers/bicep/modules/storageAccount.bicep index 7e10ccae3..cf38ac527 100644 --- a/deployers/bicep/modules/storageAccount.bicep +++ b/deployers/bicep/modules/storageAccount.bicep @@ -12,6 +12,8 @@ param keyVault string param authenticationType string param configureApplicationPermissions bool +param enablePrivateNetworking bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -26,7 +28,9 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' = { name: 'Standard_LRS' } kind: 'StorageV2' + properties: { + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' accessTier: 'Hot' allowBlobPublicAccess: false allowSharedKeyAccess: true diff --git a/deployers/bicep/modules/videoIndexer.bicep b/deployers/bicep/modules/videoIndexer.bicep index 8f41544ac..786d71080 100644 --- a/deployers/bicep/modules/videoIndexer.bicep +++ b/deployers/bicep/modules/videoIndexer.bicep @@ -11,6 +11,8 @@ param logAnalyticsId string param storageAccount string param openAiServiceName string +param enablePrivateNetworking bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -33,7 +35,7 @@ resource videoIndexerService 'Microsoft.VideoIndexer/accounts@2025-04-01' = { type: 'SystemAssigned' } properties: { - publicNetworkAccess: 'Enabled' + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' storageServices: { resourceId: storage.id } diff --git a/deployers/bicep/modules/virtualNetwork.bicep b/deployers/bicep/modules/virtualNetwork.bicep new file mode 100644 index 000000000..03952410c --- /dev/null +++ b/deployers/bicep/modules/virtualNetwork.bicep @@ -0,0 +1,44 @@ +targetScope = 'resourceGroup' + +param location string +param vNetName string +param addressSpaces array +param subnetConfigs array +param tags object + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2021-05-01' = { + name: vNetName + location: location + properties: { + addressSpace: { + addressPrefixes: addressSpaces + } + subnets: [for subnet in subnetConfigs: { + name: subnet.name + properties: { + addressPrefix: subnet.addressPrefix + privateEndpointNetworkPolicies: subnet.enablePrivateEndpointNetworkPolicies ? 'Enabled' : 'Disabled' + privateLinkServiceNetworkPolicies: subnet.enablePrivateLinkServiceNetworkPolicies ? 'Enabled' : 'Disabled' + delegations: subnet.name == 'AppServiceIntegration' ? [ + { + name: 'delegation' + properties: { + serviceName: 'Microsoft.Web/serverFarms' + } + } + ] : [] + } + }] + } + tags: tags +} + +var subnetIds = [for subnet in subnetConfigs: resourceId('Microsoft.Network/virtualNetworks/subnets', vNetName, subnet.name)] +var subnetNames = [for subnet in subnetConfigs: subnet.name] +var appServiceIntegrationSubnetIndex = indexOf(subnetNames, 'AppServiceIntegration') +var privateEndpointIndex = indexOf(subnetNames, 'PrivateEndpoints') + +// output results +output vNetId string = virtualNetwork.id +output privateNetworkSubnetId string = privateEndpointIndex == -1 ? '' : subnetIds[privateEndpointIndex] +output appServiceSubnetId string = appServiceIntegrationSubnetIndex == -1 ? '' : subnetIds[appServiceIntegrationSubnetIndex] diff --git a/deployers/bicep/params/scjulie1.bicepparam b/deployers/bicep/params/scjulie1.bicepparam deleted file mode 100644 index 6b115dc37..000000000 --- a/deployers/bicep/params/scjulie1.bicepparam +++ /dev/null @@ -1,18 +0,0 @@ -using '../main.bicep' - -param azurePlatform = 'AzureUSGovernment' // or 'AzureCloud' -param tenantId = '' // Provide via --parameters or a secure way -param location = 'usgovvirginia' // or your preferred region -param resourceOwnerId = 'johndoe@domain.com' -param environment = 'sbx' -param baseName = 'julie1' -param acrName = 'acr8000' -param acrResourceGroupName = 'sc-emma1-sbx1-rg' // RG of your ACR -param imageName = 'simple-chat:2025-05-15_7' // Be specific with tags -param useExistingOpenAiInstance = true -param existingAzureOpenAiResourceName = 'gregazureopenai1' // if useExistingOpenAiInstance is true -param existingAzureOpenAiResourceGroupName = 'azureopenairg' // if useExistingOpenAiInstance is true -param appRegistrationClientId = 'a9acf8e2-441d-4aca-84f6-a83b3e820644' // scbingo1-ar -param appRegistrationClientSecret = '' // Provide via --parameters or a secure way -param appRegistrationSpObjectId = '364c5131-27b3-4ac1-bf95-0bd55106a109' -// Other SKUs can be overridden if needed diff --git a/deployers/bicep/postconfig.py b/deployers/bicep/postconfig.py index 44406da2e..5a12b56fd 100644 --- a/deployers/bicep/postconfig.py +++ b/deployers/bicep/postconfig.py @@ -45,8 +45,8 @@ var_blobStorageEndpoint = os.getenv("var_blobStorageEndpoint") var_contentSafetyEndpoint = os.getenv("var_contentSafetyEndpoint") var_searchServiceEndpoint = os.getenv("var_searchServiceEndpoint") -var_documentIntelligenceServiceEndpoint = os.getenv( - "var_documentIntelligenceServiceEndpoint") +var_documentIntelligenceServiceEndpoint = os.getenv("var_documentIntelligenceServiceEndpoint") +var_redisCacheHostName = os.getenv("var_redisCacheHostName") var_videoIndexerName = os.getenv("var_videoIndexerName") var_videoIndexerLocation = os.getenv("var_deploymentLocation") var_videoIndexerAccountId = os.getenv("var_videoIndexerAccountId") @@ -133,7 +133,7 @@ item["enable_content_safety"] = True item["content_safety_endpoint"] = var_contentSafetyEndpoint item["content_safety_authentication_type"] = var_authenticationType -if keyvault_client: +if keyvault_client and var_authenticationType == "key": try: contentSafety_key_secret = keyvault_client.get_secret( "content-safety-key") @@ -143,13 +143,27 @@ print( f"Warning: Could not retrieve content-safety-key from Key Vault: {e}") +# Redis Cache Configuration +if var_redisCacheHostName and var_redisCacheHostName.strip(): + item["enable_redis_cache"] = True +item["redis_url"] = var_redisCacheHostName +item["redis_auth_type"] = var_authenticationType +if keyvault_client and var_authenticationType == "key": + try: + redis_key_secret = keyvault_client.get_secret("redis-cache-key") + item["redis_key"] = redis_key_secret.value + print("Retrieved redis cache key from Key Vault") + except Exception as e: + print( + f"Warning: Could not retrieve redis-cache-key from Key Vault: {e}") + # Safety > Conversation Archiving item["enable_conversation_archiving"] = True # Search and Extract > Azure AI Search item["azure_ai_search_endpoint"] = var_searchServiceEndpoint item["azure_ai_search_authentication_type"] = var_authenticationType -if keyvault_client: +if keyvault_client and var_authenticationType == "key": try: search_key_secret = keyvault_client.get_secret("search-service-key") item["azure_ai_search_key"] = search_key_secret.value @@ -161,7 +175,7 @@ # Search and Extract > Azure Document Intelligence item["azure_document_intelligence_endpoint"] = var_documentIntelligenceServiceEndpoint item["azure_document_intelligence_authentication_type"] = var_authenticationType -if keyvault_client: +if keyvault_client and var_authenticationType == "key": try: documentIntelligence_key_secret = keyvault_client.get_secret( "document-intelligence-key") @@ -186,7 +200,7 @@ item["enable_audio_file_support"] = True item["speech_service_endpoint"] = var_speechServiceEndpoint item["speech_service_location"] = var_speechServiceLocation -if keyvault_client: +if keyvault_client and var_authenticationType == "key": try: speech_key_secret = keyvault_client.get_secret("speech-service-key") item["speech_service_key"] = speech_key_secret.value diff --git a/docs/explanation/features/v0.236.011/CONTROL_CENTER_APPLICATION_ROLES.md b/docs/explanation/features/v0.236.011/CONTROL_CENTER_APPLICATION_ROLES.md new file mode 100644 index 000000000..29ffc1fca --- /dev/null +++ b/docs/explanation/features/v0.236.011/CONTROL_CENTER_APPLICATION_ROLES.md @@ -0,0 +1,154 @@ +# Control Center Application Roles + +## Overview + +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 + +## New Roles + +### Control Center Admin + +| Property | Value | +|----------|-------| +| **Role Name** | Control Center Admin | +| **Description** | Full administrative access to Control Center functionality | +| **Access Level** | Full read/write access to all Control Center features | + +**Permissions:** +- View all Control Center dashboards and metrics +- Manage user access and permissions +- Execute administrative operations (take ownership, transfer, delete) +- Approve/reject workflow requests +- Configure Control Center settings + +### Control Center Dashboard Reader + +| Property | Value | +|----------|-------| +| **Role Name** | Control Center Dashboard Reader | +| **Description** | Read-only access to Control Center dashboards | +| **Access Level** | View-only access to dashboards and metrics | + +**Permissions:** +- View Control Center dashboard +- View activity trends and metrics +- View user statistics +- View group and workspace information +- **Cannot** perform administrative actions +- **Cannot** modify settings or configurations + +## Use Cases + +### Scenario 1: IT Operations Team +- **Need**: Monitor system health and usage without admin capabilities +- **Solution**: Assign "Control Center Dashboard Reader" role +- **Benefit**: Visibility into metrics without risk of accidental changes + +### Scenario 2: Delegated Administration +- **Need**: Department leads manage their users' access +- **Solution**: Assign "Control Center Admin" role to specific individuals +- **Benefit**: Distributed administration without full application admin access + +### Scenario 3: Compliance Auditors +- **Need**: Review activity logs and usage patterns +- **Solution**: Assign "Control Center Dashboard Reader" role +- **Benefit**: Audit capability without modification access + +## Configuration + +### Adding Roles to Entra ID Enterprise Application + +1. Navigate to Azure Portal → Entra ID → Enterprise Applications +2. Find your SimpleChat application registration +3. Go to **App roles** +4. Add the new roles from `appRegistrationRoles.json` + +### Role Assignment + +```json +{ + "roles": [ + { + "allowedMemberTypes": ["User"], + "description": "Full administrative access to Control Center", + "displayName": "Control Center Admin", + "isEnabled": true, + "value": "ControlCenterAdmin" + }, + { + "allowedMemberTypes": ["User"], + "description": "Read-only access to Control Center dashboards", + "displayName": "Control Center Dashboard Reader", + "isEnabled": true, + "value": "ControlCenterDashboardReader" + } + ] +} +``` + +### Assigning Roles to Users + +1. Navigate to Enterprise Application → Users and groups +2. Click **Add user/group** +3. Select user(s) to assign +4. Select the appropriate role +5. Click **Assign** + +## Role Hierarchy + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Admin │ ← Full application admin +│ (All permissions) │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Control Center Admin │ ← Control Center admin only +│ (Full CC access, no app settings) │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ + │ +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā–¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Control Center Dashboard Reader │ ← View-only access +│ (Read-only dashboard access) │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +## Integration with Existing Roles + +| Existing Role | Control Center Access | +|---------------|----------------------| +| Admin | Full access (includes all CC permissions) | +| User | No Control Center access by default | +| Owner | Group-level access only | +| DocumentManager | No Control Center access | + +| New Role | Control Center Access | +|----------|----------------------| +| ControlCenterAdmin | Full CC admin access | +| ControlCenterDashboardReader | Read-only CC dashboard access | + +## Security Considerations + +1. **Principle of Least Privilege**: Assign Dashboard Reader by default, escalate to Admin only when needed +2. **Audit Trail**: All Control Center actions are logged regardless of role +3. **Role Separation**: Dashboard Reader cannot perform any destructive operations +4. **Admin Oversight**: Full Admin role retains visibility into all role assignments + +## Files Modified + +- `appRegistrationRoles.json` - Added new role definitions + +## Related Features + +- [Control Center](../v0.235.001/control_center.md) - Main Control Center functionality +- [Approval Workflow System](../v0.235.001/APPROVAL_WORKFLOW_SYSTEM.md) - Protected operations requiring approval +- [Enhanced User Management](../v0.235.001/ENHANCED_USER_MANAGEMENT.md) - User metrics and management + +## Migration Notes + +Existing deployments should: +1. Update the Entra ID app registration with new roles +2. Assign appropriate roles to users who need Control Center access +3. Review existing Admin role assignments for potential role refinement diff --git a/docs/explanation/features/v0.236.011/CONVERSATION_DEEP_LINKING.md b/docs/explanation/features/v0.236.011/CONVERSATION_DEEP_LINKING.md new file mode 100644 index 000000000..d3c6e53e8 --- /dev/null +++ b/docs/explanation/features/v0.236.011/CONVERSATION_DEEP_LINKING.md @@ -0,0 +1,141 @@ +# Conversation Deep Linking + +## Overview + +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 + +## Key Features + +- **Direct Conversation Links**: Share URLs that open a specific conversation +- **URL Parameter Support**: Supports both `conversationId` and `conversation_id` parameters +- **Automatic URL Updates**: Current conversation ID is automatically added to the URL +- **Browser History Integration**: Uses `replaceState` to update URLs without creating new history entries +- **Error Handling**: Graceful handling of invalid or inaccessible conversation IDs + +## How It Works + +### URL Format + +Conversations can be linked using either parameter format: + +``` +https://your-simplechat.com/?conversationId= +https://your-simplechat.com/?conversation_id= +``` + +### Automatic URL Updates + +When users select a conversation in the sidebar, the URL is automatically updated to include the conversation ID: + +```javascript +function updateConversationUrl(conversationId) { + if (!conversationId) return; + + try { + const url = new URL(window.location.href); + url.searchParams.set('conversationId', conversationId); + window.history.replaceState({}, '', url.toString()); + } catch (error) { + console.warn('Failed to update conversation URL:', error); + } +} +``` + +### Deep Link Loading + +On page load, the application checks for a `conversationId` parameter and loads that conversation: + +```javascript +// Deep-link: conversationId query param +const conversationId = getUrlParameter("conversationId") || getUrlParameter("conversation_id"); +if (conversationId) { + try { + await ensureConversationPresent(conversationId); + await selectConversation(conversationId); + } catch (err) { + console.error('Failed to load conversation from URL param:', err); + showToast('Could not open that conversation.', 'danger'); + } +} +``` + +## User Experience + +### Sharing Conversations + +1. Navigate to any conversation +2. Copy the URL from the browser address bar +3. Share the URL with colleagues +4. Recipients with access can open the link to view the conversation + +### Receiving Shared Links + +1. Click or paste a shared conversation link +2. The application loads and displays the referenced conversation +3. If the conversation doesn't exist or isn't accessible, an error toast is shown + +### Error Handling + +When a deep link fails to load: +- A toast notification appears: "Could not open that conversation." +- The user remains on the default view +- Console logging captures the error details for debugging + +## Technical Architecture + +### Frontend Components + +| File | Purpose | +|------|---------| +| [chat-onload.js](../../../../application/single_app/static/js/chat/chat-onload.js) | Handles deep link loading on page initialization | +| [chat-conversations.js](../../../../application/single_app/static/js/chat/chat-conversations.js) | `updateConversationUrl()` function for URL management | + +### Functions Involved + +| Function | Purpose | +|----------|---------| +| `getUrlParameter(name)` | Retrieves query parameter value from current URL | +| `ensureConversationPresent(id)` | Ensures conversation exists in the local list | +| `selectConversation(id)` | Loads and displays the specified conversation | +| `updateConversationUrl(id)` | Updates URL with current conversation ID | + +## Use Cases + +### Team Collaboration +- Share conversation links in chat or email for review +- Direct colleagues to specific AI interactions for discussion + +### Support and Troubleshooting +- Users can share conversation links with support staff +- Administrators can reference specific conversations in reports + +### Documentation +- Bookmark important conversations for future reference +- Create documentation links to example interactions + +## Security Considerations + +1. **Access Control**: Deep links respect existing conversation access permissions +2. **User Ownership**: Only accessible if the user has rights to the conversation +3. **No Authentication Bypass**: Users must still be logged in to access conversations +4. **Workspace Boundaries**: Workspace permissions still apply + +## Browser Compatibility + +- Uses standard `URL` and `URLSearchParams` APIs +- `history.replaceState()` for seamless URL updates +- Compatible with all modern browsers + +## Known Limitations + +- Deep links only work for conversations the current user has access to +- Links to deleted conversations will show an error +- Group/public workspace conversations require appropriate membership + +## Related Features + +- Conversation management and history +- Sidebar conversation navigation +- Chat workspace functionality diff --git a/docs/explanation/features/v0.236.011/PLUGIN_AUTH_TYPE_CONSTRAINTS.md b/docs/explanation/features/v0.236.011/PLUGIN_AUTH_TYPE_CONSTRAINTS.md new file mode 100644 index 000000000..093923c62 --- /dev/null +++ b/docs/explanation/features/v0.236.011/PLUGIN_AUTH_TYPE_CONSTRAINTS.md @@ -0,0 +1,202 @@ +# Plugin Authentication Type Constraints + +## Overview + +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 + +## Key Features + +- **Per-Plugin Auth Types**: Each plugin type can define its own allowed authentication types +- **Schema-Based Defaults**: Falls back to global AuthType enum from plugin.schema.json +- **Definition File Overrides**: Plugin-specific definition files can restrict available auth types +- **API Endpoint**: RESTful endpoint to query allowed auth types for any plugin type + +## How It Works + +### Authentication Type Resolution + +The system resolves allowed authentication types in this order: + +1. **Check Plugin Definition File**: `{plugin_type}.definition.json` + - If `allowedAuthTypes` array exists and is non-empty, use it +2. **Fallback to Global Schema**: `plugin.schema.json` + - Use the `AuthType` enum from definitions + +### API Endpoint + +``` +GET /api/plugins/{plugin_type}/auth-types +``` + +**Response:** +```json +{ + "allowedAuthTypes": ["none", "api_key", "oauth2", "basic"], + "source": "definition" +} +``` + +**Response Fields:** +| Field | Description | +|-------|-------------| +| `allowedAuthTypes` | Array of allowed authentication type strings | +| `source` | Where the types came from: "definition" or "schema" | + +## Configuration Files + +### Plugin Schema (Global Defaults) + +Location: `static/json/schemas/plugin.schema.json` + +```json +{ + "definitions": { + "AuthType": { + "enum": ["none", "api_key", "oauth2", "basic", "bearer", "custom"] + } + } +} +``` + +### Plugin Definition Files (Per-Plugin Overrides) + +Location: `static/json/schemas/{plugin_type}.definition.json` + +Example for a plugin that only supports API key authentication: + +```json +{ + "name": "weather_plugin", + "displayName": "Weather API", + "description": "Get weather information", + "allowedAuthTypes": ["none", "api_key"] +} +``` + +## Technical Architecture + +### Backend Implementation + +Location: [route_backend_plugins.py](../../../../application/single_app/route_backend_plugins.py) + +```python +@bpap.route('/api/plugins//auth-types', methods=['GET']) +@login_required +@user_required +def get_plugin_auth_types(plugin_type): + """ + Returns allowed auth types for a plugin type. Uses definition file if present, + otherwise falls back to AuthType enum in plugin.schema.json. + """ + schema_dir = os.path.join(current_app.root_path, 'static', 'json', 'schemas') + safe_type = re.sub(r'[^a-zA-Z0-9_]', '_', plugin_type).lower() + + # Try to load from plugin definition file + definition_path = os.path.join(schema_dir, f'{safe_type}.definition.json') + schema_path = os.path.join(schema_dir, 'plugin.schema.json') + + allowed_auth_types = [] + source = "schema" + + # Load defaults from schema + try: + with open(schema_path, 'r', encoding='utf-8') as schema_file: + schema = json.load(schema_file) + allowed_auth_types = ( + schema + .get('definitions', {}) + .get('AuthType', {}) + .get('enum', []) + ) + except Exception: + allowed_auth_types = [] + + # Override with definition file if present + if os.path.exists(definition_path): + try: + with open(definition_path, 'r', encoding='utf-8') as definition_file: + definition = json.load(definition_file) + allowed_from_definition = definition.get('allowedAuthTypes') + if isinstance(allowed_from_definition, list) and allowed_from_definition: + allowed_auth_types = allowed_from_definition + source = "definition" + except Exception: + pass + + return jsonify({ + "allowedAuthTypes": allowed_auth_types, + "source": source + }) +``` + +### Security + +- Plugin type is sanitized to prevent path traversal +- Only alphanumeric characters and underscores are allowed in plugin type names +- Endpoint requires user authentication + +## Common Authentication Types + +| Type | Description | Use Case | +|------|-------------|----------| +| `none` | No authentication required | Public APIs | +| `api_key` | API key in header or query | Most REST APIs | +| `oauth2` | OAuth 2.0 flow | Microsoft Graph, Google APIs | +| `basic` | Basic HTTP authentication | Legacy systems | +| `bearer` | Bearer token authentication | JWT-based APIs | +| `custom` | Custom authentication handler | Special requirements | + +## Use Cases + +### Restricting Auth for Internal Plugins + +An internal plugin might only support specific authentication: + +```json +{ + "name": "internal_hr_system", + "allowedAuthTypes": ["oauth2"] +} +``` + +### Simple Public API Plugin + +A public weather API might need no authentication: + +```json +{ + "name": "public_weather", + "allowedAuthTypes": ["none", "api_key"] +} +``` + +## Frontend Integration + +The frontend can query auth types to: +1. Display only valid authentication options in plugin configuration UI +2. Validate user selections before saving +3. Show appropriate configuration fields based on auth type + +Example usage: + +```javascript +async function loadAuthTypes(pluginType) { + const response = await fetch(`/api/plugins/${pluginType}/auth-types`); + const data = await response.json(); + return data.allowedAuthTypes; +} +``` + +## Known Limitations + +- Auth types must be predefined in the schema +- Custom auth implementations require additional plugin code +- Definition files must be manually created for each plugin type + +## Related Features + +- Plugin Management +- Action/Plugin Registration +- OpenAPI Plugin Integration diff --git a/docs/explanation/features/v0.236.011/PRIVATE_NETWORKING_SUPPORT.md b/docs/explanation/features/v0.236.011/PRIVATE_NETWORKING_SUPPORT.md new file mode 100644 index 000000000..de2ae92f1 --- /dev/null +++ b/docs/explanation/features/v0.236.011/PRIVATE_NETWORKING_SUPPORT.md @@ -0,0 +1,179 @@ +# Private Networking Support + +## Overview + +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 + +## Key Features + +- **Private Endpoint Support**: All Azure PaaS services can be configured with private endpoints +- **Virtual Network Integration**: Full VNet integration for App Service and dependent resources +- **Private DNS Zones**: Automated DNS zone configuration for private endpoint resolution +- **AZD Integration**: Seamless deployment via `azd up` with private networking enabled +- **Bicep Automation**: Infrastructure-as-code templates for reproducible deployments +- **Post-Deployment Security**: Automatic disabling of public network access when private networking is enabled + +## Architecture + +### Network Topology + +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Virtual Network │ +│ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ +│ │ App Service │ │ Private DNS │ │ Private │ │ +│ │ Subnet │ │ Zones │ │ Endpoints │ │ +│ │ │ │ │ │ Subnet │ │ +│ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ │ - Cosmos DB │ │ │ │ +│ │ │SimpleChat │ │ │ - OpenAI │ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ │ +│ │ │ App │──────│ - AI Search │──────│ Cosmos DB │ │ │ +│ │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ │ - Storage │ │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ │ +│ │ │ │ - Key Vault │ │ │ │ +│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ │ +│ │ │ Azure │ │ │ +│ │ │ OpenAI │ │ │ +│ │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ │ +│ │ │ │ +│ │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” │ │ +│ │ │ AI Search │ │ │ +│ │ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ │ +│ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +### Supported Private Endpoints + +| Service | Private DNS Zone | +|---------|-----------------| +| Azure Cosmos DB | `privatelink.documents.azure.com` | +| Azure OpenAI | `privatelink.openai.azure.com` | +| Azure AI Search | `privatelink.search.windows.net` | +| Azure Blob Storage | `privatelink.blob.core.windows.net` | +| Azure Key Vault | `privatelink.vaultcore.azure.net` | +| Azure Document Intelligence | `privatelink.cognitiveservices.azure.com` | + +## Deployment + +### Prerequisites + +1. **Azure Subscription** with appropriate permissions +2. **Azure Developer CLI (AZD)** installed +3. **Azure CLI** installed and authenticated +4. **Permissions**: Contributor or higher on the subscription/resource group + +### AZD Deployment + +```bash +# Clone the repository +git clone https://github.com/microsoft/simplechat.git +cd simplechat/deployers + +# Initialize AZD (first time) +azd init + +# Enable private networking +azd env set ENABLE_PRIVATE_NETWORKING true + +# Deploy with private networking +azd up +``` + +### Bicep Deployment + +```bash +# Deploy with private networking parameter +az deployment sub create \ + --location eastus \ + --template-file main.bicep \ + --parameters enablePrivateNetworking=true +``` + +## Configuration Options + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ENABLE_PRIVATE_NETWORKING` | Enable private endpoints for all services | `false` | +| `VNET_ADDRESS_SPACE` | Virtual network address space | `10.0.0.0/16` | +| `APP_SUBNET_PREFIX` | App Service subnet prefix | `10.0.1.0/24` | +| `PRIVATE_ENDPOINT_SUBNET_PREFIX` | Private endpoints subnet prefix | `10.0.2.0/24` | + +## Deployment Hooks + +### Post-Provision Hook +- Creates private DNS zones +- Configures private endpoints +- Sets up VNet integration + +### Pre-Deploy Hook +- Validates network configuration +- Ensures DNS resolution is working + +### Post-Up Hook +- **NEW**: Automatically disables public network access for resources when private networking is enabled +- Validates connectivity through private endpoints +- Outputs connection validation results + +## Azure Government Considerations + +### Regional Availability +- Private endpoints available in all USGov regions +- Some services may have regional restrictions + +### Model Configuration +- Azure OpenAI models may differ in government regions +- Configure model overrides as needed + +### Service Limitations +- Some preview features may not be available +- Check Azure Government documentation for current status + +## Error Handling + +The deployment scripts include: +- **Stepwise logging**: Detailed output for each deployment phase +- **Explicit error handling**: Failures caught early with clear messages +- **Troubleshooting guidance**: Helpful error messages for common issues + +## Post-Deployment Validation + +After deployment, validate: + +1. **DNS Resolution**: Private DNS zones resolve correctly +2. **Network Connectivity**: App Service can reach all services via private endpoints +3. **AI Model Connections**: Test chat functionality +4. **Search Integration**: Verify AI Search connectivity +5. **Document Processing**: Test Document Intelligence + +## Security Benefits + +1. **No Public Exposure**: Services not accessible from public internet +2. **Network Isolation**: All traffic stays within Azure backbone +3. **Reduced Attack Surface**: Minimized exposure to external threats +4. **Compliance**: Meets enterprise security requirements +5. **Data Protection**: Data never traverses public networks + +## Known Issues and Workarounds + +1. **DNS Propagation Delay**: Allow 5-10 minutes for DNS changes to propagate +2. **VNet Peering**: Additional configuration needed if peering with existing VNets +3. **On-Premises Connectivity**: Requires ExpressRoute or VPN Gateway for hybrid scenarios + +## Files Modified + +### Deployment Files +- `deployers/azure.yaml` - Enhanced hooks with logging and error handling +- `deployers/bicep/*.bicep` - Private networking Bicep templates + +### Documentation +- `deployers/bicep/README.md` - Enhanced prerequisites and USGov guidance +- `OneClickDeploy.md` - Corrected deployment button links + +## Related Documentation + +- [Azure Private Endpoints Documentation](https://docs.microsoft.com/azure/private-link/private-endpoint-overview) +- [App Service VNet Integration](https://docs.microsoft.com/azure/app-service/web-sites-integrate-with-vnet) +- [Private DNS Zones](https://docs.microsoft.com/azure/dns/private-dns-overview) diff --git a/docs/explanation/features/v0.236.011/RETENTION_POLICY_DEFAULTS.md b/docs/explanation/features/v0.236.011/RETENTION_POLICY_DEFAULTS.md new file mode 100644 index 000000000..e3fe426df --- /dev/null +++ b/docs/explanation/features/v0.236.011/RETENTION_POLICY_DEFAULTS.md @@ -0,0 +1,208 @@ +# RETENTION_POLICY_DEFAULTS.md + +**Feature**: Admin-Configurable Default Retention Policies +**Version**: 0.236.011 +**Implemented in**: 0.236.011 + +## Overview and Purpose + +The Retention Policy Defaults feature allows administrators to configure organization-wide default retention periods for conversations and documents across all workspace types (personal, group, and public). Users can choose to use the organization default or set their own custom retention period. Administrators also have the ability to force push defaults to override all custom policies. + +## Key Features + +- **Organization Defaults**: Set default retention periods for conversations and documents per workspace type +- **User Choice**: Users see "Using organization default (X days)" option and can override with custom settings +- **Conditional Display**: Default settings only appear for enabled workspace types +- **Force Push**: Administrators can push organization defaults to all workspaces, overriding custom settings +- **Activity Logging**: Force push actions are logged for audit purposes +- **Settings Auto-Save**: Force push automatically saves pending settings changes before executing + +## Technical Specifications + +### Architecture Overview + +The feature integrates with the existing retention policy system and adds: + +1. **Backend Settings** - 6 new settings fields for default retention values +2. **Admin UI** - Dropdown selectors in Admin Settings for each workspace type +3. **API Endpoints** - New endpoints for fetching defaults and force pushing +4. **User UI Integration** - Updated profile, control center, and workspace manager +5. **Execution Logic** - Resolution of 'default' values at policy execution time + +### New Settings Fields + +Added to `functions_settings.py`: + +| Setting | Default Value | Description | +|---------|---------------|-------------| +| `default_retention_conversation_personal` | `'none'` | Default conversation retention for personal workspaces | +| `default_retention_document_personal` | `'none'` | Default document retention for personal workspaces | +| `default_retention_conversation_group` | `'none'` | Default conversation retention for group workspaces | +| `default_retention_document_group` | `'none'` | Default document retention for group workspaces | +| `default_retention_conversation_public` | `'none'` | Default conversation retention for public workspaces | +| `default_retention_document_public` | `'none'` | Default document retention for public workspaces | + +**Note**: Value `'none'` means no automatic deletion. Numeric values represent days. + +### API Endpoints + +#### Get Retention Defaults + +**Endpoint**: `GET /api/retention-policy/defaults/` + +**Parameters**: +- `workspace_type`: One of `personal`, `group`, or `public` + +**Response**: +```json +{ + "success": true, + "workspace_type": "personal", + "defaults": { + "conversation_retention_days": "none", + "document_retention_days": "30" + } +} +``` + +**Authentication**: Requires user authentication (`@login_required`) + +#### Force Push Retention Defaults + +**Endpoint**: `POST /api/admin/retention-policy/force-push` + +**Request Body**: +```json +{ + "scopes": ["personal", "group", "public"] +} +``` + +**Response**: +```json +{ + "success": true, + "message": "Defaults pushed to 150 items", + "updated_count": 150, + "scopes": ["personal", "group"], + "details": { + "personal": 100, + "group": 50 + } +} +``` + +**Authentication**: Requires admin authentication (`@admin_required`) + +### File Structure + +**Backend Files**: +- `functions_settings.py` - New default retention settings fields +- `route_frontend_admin_settings.py` - Handling for saving new settings +- `route_backend_retention_policy.py` - New API endpoints +- `functions_retention_policy.py` - `resolve_retention_value()` helper function +- `functions_activity_logging.py` - `log_retention_policy_force_push()` function + +**Frontend Files**: +- `templates/admin_settings.html` - Default Retention Policies section, Force Push modal +- `templates/profile.html` - Updated retention dropdowns with org default option +- `templates/control_center.html` - Updated group and public workspace retention UI +- `static/js/workspace-manager.js` - Public workspace retention settings + +## Usage Instructions + +### Configuring Organization Defaults (Admin) + +1. Navigate to **Admin Settings** > **Content** tab +2. Scroll to the **Retention Policy** section +3. For each enabled workspace type, you'll see: + - **Default Conversation Retention Days**: How long conversations are kept + - **Default Document Retention Days**: How long documents are kept +4. Select the desired defaults from the dropdown (1 day to 10 years, or "Don't delete") +5. Click **Save All Settings** + +### Force Pushing Defaults (Admin) + +1. In the **Default Retention Policies** section, click **Force Push Defaults to All** +2. In the modal, select which workspace types to update +3. Review the warning about overriding custom policies +4. Click **Force Push** to confirm +5. The system will: + - First save any pending settings changes + - Then push defaults to all selected workspaces + - Display a summary of updated items +6. Click **Close** when complete + +### User Experience + +Users see updated retention options in their workspace settings: + +- **Using organization default (X days)** - Uses the admin-configured default +- **Don't delete** - Keep items indefinitely +- **Custom values** - 1 day to 10 years + +When "Using organization default" is selected: +- The actual default value is shown (e.g., "30 days") +- If the admin changes the default, the user's policy automatically follows +- Users can override by selecting a specific value + +## Activity Logging + +Force push actions are logged to the `activity_logs` container with: + +- **Activity Type**: `retention_policy_force_push` +- **Admin Info**: User ID and email of admin who executed +- **Scopes**: Which workspace types were affected +- **Results**: Breakdown of updates per workspace type +- **Total Updated**: Number of workspaces/users updated +- **Timestamp**: When the action occurred + +## UI/UX Details + +### Admin Settings Modal Flow + +1. **Initial State**: Shows Cancel and Force Push buttons +2. **Processing**: + - Status shows "Saving settings first..." + - Then "Pushing defaults to workspaces..." +3. **Completed**: + - Cancel and Force Push buttons hide + - Only Close button visible + - Results summary displayed + +### Conditional Visibility + +Default retention dropdowns only appear when the corresponding workspace type is enabled: +- Personal workspace defaults shown only when `enable_retention_policy_personal` is checked +- Group workspace defaults shown only when `enable_retention_policy_group` is checked +- Public workspace defaults shown only when `enable_retention_policy_public` is checked + +## Testing and Validation + +### Test Coverage + +The feature can be validated by: +1. Setting organization defaults in Admin Settings +2. Verifying the defaults appear in user-facing dropdowns +3. Testing the Force Push functionality +4. Checking activity logs for audit records +5. Verifying retention policy execution respects 'default' values + +### Performance Considerations + +- Force push iterates through all users/groups/workspaces +- Large deployments may take several seconds to complete +- Progress indicator shown during execution +- Non-blocking - admin can continue using the application + +## Known Limitations + +- Force push is an all-or-nothing operation per workspace type +- No option to selectively target specific users/groups +- Requires page refresh to see updated defaults in user UI after admin changes + +## Related Documentation + +- Retention Policy system documentation +- Admin Settings configuration guide +- Activity Logging reference diff --git a/docs/explanation/features/v0.236.011/USER_AGREEMENT.md b/docs/explanation/features/v0.236.011/USER_AGREEMENT.md new file mode 100644 index 000000000..e87589d7b --- /dev/null +++ b/docs/explanation/features/v0.236.011/USER_AGREEMENT.md @@ -0,0 +1,223 @@ +# User Agreement Feature + +## Overview + +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 + +## Key Features + +- **Global Admin Configuration**: Single configuration point in Admin Settings → Workspaces tab +- **Workspace Type Selection**: Apply agreement to personal workspaces, group workspaces, public workspaces, and/or chat +- **Markdown Support**: Agreement text supports Markdown formatting for rich content +- **Word Limit**: 200-word limit with real-time word count display +- **Daily Acceptance Option**: Optional setting to only prompt users once per day +- **Activity Logging**: All acceptances are logged for compliance tracking + +## Configuration + +### Accessing User Agreement Settings + +1. Navigate to **Admin Settings** from the sidebar +2. Select the **Workspaces** tab +3. Scroll to the **User Agreement** section + +### Configuration Options + +| Setting | Description | +|---------|-------------| +| **Enable User Agreement** | Master toggle to enable/disable the feature | +| **Apply To** | Checkboxes to select which workspace types require agreement (Personal, Group, Public, Chat) | +| **Agreement Text** | Markdown-formatted text displayed to users (max 200 words) | +| **Enable Daily Acceptance** | When enabled, users only need to accept once per day instead of every upload | + +### Example Agreement Text + +```markdown +## File Upload Agreement + +By uploading files to this workspace, you agree to the following: + +1. **Ownership**: You have the right to share this content +2. **Confidentiality**: You will not upload confidential information without authorization +3. **Compliance**: All uploads comply with organizational policies + +For questions, contact your administrator. +``` + +## User Experience + +### File Upload Flow + +1. User initiates a file upload (drag-and-drop or file picker) +2. System checks if User Agreement is enabled for that workspace type +3. If enabled and user hasn't accepted today (when daily acceptance is on): + - Modal appears with agreement text + - User can **Accept & Upload** or **Cancel** +4. Upon acceptance: + - Acceptance is logged to activity logs + - File upload proceeds normally +5. If cancelled: + - Upload is aborted + - No files are uploaded + +### Modal Interface + +The User Agreement modal displays: +- Agreement title +- Rendered Markdown content (sanitized via DOMPurify) +- Daily acceptance info (when enabled): "You only need to accept once per day" +- **Cancel** button - Dismisses modal, cancels upload +- **Accept & Upload** button - Records acceptance, proceeds with upload + +## Technical Architecture + +### Backend Components + +| File | Purpose | +|------|---------| +| `route_frontend_admin_settings.py` | Handles form submission for User Agreement settings | +| `route_backend_user_agreement.py` | API endpoints for checking/accepting agreements | +| `functions_activity_logging.py` | `log_user_agreement_accepted()` and `has_user_accepted_agreement_today()` | + +### Frontend Components + +| File | Purpose | +|------|---------| +| `admin_settings.html` | Configuration UI in Workspaces tab | +| `base.html` | User Agreement upload modal | +| `user-agreement.js` | `UserAgreementManager` module for handling upload checks | + +### API Endpoints + +#### Check Agreement Status +``` +GET /api/user_agreement/check?workspace_type={type}&workspace_id={id}&action_context=file_upload +``` + +**Response:** +```json +{ + "needsAgreement": true, + "agreementText": "## Agreement\n\nYour agreement text...", + "enableDailyAcceptance": true +} +``` + +#### Record Acceptance +``` +POST /api/user_agreement/accept +Content-Type: application/json + +{ + "workspace_type": "personal", + "workspace_id": "default", + "action_context": "file_upload" +} +``` + +**Response:** +```json +{ + "success": true, + "message": "User agreement accepted" +} +``` + +### Settings Data Model + +Settings are stored in `app_settings` with the following keys: + +```python +{ + "enable_user_agreement": False, # Master toggle + "user_agreement_text": "", # Markdown content + "user_agreement_apply_to": [], # List: ["personal", "group", "public", "chat"] + "enable_user_agreement_daily": False # Daily acceptance toggle +} +``` + +### Activity Log Entry + +When a user accepts the agreement, an activity log entry is created: + +```python +{ + "activity_type": "user_agreement_accepted", + "user_id": "user@example.com", + "workspace_type": "personal", + "workspace_id": "default", + "action_context": "file_upload", + "timestamp": "2026-01-21T10:30:00Z" +} +``` + +## Integration Points + +### Workspace Upload Handlers + +The following files integrate with `UserAgreementManager`: + +| File | Workspace Type | Integration Point | +|------|----------------|-------------------| +| `workspace-documents.js` | Personal | `handleFileUpload()` | +| `group_workspaces.html` | Group | `uploadFiles()` | +| `public_workspace.js` | Public | `handleFileUpload()` | +| `chat-input-actions.js` | Chat | `handleFileSelect()` | + +### Usage Pattern + +```javascript +// Example integration in upload handler +async function handleFileUpload(files) { + // Check user agreement before upload + UserAgreementManager.checkBeforeUpload( + 'personal', // workspace type + 'default', // workspace id + files, // files to upload + function(approvedFiles) { + // This callback runs after user accepts + proceedWithUpload(approvedFiles); + } + ); +} +``` + +## Security Considerations + +- Agreement text is sanitized using DOMPurify before rendering +- All API endpoints require user authentication +- Admin settings are protected by `@admin_required` decorator +- Activity logs provide audit trail for compliance + +## Dependencies + +- **marked.js**: Markdown parsing +- **DOMPurify**: HTML sanitization +- **Bootstrap 5**: Modal component + +## Sidebar Navigation + +The User Agreement settings are accessible via: +- **Admin Settings** → **Workspaces** submenu → **User Agreement** + +This follows the same pattern as other workspace-related admin settings like Retention Policy. + +## Testing + +To test the feature: + +1. Enable User Agreement in Admin Settings → Workspaces +2. Select at least one workspace type (e.g., Personal Workspaces) +3. Enter agreement text +4. Navigate to a personal workspace +5. Attempt to upload a file +6. Verify the agreement modal appears +7. Accept and verify the upload proceeds +8. Check Activity Logs for acceptance entry + +## Related Features + +- [Activity Logging](../v0.229.001/ACTION_LOGGING_AND_CITATION.md) - Acceptance tracking +- [Public Workspaces](../v0.229.001/PUBLIC_WORKSPACES.md) - Workspace types diff --git a/docs/explanation/features/v0.236.011/WEB_SEARCH_AZURE_AI_FOUNDRY.md b/docs/explanation/features/v0.236.011/WEB_SEARCH_AZURE_AI_FOUNDRY.md new file mode 100644 index 000000000..7107017ff --- /dev/null +++ b/docs/explanation/features/v0.236.011/WEB_SEARCH_AZURE_AI_FOUNDRY.md @@ -0,0 +1,187 @@ +# Web Search via Azure AI Foundry Agents + +## Overview + +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 + +## Key Features + +- **Azure AI Foundry Integration**: Leverages Azure AI Foundry's Grounding with Bing Search capability +- **Admin Consent Flow**: Requires explicit administrator consent before enabling due to data processing considerations +- **Activity Logging**: All consent acceptances are logged for compliance and audit purposes +- **Setup Guide Modal**: Comprehensive in-app configuration guide with step-by-step instructions +- **User Data Notice**: Admin-configurable notification banner informing users when their message will be sent to Bing +- **Graceful Error Handling**: Informs users when web search fails rather than answering from outdated training data +- **Seamless Experience**: Web search results are automatically integrated into AI responses + +## Admin Consent Requirement + +Before web search can be enabled, administrators must acknowledge important data handling considerations: + +### Consent Message + +> When you use Grounding with Bing Search, your customer data is transferred outside of the Azure compliance boundary to the Grounding with Bing Search service. Grounding with Bing Search is not subject to the same data processing terms (including location of processing) and does not have the same compliance standards and certifications as the Azure AI Agent Service, as described in the Grounding with Bing Search TOU. + +### Why Consent is Required + +1. **Data Transfer**: Customer data is transferred outside the Azure compliance boundary +2. **Different Terms**: Grounding with Bing Search has different data processing terms +3. **Compliance Considerations**: Different compliance standards and certifications apply +4. **Organizational Responsibility**: Organizations must assess whether this meets their requirements + +## Configuration + +### Enabling Web Search + +1. Navigate to **Admin Settings** from the sidebar +2. Go to the **Search** or **Agents** section +3. Locate the **Web Search** toggle +4. Read and accept the consent message +5. Enable web search + +### Settings Stored + +| Setting | Description | +|---------|-------------| +| `enable_web_search` | Master toggle for web search capability | +| `web_search_consent_accepted` | Tracks whether consent has been accepted | +| `enable_web_search_user_notice` | Toggle for showing user notification when web search is activated | +| `web_search_user_notice_text` | Customizable notification message shown to users | + +## User Data Notice + +Administrators can enable a notification banner that appears when users activate web search, informing them about data being sent to Bing. + +### Configuration + +1. Navigate to **Admin Settings** > **Search and Extract** tab +2. Locate the **User Data Notice** card in the Web Search section +3. Enable the **Show User Notice** toggle +4. Customize the notification text (optional) + +### Default Notice Text + +> Your message will be sent to Microsoft Bing for web search. Only your current message is sent, not your conversation history. + +### Behavior + +- **Appears**: When user clicks the "Web" button to activate web search +- **Dismissible**: Users can dismiss the notice via the X button +- **Session-based**: Dismissal persists for the browser session only +- **Hides automatically**: When web search is deactivated + +## Setup Guide Modal + +The admin settings include a comprehensive setup guide modal with: + +### Pricing Information + +| Metric | Value | +|--------|-------| +| **Cost** | $14 per 1,000 transactions | +| **Rate Limit** | 150 transactions/second | +| **Daily Limit** | 1,000,000 transactions/day | + +### Step-by-Step Instructions + +1. Create an Azure AI Foundry project +2. Navigate to Agents section +3. Create a new agent with Bing grounding tool +4. Configure result count to 10 +5. Add recommended agent instructions +6. Copy the agent ID to SimpleChat admin settings +7. Configure Azure AI Foundry connection settings + +### Access + +Click the **Setup Guide** button in the Web Search admin settings section to open the modal. + +## Technical Architecture + +### Backend Components + +| File | Purpose | +|------|---------| +| [route_frontend_admin_settings.py](../../../../application/single_app/route_frontend_admin_settings.py) | Handles consent flow and settings persistence | +| [route_backend_chats.py](../../../../application/single_app/route_backend_chats.py) | `perform_web_search()` with graceful error handling | +| [functions_activity_logging.py](../../../../application/single_app/functions_activity_logging.py) | `log_web_search_consent_acceptance()` for audit logging | +| [functions_settings.py](../../../../application/single_app/functions_settings.py) | Default settings including user notice configuration | + +### Frontend Components + +| File | Purpose | +|------|---------| +| [admin_settings.html](../../../../application/single_app/templates/admin_settings.html) | Admin UI for web search configuration | +| [_web_search_foundry_info.html](../../../../application/single_app/templates/_web_search_foundry_info.html) | Setup guide modal with pricing and instructions | +| [chats.html](../../../../application/single_app/templates/chats.html) | User notice container in chat interface | +| [chat-input-actions.js](../../../../application/single_app/static/js/chat-input-actions.js) | Notice show/hide logic with session dismissal | + +### Consent Flow Logic + +```python +# Simplified flow +web_search_consent_accepted = form_data.get('web_search_consent_accepted') == 'true' +requested_enable_web_search = form_data.get('enable_web_search') == 'on' +enable_web_search = requested_enable_web_search and web_search_consent_accepted + +# Log consent if newly accepted +if enable_web_search and web_search_consent_accepted and not settings.get('web_search_consent_accepted'): + log_web_search_consent_acceptance( + user_id=user_id, + admin_email=admin_email, + consent_text=web_search_consent_message, + source='admin_settings' + ) +``` + +### Activity Log Entry + +When consent is accepted, the following information is logged: +- Admin user ID +- Admin email address +- Full consent text +- Source of consent (admin_settings) +- Timestamp + +## User Experience + +### For End Users + +- Web search is transparent when enabled +- AI responses automatically incorporate relevant web search results +- Citations from web sources are displayed alongside responses +- Optional notification banner when activating web search (if enabled by admin) +- Graceful error messages when web search fails + +### For Administrators + +- Clear consent flow before enabling +- One-time consent acceptance (persisted in settings) +- Audit trail of consent acceptance +- Comprehensive setup guide with pricing information +- Configurable user notification for transparency + +## Security Considerations + +1. **Consent Tracking**: All consent acceptances are logged for compliance +2. **Admin-Only Configuration**: Only administrators can enable web search +3. **Data Awareness**: Clear communication about data handling implications +4. **Revocability**: Web search can be disabled at any time + +## Related Features + +- [Azure AI Foundry Agent Support](AZURE_AI_FOUNDRY_AGENT_SUPPORT.md) +- Agent-based chat with real-time information + +## Dependencies + +- Azure AI Foundry account with Grounding with Bing Search enabled +- Proper Azure AI Foundry configuration in SimpleChat + +## Known Limitations + +- Web search results depend on Bing Search availability +- Results may vary based on Bing's index freshness +- Subject to Bing Search Terms of Use diff --git a/docs/explanation/fixes/v0.235.022/RETENTION_POLICY_DOCUMENT_DELETION_FIX.md b/docs/explanation/fixes/v0.235.022/RETENTION_POLICY_DOCUMENT_DELETION_FIX.md new file mode 100644 index 000000000..e010a0efa --- /dev/null +++ b/docs/explanation/fixes/v0.235.022/RETENTION_POLICY_DOCUMENT_DELETION_FIX.md @@ -0,0 +1,149 @@ +# Retention Policy Document Deletion Fix + +**Version Implemented:** 0.235.022 + +## Problem Statement + +The retention policy execution was failing when attempting to delete aged documents, while conversation deletion worked correctly. The error manifested as: + +``` +[DEBUG] [INFO]: Error querying aged documents for personal (partition_value=1d6312bd-3eaa-4586-8b74-e90eee126f78): (BadRequest) One of the input values is invalid. +``` + +This prevented the automated cleanup of old documents based on user-configured retention policies. + +## Root Cause Analysis + +Investigation revealed **four distinct issues** causing the document deletion to fail: + +### Issue 1: Wrong Field Name +Documents use `last_updated` as the timestamp field, but the retention policy was querying for `last_activity_at` (which is used by conversations). + +**Document schema:** +```json +{ + "upload_date": "2025-11-20T15:17:57Z", + "last_updated": "2025-11-20T15:54:22Z" +} +``` + +**Incorrect query:** +```sql +WHERE c.last_activity_at < @cutoff_date +``` + +### Issue 2: Date Format Mismatch +Documents store timestamps in `YYYY-MM-DDTHH:MM:SSZ` format, but the query was using Python's `.isoformat()` which produces `+00:00` suffix with microseconds. + +- **Document format:** `2026-01-08T21:49:15Z` +- **Query format:** `2026-01-15T15:49:09.828460+00:00` + +Cosmos DB string comparison failed due to format differences. + +### Issue 3: Duplicate Column in SELECT +The query included both `c.{partition_field}` and `c.user_id` in the SELECT clause. When `partition_field='user_id'`, this created a duplicate column causing query errors. + +**Problematic query:** +```sql +SELECT c.id, c.file_name, c.title, c.last_updated, c.user_id, c.user_id +``` + +### Issue 4: Incorrect Activity Logging Parameter +The `log_conversation_deletion()` function was called with `deletion_reason='retention_policy'`, but this parameter doesn't exist in the function signature. It should use `additional_context` instead. + +## Solution Implementation + +### File Modified: `functions_retention_policy.py` + +#### Fix 1: Correct Field Name +Changed document queries to use `last_updated` instead of `last_activity_at`: + +```python +# Query for aged documents +# Documents use 'last_updated' field (not 'last_activity_at' like conversations) +query = f""" + SELECT c.id, c.file_name, c.title, c.last_updated, c.user_id + FROM c + WHERE c.{partition_field} = @partition_value + AND c.last_updated < @cutoff_date +""" +``` + +#### Fix 2: Correct Date Format +Changed from `.isoformat()` to `.strftime()` to match document timestamp format: + +```python +# Documents use format like '2026-01-08T21:49:15Z' so we match that format +cutoff_date = datetime.now(timezone.utc) - timedelta(days=retention_days) +cutoff_iso = cutoff_date.strftime('%Y-%m-%dT%H:%M:%SZ') +``` + +#### Fix 3: Remove Duplicate Column +Simplified SELECT to avoid duplicate columns: + +```python +SELECT c.id, c.file_name, c.title, c.last_updated, c.user_id +``` + +#### Fix 4: Correct Activity Logging Parameter +Changed from invalid parameter to proper `additional_context`: + +```python +# Before (incorrect) +log_conversation_deletion( + ... + deletion_reason='retention_policy' +) + +# After (correct) +log_conversation_deletion( + ... + additional_context={'deletion_reason': 'retention_policy'} +) +``` + +### Additional Improvements + +#### Enhanced Debug Logging +Added comprehensive debug logging to aid future troubleshooting: + +```python +debug_print(f"Processing retention for user {user_id}: conversations={conversation_retention_days} days, documents={document_retention_days} days") +debug_print(f"Querying aged documents: workspace_type={workspace_type}, partition_field={partition_field}, partition_value={partition_value}, cutoff_date={cutoff_iso}, retention_days={retention_days}") +debug_print(f"Found {len(aged_documents)} aged documents for {workspace_type} workspace") +``` + +## Testing & Validation + +After the fix, retention policy execution completed successfully: + +``` +[DEBUG] [INFO]: Querying aged documents: workspace_type=personal, partition_field=user_id, partition_value=1d6312bd-3eaa-4586-8b74-e90eee126f78, cutoff_date=2026-01-15T15:58:09Z, retention_days=1 +[DEBUG] [INFO]: Found 1 aged documents for personal workspace +[DEBUG] [INFO]: [DELETE DOCUMENT] Starting deletion for document: 36a030b2-57b2-426b-8aa9-6f49eed5f8a6 +[DEBUG] [INFO]: Logged document deletion transaction: 36a030b2-57b2-426b-8aa9-6f49eed5f8a6 +Successfully deleted blob at 1d6312bd-3eaa-4586-8b74-e90eee126f78/test.pdf +[DEBUG] [INFO]: Deleted document 36a030b2-57b2-426b-8aa9-6f49eed5f8a6 (test.pdf) due to retention policy +[DEBUG] [INFO]: Notification created: 5a92235d-d408-4449-9c72-0951ed198688 [personal] [system_announcement] +[DEBUG] [INFO]: Retention policy execution completed: {'success': True, ... 'documents': 1, 'users_affected': 1 ...} +``` + +## Files Changed + +| File | Changes | +|------|---------| +| `functions_retention_policy.py` | Fixed field name, date format, duplicate columns, activity logging | +| `config.py` | Version bump to 0.235.022 | + +## Impact + +- **Retention Policy:** Now correctly deletes aged documents based on user settings +- **Activity Logging:** Document deletions are properly logged with deletion reason +- **User Notifications:** Users receive notifications when documents are deleted by retention policy +- **Blob Storage:** Associated blob files are correctly removed + +## Related Components + +- Conversation retention (uses `last_activity_at` - unchanged) +- Group workspace retention (shares same document deletion logic) +- Public workspace retention (shares same document deletion logic) diff --git a/docs/explanation/fixes/v0.236.011/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md b/docs/explanation/fixes/v0.236.011/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md new file mode 100644 index 000000000..e7f385828 --- /dev/null +++ b/docs/explanation/fixes/v0.236.011/AGENT_PAYLOAD_FIELD_LENGTHS_FIX.md @@ -0,0 +1,27 @@ +# Agent Payload Field Lengths Fix (Version 0.237.009) + +## Header Information +- **Fix Title:** Agent payload field length validation +- **Issue Description:** Agent payload validation did not enforce length limits, allowing oversized values into storage. +- **Root Cause Analysis:** Length checks existed but were never invoked in `sanitize_agent_payload`, and no limits covered Azure-specific fields. +- **Fixed/Implemented in version:** **0.237.009** +- **Config Version Updated:** `config.py` VERSION set to **0.237.009** + +## Technical Details +- **Files Modified:** + - application/single_app/functions_agent_payload.py + - application/single_app/config.py +- **Code Changes Summary:** + - Added max length recommendations for Azure OpenAI and APIM fields. + - Validated field lengths in `sanitize_agent_payload` and for Foundry settings. + - Bumped application version in config.py. +- **Testing Approach:** + - Added a functional test to confirm validation wiring and limits are present. + +## Validation +- **Test Results:** functional_tests/test_agent_payload_field_lengths.py +- **Before/After Comparison:** + - Before: Oversized agent fields could pass validation. + - After: Oversized fields raise `AgentPayloadError` with a clear message. +- **User Experience Improvements:** + - Prevents invalid payloads and provides consistent validation feedback. diff --git a/docs/explanation/fixes/v0.236.011/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md b/docs/explanation/fixes/v0.236.011/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md new file mode 100644 index 000000000..71e1f0de9 --- /dev/null +++ b/docs/explanation/fixes/v0.236.011/AGENT_TEMPLATE_MAX_LENGTHS_FIX.md @@ -0,0 +1,27 @@ +# Agent Template Max Lengths Fix (Version 0.237.010) + +## Header Information +- **Fix Title:** Agent template max length validation +- **Issue Description:** Agent template updates did not enforce length limits, allowing oversized fields into storage. +- **Root Cause Analysis:** Length checks were missing from the update path in `update_agent_template`. +- **Fixed/Implemented in version:** **0.237.010** +- **Config Version Updated:** `config.py` VERSION set to **0.237.010** + +## Technical Details +- **Files Modified:** + - application/single_app/functions_agent_templates.py + - application/single_app/config.py +- **Code Changes Summary:** + - Added max length constants for template fields and list items. + - Validated lengths during template updates. + - Bumped application version in config.py. +- **Testing Approach:** + - Added a functional test to validate length validation wiring. + +## Validation +- **Test Results:** functional_tests/test_agent_template_length_validation.py +- **Before/After Comparison:** + - Before: Oversized template fields could be saved. + - After: Oversized fields raise a validation error before persistence. +- **User Experience Improvements:** + - Consistent template validation and clearer error feedback. diff --git a/docs/explanation/fixes/v0.236.011/CONTROL_CENTER_DATE_LABELS_FIX.md b/docs/explanation/fixes/v0.236.011/CONTROL_CENTER_DATE_LABELS_FIX.md new file mode 100644 index 000000000..a7d1bf34f --- /dev/null +++ b/docs/explanation/fixes/v0.236.011/CONTROL_CENTER_DATE_LABELS_FIX.md @@ -0,0 +1,27 @@ +# Control Center Date Labels Fix (Version 0.235.074) + +## Header Information +- **Fix Title:** Control Center Date Labels Fix +- **Issue Description:** Control Center charts displayed dates one day behind due to UTC parsing of date keys. +- **Root Cause Analysis:** The frontend parsed YYYY-MM-DD strings with `new Date(...)`, which treats the value as UTC and shifts the day in local timezones. +- **Fixed/Implemented in version:** **0.235.074** +- **Config Version Updated:** `config.py` VERSION set to **0.235.074** + +## Technical Details +- **Files Modified:** + - application/single_app/static/js/control-center.js + - application/single_app/config.py +- **Code Changes Summary:** + - Added a local date parsing helper for YYYY-MM-DD keys. + - Updated chart label and tooltip rendering to use local date parsing. + - Bumped application version in config.py. +- **Testing Approach:** + - Added a functional test to validate the date parsing helper is present in the chart logic. + +## Validation +- **Test Results:** functional_tests/test_control_center_date_labels_fix.py +- **Before/After Comparison:** + - Before: Date labels in charts appeared one day behind in local timezones. + - After: Date labels match the correct local date (e.g., Jan 21 for today). +- **User Experience Improvements:** + - Accurate daily labels across all activity charts. diff --git a/docs/explanation/fixes/v0.236.011/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md b/docs/explanation/fixes/v0.236.011/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md new file mode 100644 index 000000000..f76993bab --- /dev/null +++ b/docs/explanation/fixes/v0.236.011/SOVEREIGN_CLOUD_COGNITIVE_SERVICES_SCOPE_FIX.md @@ -0,0 +1,94 @@ +# Sovereign Cloud Cognitive Services Scope Fix + +## Overview + +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 + +**Related Issue:** [#616](https://github.com/microsoft/simplechat/issues/616#issue-3835164022) + +## Problem + +The `chat_stream_api` and `smart_http_plugin` contained hardcoded references to commercial Azure cognitive services scope URLs. This caused authentication failures when running SimpleChat in: +- Azure Government (MAG) environments +- Custom/sovereign cloud deployments + +### Error Symptoms + +Users in MAG environments encountered authentication errors when: +- Using chat with streaming enabled +- Making Smart HTTP Plugin calls + +The error occurred because the code attempted to authenticate against commercial Azure endpoints instead of the appropriate government or custom cloud endpoints. + +## Root Cause + +The authentication scope was hardcoded as the commercial cognitive services URL rather than using the configurable value from `config.py`. This meant: +- Commercial: `https://cognitiveservices.azure.com/.default` +- Government: Should be `https://cognitiveservices.azure.us/.default` +- Custom: Should use environment-specific scope + +## Solution + +Replaced all hardcoded cognitive services scope references with the configurable variable from `config.py`: +- `AZURE_OPENAI_TOKEN_SCOPE` environment variable +- Dynamically resolved based on cloud environment + +### Files Modified + +1. **chat_stream_api** (streaming chat implementation) + - Replaced hardcoded scope with `config.AZURE_OPENAI_TOKEN_SCOPE` + +2. **smart_http_plugin** (Smart HTTP Plugin) + - Replaced hardcoded scope with configurable variable + +## Cloud Environment Support + +| Cloud Environment | Cognitive Services Scope | +|-------------------|-------------------------| +| Commercial | `https://cognitiveservices.azure.com/.default` | +| Government (MAG) | `https://cognitiveservices.azure.us/.default` | +| China | `https://cognitiveservices.azure.cn/.default` | +| Custom | Configurable via environment variable | + +## Testing + +### Azure Government Validation + +1. Deploy SimpleChat to Azure Government environment +2. Configure appropriate Azure OpenAI resources +3. Enable streaming in chat settings +4. Send a chat message with streaming enabled +5. Verify response streams correctly without authentication errors + +### Commercial Cloud Validation + +1. Verify existing commercial deployments continue to function +2. Test streaming chat functionality +3. Test Smart HTTP Plugin calls + +## Impact + +- **Azure Government**: Full streaming and plugin functionality now works correctly +- **Custom Clouds**: Deployments can configure appropriate scope for their environment +- **Commercial**: No change to existing behavior + +## Configuration + +The cognitive services scope is configured via: + +```python +# config.py +AZURE_OPENAI_TOKEN_SCOPE = os.getenv('AZURE_OPENAI_TOKEN_SCOPE', 'https://cognitiveservices.azure.com/.default') +``` + +For Azure Government, set: +``` +AZURE_OPENAI_TOKEN_SCOPE=https://cognitiveservices.azure.us/.default +``` + +## Related + +- Sovereign Cloud Managed Identity Authentication Fix (v0.229.001) +- Azure Government Support documentation diff --git a/docs/explanation/fixes/v0.236.011/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md b/docs/explanation/fixes/v0.236.011/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md new file mode 100644 index 000000000..f93a7871c --- /dev/null +++ b/docs/explanation/fixes/v0.236.011/USER_SEARCH_TOAST_INLINE_MESSAGES_FIX.md @@ -0,0 +1,68 @@ +# User Search Toast and Inline Messages Fix + +## Overview + +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 + +**Related PR:** [#608](https://github.com/microsoft/simplechat/pull/608#discussion_r2701900020) + +## Problem + +The user search functionality in group management used browser `alert()` pop-ups for all feedback messages (empty search, no users found, errors). This created a disruptive user experience and was inconsistent with the toast notification patterns used elsewhere in the application. + +## Solution + +Refactored the `searchUsers()` function to display feedback using: +- **Inline messages**: Primary feedback shown directly in the search results area +- **Toast notifications**: Used only for errors, in addition to inline messaging + +## User Experience + +### Empty Search Query +When users click search without entering a query: +- Inline message displayed in the search results area +- No disruptive alert pop-up + +### No Users Found +When the search returns no results: +- Informative inline message in the results area +- Clear indication that no matching users exist + +### Users Found +When one or more users are found: +- Results displayed in the search results area +- Success feedback integrated naturally into the flow + +### Error Handling +When an error occurs: +- Inline error message displayed +- Toast notification also shown for visibility +- Consistent with application error handling patterns + +## Technical Details + +### Files Modified +- Group management JavaScript (search user functionality) + +### Changes +- Replaced `alert()` calls with inline message rendering +- Added toast notification for error cases only +- Maintained consistent styling with existing UI patterns + +## Benefits + +1. **Non-disruptive UX**: Users can continue working without dismissing pop-ups +2. **Contextual feedback**: Messages appear where users are looking (in the search area) +3. **Consistency**: Aligns with toast notification patterns used elsewhere +4. **Accessibility**: Better screen reader support with inline messages +5. **Modern UI**: Follows contemporary web application design patterns + +## Testing + +1. Open group management → Add Members +2. Click Search without entering a query → Verify inline "empty search" message +3. Search for a non-existent user → Verify inline "no users found" message +4. Search for an existing user → Verify results display correctly +5. Simulate network error → Verify both inline message and toast appear diff --git a/docs/explanation/fixes/v0.236.011/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md b/docs/explanation/fixes/v0.236.011/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md new file mode 100644 index 000000000..233324c97 --- /dev/null +++ b/docs/explanation/fixes/v0.236.011/WEB_SEARCH_FAILURE_GRACEFUL_HANDLING_FIX.md @@ -0,0 +1,184 @@ +# Web Search Failure Graceful Handling Fix + +## Overview + +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 + +## Problem + +When using the Azure AI Foundry web search agent (Bing grounding), if the web search operation failed for any reason (network issues, configuration errors, API failures), the conversation would continue without web search results. The AI model would then answer the user's question based on its training data, which could be outdated or incorrect. + +### Example Scenario + +**User asks:** "Who is the current President of the United States?" + +**Before fix (incorrect behavior):** +- Web search fails silently due to agent configuration issue +- Model answers from training data: "Joe Biden is the current President" +- User receives confident but potentially outdated/incorrect information +- No indication that web search failed + +**After fix (correct behavior):** +- Web search fails +- System message injected instructing model to inform user of failure +- Model responds: "I'm sorry, but the web search encountered an error and I couldn't retrieve current information. Please try again later." +- User is aware the information may be unavailable + +### Error Symptoms + +Users would receive: +- Answers based on outdated training data cutoff dates +- Incorrect information for time-sensitive queries +- No indication that web search was attempted but failed +- Confidently stated incorrect facts + +## Root Cause + +The `perform_web_search` function in `route_backend_chats.py` did not communicate failure status back to the calling code. When exceptions occurred during web search: +1. Errors were logged but not acted upon +2. The function returned `None` in all cases (success and failure) +3. No mechanism existed to inform the model about search failures +4. The conversation proceeded as if web search was not configured + +## Solution + +Implemented a comprehensive failure handling mechanism: + +### 1. Return Value Indication + +Modified `perform_web_search` to return a boolean status: +- `True` - Web search succeeded or was intentionally skipped (disabled, empty query) +- `False` - Web search failed due to an error + +### 2. System Message Injection on Failure + +When web search fails, a system message is added to the conversation context instructing the model to: +- Acknowledge the search failure to the user +- Not attempt to answer using training data +- Suggest the user try again later + +### 3. Error-Specific Messages + +Different failure scenarios receive appropriate messages: + +| Failure Type | System Message | +|--------------|----------------| +| Agent ID Not Configured | "Web search agent is not configured. Please inform the user that web search is currently unavailable." | +| Foundry Invocation Error | "Web search failed: [error details]. Please inform the user that the web search encountered an error and you cannot provide real-time information for this query." | +| Unexpected Exception | "Web search failed with an unexpected error: [error]. Please inform the user that the web search encountered an error and suggest they try again later." | + +### Files Modified + +**route_backend_chats.py** +- Modified `perform_web_search` function to return boolean status +- Added system message injection on all failure paths +- Updated exception handlers to set appropriate failure messages + +## Code Changes + +### Return Value Pattern + +```python +def perform_web_search(conversation_id, source, query, web_search_results_container): + """ + Now returns: + - True: Web search succeeded or was intentionally skipped + - False: Web search failed due to an error + """ + + # Success path + return True + + # Failure path - inject system message and return False + web_search_results_container.append({ + 'role': 'system', + 'content': 'Web search failed: [error]. Please inform the user...' + }) + return False +``` + +### System Message Structure + +When failure occurs, a message is appended to the conversation: +```python +{ + 'role': 'system', + 'content': 'Web search failed with an unexpected error: [error details]. ' + 'Please inform the user that the web search encountered an error ' + 'and you cannot provide real-time information for this query. ' + 'Suggest they try again later.' +} +``` + +## Testing + +### Failure Scenario Validation + +1. **Missing Agent Configuration** + - Remove web search agent ID from settings + - Send a query to web search-enabled agent + - Verify user receives message about unavailable web search + +2. **Network/API Failure** + - Simulate network connectivity issue + - Send a query to web search agent + - Verify user receives error message instead of outdated answer + +3. **Success Scenario (Regression)** + - Configure valid web search agent + - Send a query requesting current information + - Verify web search results are returned with citations + +### Test Commands + +```python +# Test query for web search +"Who is the current President of the United States?" +"What is the current weather in Seattle?" +"What are today's top news headlines?" +``` + +## Impact + +- **User Experience**: Users are now informed when web search fails instead of receiving potentially incorrect information +- **Transparency**: Clear indication when real-time information cannot be retrieved +- **Trust**: Users can make informed decisions about the reliability of responses +- **Error Visibility**: Administrators can identify web search configuration issues through user reports + +## Configuration + +Web search requires proper Azure AI Foundry configuration: + +```python +# Required settings +FOUNDRY_WEB_SEARCH_AGENT_ID = "asst_xxxxxxxxxxxxx" # Foundry agent with Bing grounding +AZURE_AI_PROJECT_CONNECTION_STRING = "..." # Project connection string +``` + +## Debug Logging + +Enhanced debug logging was also added to `perform_web_search` to aid troubleshooting: + +```python +debug_print(f"🌐 Starting web search for conversation: {conversation_id}") +debug_print(f"šŸ“Š Web search query: '{query}'") +debug_print(f"āœ… Web search completed successfully with {len(citations)} citations") +debug_print(f"āŒ Web search failed: {error_details}") +``` + +Enable debug logging by setting: +```python +DEBUG_LOG_ENABLED = True +``` + +## Related + +- [Azure AI Foundry Agent Support](../features/v0.236.011/AZURE_AI_FOUNDRY_AGENT_SUPPORT.md) +- Bing Grounding Tool Configuration +- Error Handling Best Practices + +## Migration Notes + +This is a behavioral change that improves user experience. No configuration changes are required. Existing web search functionality will continue to work, with improved failure handling when errors occur. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 02d7139a8..2d1e0e942 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -1,6 +1,100 @@ # Feature Release +### **(v0.236.011)** + +#### New Features + +* **Retention Policy Defaults** + * Admin-configurable organization-wide default retention policies for conversations and documents across all workspace types. + * **Organization Defaults**: Set default retention periods (1 day to 10 years, or "Don't delete") separately for personal, group, and public workspaces. + * **User Choice**: Users see "Using organization default (X days)" option and can override with custom settings or revert to org default. + * **Conditional Display**: Default retention settings only appear in Admin Settings when the corresponding workspace type is enabled. + * **Force Push Feature**: Administrators can push organization defaults to all workspaces, overriding any custom retention policies users have set. + * **Settings Auto-Save**: Force push automatically saves pending settings changes before executing to ensure current values are pushed. + * **Activity Logging**: Force push actions are logged to `activity_logs` container for audit purposes with admin info, affected scopes, and results summary. + * **API Endpoints**: New `/api/retention-policy/defaults/` (GET) and `/api/admin/retention-policy/force-push` (POST) endpoints. + * **Files Modified**: `functions_settings.py`, `admin_settings.html`, `route_frontend_admin_settings.py`, `route_backend_retention_policy.py`, `functions_retention_policy.py`, `functions_activity_logging.py`, `profile.html`, `control_center.html`, `workspace-manager.js`. + * (Ref: Default retention settings, Force Push modal, activity logging, retention policy execution) + +* **Private Networking Support** + * Comprehensive private networking support for SimpleChat deployments via Azure Developer CLI (AZD) and Bicep infrastructure-as-code. + * **Network Isolation**: Private endpoints for all Azure PaaS services (Cosmos DB, Azure OpenAI, AI Search, Storage, Key Vault, Document Intelligence). + * **VNet Integration**: Full virtual network integration for App Service and dependent resources with automated Private DNS zone configuration. + * **AZD Integration**: Seamless deployment via `azd up` with `ENABLE_PRIVATE_NETWORKING=true` environment variable. + * **Post-Deployment Security**: New `postup` hook automatically disables public network access when private networking is enabled. + * **Enhanced Deployment Hooks**: Refactored all deployment hooks in `azure.yaml` with stepwise logging, explicit error handling, and clearer output for troubleshooting. + * **Documentation Updates**: Expanded Bicep README with prerequisites, Azure Government (USGov) considerations, and post-deployment validation steps. + * (Ref: `deployers/azure.yaml`, `deployers/bicep/`, private endpoint configuration, VNet integration) + +* **User Agreement for File Uploads** + * Global admin-configurable agreement that users must accept before uploading files to workspaces. + * **Configuration Options**: Enable/disable toggle, workspace type selection (Personal, Group, Public, Chat), Markdown-formatted agreement text (200-word limit), optional daily acceptance mode. + * **User Experience**: Modal prompt before file uploads with agreement text, "Accept & Upload" or "Cancel" options, daily acceptance tracking to reduce repeat prompts. + * **Activity Logging**: All acceptances logged to activity logs for compliance tracking with timestamp, user, workspace type, and action context. + * **Admin Access**: Settings accessible via Admin Settings → Workspaces tab → User Agreement section, with sidebar navigation link. + * **Files Added**: `user-agreement.js` (frontend module), `route_backend_user_agreement.py` (API endpoints). + * **Files Modified**: `admin_settings.html`, `route_frontend_admin_settings.py`, `base.html`, `_sidebar_nav.html`, `functions_activity_logging.py`, `workspace-documents.js`, `group_workspaces.html`, `public_workspace.js`, `chat-input-actions.js`. + * (Ref: User Agreement modal, file upload workflows, activity logging, admin configuration) + +* **Web Search via Azure AI Foundry Agents** + * Web search capability through Azure AI Foundry agents using Grounding with Bing Search service. + * **Pricing**: $14 per 1,000 transactions (150 transactions/second, 1M transactions/day limit). + * **Admin Consent Flow**: Requires explicit administrator consent before enabling due to data processing considerations outside Azure compliance boundary. + * **Consent Logging**: All consent acceptances are logged to activity logs for compliance and audit purposes. + * **Setup Guide Modal**: Comprehensive in-app configuration guide with step-by-step instructions for creating the agent, configuring Bing grounding, setting result count to 10, and recommended agent instructions. + * **User Data Notice**: Admin-configurable notification banner that appears when users activate web search, informing them that their message will be sent to Microsoft Bing. Customizable notice text, dismissible per session. + * **Graceful Error Handling**: When web search fails, the system informs users rather than answering from outdated training data. + * **Seamless Integration**: Web search results automatically integrated into AI responses when enabled. + * **Settings**: `enable_web_search` toggle, `web_search_consent_accepted` tracking, `enable_web_search_user_notice` toggle, and `web_search_user_notice_text` customization in admin settings. + * **Files Added**: `_web_search_foundry_info.html` (setup guide modal). + * **Files Modified**: `route_frontend_admin_settings.py`, `route_backend_chats.py`, `functions_activity_logging.py`, `admin_settings.html`, `chats.html`, `chat-input-actions.js`, `functions_settings.py`. + * (Ref: Grounding with Bing Search, Azure AI Foundry, consent workflow, activity logging, pricing, user transparency) + +* **Conversation Deep Linking** + * Direct URL links to specific conversations via query parameters for sharing and bookmarking. + * **URL Parameters**: Supports both `conversationId` and `conversation_id` query parameters. + * **Automatic URL Updates**: Current conversation ID automatically added to URL when selecting conversations. + * **Browser Integration**: Uses `history.replaceState()` for seamless URL updates without new history entries. + * **Error Handling**: Graceful handling of invalid or inaccessible conversation IDs with toast notifications. + * **Files Modified**: `chat-onload.js`, `chat-conversations.js`. + * (Ref: deep linking, URL parameters, conversation navigation, shareability) + +* **Plugin Authentication Type Constraints** + * Per-plugin-type authentication method restrictions for better security and API compatibility. + * **Schema-Based Defaults**: Falls back to global `AuthType` enum from `plugin.schema.json`. + * **Definition File Overrides**: Plugin-specific `.definition.json` files can restrict available auth types. + * **API Endpoint**: New `/api/plugins//auth-types` endpoint returns allowed auth types and source. + * **Frontend Integration**: UI can query allowed auth types to display only valid options. + * **Files Modified**: `route_backend_plugins.py`. + * (Ref: plugin authentication, auth type constraints, OpenAPI plugins, security) + +#### Bug Fixes + +* **Control Center Chart Date Labels Fix** + * Fixed activity trends chart date labels to parse dates in local time instead of UTC. + * **Root Cause**: JavaScript `new Date()` was parsing date strings as UTC, causing labels to display previous day in western timezones. + * **Solution**: Parse date components explicitly and construct Date objects in local timezone. + * **Impact**: Chart x-axis labels now correctly show the intended dates regardless of user timezone. + * **Files Modified**: `control_center.html` (Chart.js date parsing logic). + * (Ref: Chart.js, date parsing, timezone handling, activity trends) + +* **Sovereign Cloud Cognitive Services Scope Fix** + * Fixed hardcoded commercial Azure cognitive services scope references that prevented authentication in Azure Government (MAG) and custom cloud environments. + * **Root Cause**: `chat_stream_api` and `smart_http_plugin` used hardcoded commercial cognitive services scope URL instead of configurable value from `config.py`. + * **Solution**: Replaced hardcoded scope with `AZURE_OPENAI_TOKEN_SCOPE` environment variable, dynamically resolved based on cloud environment. + * **Impact**: Streaming chat and Smart HTTP Plugin now work correctly in Azure Government, China, and custom cloud deployments. + * **Related Issue**: [#616](https://github.com/microsoft/simplechat/issues/616) + * (Ref: `chat_stream_api`, `smart_http_plugin`, sovereign cloud authentication, MAG support) + +* **User Search Toast and Inline Messages Fix** + * Updated `searchUsers()` function to use inline and toast messages instead of browser alert pop-ups. + * **Improvement**: Search feedback (empty search, no users found, errors) now displays as inline messages in the search results area. + * **Error Handling**: Errors display both inline message and toast notification for visibility. + * **Benefits**: Non-disruptive UX, contextual feedback, consistency with application patterns. + * **Related PR**: [#608](https://github.com/microsoft/simplechat/pull/608#discussion_r2701900020) + * (Ref: group management, user search, toast notifications, UX improvement) + ### **(v0.235.025)** #### Bug Fixes @@ -65,6 +159,14 @@ * **Dashboard**: Real-time statistics, key alerts, activity insights. * (Ref: `route_frontend_control_center.py`, `route_backend_control_center.py`, `control_center.html`) +* **Control Center Application Roles** + * Added two new application roles for finer-grained Control Center access control. + * **Control Center Admin**: Full administrative access to Control Center functionality including user management, administrative operations, and workflow approvals. + * **Control Center Dashboard Reader**: Read-only access to Control Center dashboards and metrics for monitoring and auditing purposes. + * **Use Cases**: IT operations monitoring, delegated administration, compliance auditing with appropriate access levels. + * **Files Modified**: `appRegistrationRoles.json` (new role definitions). + * (Ref: Entra ID app roles, role-based access control, Control Center permissions) + * **Message Threading System** * Linked-list threading system establishing proper message relationships throughout conversations. * **Thread Fields**: `thread_id` (unique identifier), `previous_thread_id` (links to previous message), `active_thread` (thread active status), `thread_attempt` (retry tracking). diff --git a/functional_tests/test_backend_foundry_agent_payload.py b/functional_tests/test_backend_foundry_agent_payload.py new file mode 100644 index 000000000..72c3daa29 --- /dev/null +++ b/functional_tests/test_backend_foundry_agent_payload.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Functional test for Azure AI Foundry agent payload sanitation. +Version: 0.233.176 +Implemented in: 0.233.176 + +This test ensures that sanitize_agent_payload enforces Foundry-specific backend +constraints (actions_to_load cleared, APIM disabled, agent_id required) and +prevents invalid Foundry payloads from being persisted. +""" + +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + +from functions_agent_payload import sanitize_agent_payload, AgentPayloadError + + +def test_foundry_agent_actions_and_apim_rules(): + """Azure AI Foundry agents drop plugins and APIM metadata.""" + print("šŸ” Testing Foundry agent sanitization rules...") + + payload = { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "foundry_agent", + "display_name": "Foundry Agent", + "description": "Test agent", + "instructions": "Be helpful", + "agent_type": "aifoundry", + "actions_to_load": ["pluginA", "pluginB"], + "enable_agent_gpt_apim": True, + "azure_agent_apim_gpt_endpoint": "https://example", + "azure_agent_apim_gpt_subscription_key": "secret", + "azure_agent_apim_gpt_deployment": "deployment", + "azure_agent_apim_gpt_api_version": "2024-06-01", + "azure_openai_gpt_endpoint": "https://aoai.cognitiveservices.azure.com", + "azure_openai_gpt_deployment": "project", + "azure_openai_gpt_api_version": "2024-05-01-preview", + "other_settings": { + "azure_ai_foundry": {"agent_id": " agent-123 "} + }, + "max_completion_tokens": 16384 + } + + cleaned = sanitize_agent_payload(payload) + + assert cleaned['agent_type'] == 'aifoundry' + assert cleaned['actions_to_load'] == [] + assert cleaned['enable_agent_gpt_apim'] is False + assert 'azure_agent_apim_gpt_endpoint' not in cleaned + assert 'azure_agent_apim_gpt_subscription_key' not in cleaned + assert cleaned['other_settings']['azure_ai_foundry']['agent_id'] == 'agent-123' + + print("āœ… Foundry agents automatically drop plugins and APIM secrets.") + + +def test_foundry_agent_requires_agent_id(): + """Missing azure_ai_foundry.agent_id should raise AgentPayloadError.""" + print("šŸ” Validating Foundry agent_id requirement...") + + payload = { + "id": "123e4567-e89b-12d3-a456-426614174000", + "name": "foundry_agent", + "display_name": "Foundry Agent", + "description": "Test agent", + "instructions": "Be helpful", + "agent_type": "aifoundry", + "actions_to_load": [], + "azure_openai_gpt_endpoint": "https://aoai.cognitiveservices.azure.com", + "azure_openai_gpt_deployment": "project", + "azure_openai_gpt_api_version": "2024-05-01-preview", + "other_settings": {"azure_ai_foundry": {}}, + "max_completion_tokens": 4096 + } + + try: + sanitize_agent_payload(payload) + except AgentPayloadError as exc: + assert 'agent_id' in str(exc) + print("āœ… Missing agent_id correctly rejected.") + return + + raise AssertionError("Expected AgentPayloadError for missing agent_id") + +if __name__ == "__main__": + tests = [ + test_foundry_agent_actions_and_apim_rules, + test_foundry_agent_requires_agent_id + ] + results = [] +@@ + success = all(results) + print(f"\nšŸ“Š Results: {sum(results)}/{len(tests)} tests passed") + sys.exit(0 if success else 1) diff --git a/functional_tests/test_control_center_date_labels_fix.py b/functional_tests/test_control_center_date_labels_fix.py new file mode 100644 index 000000000..28adbacfc --- /dev/null +++ b/functional_tests/test_control_center_date_labels_fix.py @@ -0,0 +1,62 @@ +# test_control_center_date_labels_fix.py +#!/usr/bin/env python3 +""" +Functional test for control center date labels fix. +Version: 0.235.074 +Implemented in: 0.235.074 + +This test ensures control center charts parse YYYY-MM-DD dates in local time +so label text matches the correct day. +""" + +import os +import sys + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + + +def test_control_center_date_label_parsing(): + """Validate local date parsing helper usage in control-center.js charts.""" + print("\nšŸ” Testing control center date label parsing...") + + try: + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + target_path = os.path.join( + repo_root, + "application", + "single_app", + "static", + "js", + "control-center.js", + ) + + if not os.path.exists(target_path): + raise FileNotFoundError(f"Expected file not found: {target_path}") + + with open(target_path, "r", encoding="utf-8") as handle: + content = handle.read() + + required_snippets = [ + "function parseDateKey", + "parseDateKey(dateStr)", + "parseDateKey(date)", + ] + + missing = [snippet for snippet in required_snippets if snippet not in content] + if missing: + raise AssertionError(f"Missing date parsing helpers: {missing}") + + print("āœ… Control center date label parsing helper detected.") + return True + + except Exception as exc: + print(f"āŒ Test failed: {exc}") + import traceback + + traceback.print_exc() + return False + + +if __name__ == "__main__": + success = test_control_center_date_label_parsing() + sys.exit(0 if success else 1) diff --git a/functional_tests/test_web_search_failure_handling.py b/functional_tests/test_web_search_failure_handling.py new file mode 100644 index 000000000..83afcc138 --- /dev/null +++ b/functional_tests/test_web_search_failure_handling.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +Functional test for Web Search Failure Graceful Handling. +Version: 0.236.014 +Implemented in: 0.236.014 + +This test ensures that when web search fails, the system properly injects +a system message instructing the model to inform the user about the failure +instead of answering from training data. +""" + +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + + +def test_perform_web_search_returns_boolean(): + """ + Test that perform_web_search function returns boolean values. + """ + print("šŸ” Testing perform_web_search return value type...") + + try: + from route_backend_chats import perform_web_search + import inspect + + # Get the function signature and source + source = inspect.getsource(perform_web_search) + + # Check that the function has return True statements + has_return_true = 'return True' in source + has_return_false = 'return False' in source + + if has_return_true and has_return_false: + print("āœ… perform_web_search has both 'return True' and 'return False' statements") + return True + else: + print(f"āŒ Missing return statements:") + print(f" - Has 'return True': {has_return_true}") + print(f" - Has 'return False': {has_return_false}") + return False + + except ImportError as e: + print(f"āš ļø Could not import perform_web_search: {e}") + print(" This may be expected if running outside the application context") + return True # Not a failure of the feature itself + except Exception as e: + print(f"āŒ Test failed: {e}") + import traceback + traceback.print_exc() + return False + + +def test_failure_message_injection_patterns(): + """ + Test that the code contains proper failure message injection patterns. + """ + print("\nšŸ” Testing failure message injection patterns...") + + try: + # Read the route_backend_chats.py file + file_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', 'application', 'single_app', 'route_backend_chats.py' + ) + + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Check for key patterns that indicate proper failure handling + patterns = { + 'system_role_message': "'role': 'system'" in content or '"role": "system"' in content, + 'failure_message': 'web search failed' in content.lower() or 'Web search failed' in content, + 'inform_user': 'inform the user' in content.lower(), + 'exception_handling': 'FoundryAgentInvocationError' in content, + 'return_false_on_error': 'return False' in content, + } + + all_passed = True + for pattern_name, found in patterns.items(): + status = "āœ…" if found else "āŒ" + print(f" {status} {pattern_name}: {'Found' if found else 'Not found'}") + if not found: + all_passed = False + + if all_passed: + print("āœ… All failure message injection patterns found") + else: + print("āŒ Some patterns missing") + + return all_passed + + except Exception as e: + print(f"āŒ Test failed: {e}") + import traceback + traceback.print_exc() + return False + + +def test_error_scenarios_have_return_false(): + """ + Test that error/exception blocks return False. + """ + print("\nšŸ” Testing that error scenarios return False...") + + try: + file_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', 'application', 'single_app', 'route_backend_chats.py' + ) + + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Find the perform_web_search function + func_start = content.find('def perform_web_search(') + if func_start == -1: + print("āŒ Could not find perform_web_search function") + return False + + # Find the end of the function (next def at same indentation level) + func_end = content.find('\ndef ', func_start + 1) + if func_end == -1: + func_end = len(content) + + func_content = content[func_start:func_end] + + # Check for exception handling with return False + checks = { + 'has_except_blocks': 'except' in func_content, + 'has_return_false': 'return False' in func_content, + 'has_foundry_error_handling': 'FoundryAgentInvocationError' in func_content, + 'has_generic_exception': 'except Exception' in func_content, + } + + all_passed = True + for check_name, passed in checks.items(): + status = "āœ…" if passed else "āŒ" + print(f" {status} {check_name}") + if not passed: + all_passed = False + + # Count return statements + return_true_count = func_content.count('return True') + return_false_count = func_content.count('return False') + + print(f"\n šŸ“Š Return statement counts:") + print(f" - 'return True': {return_true_count}") + print(f" - 'return False': {return_false_count}") + + if return_false_count >= 2: + print("āœ… Function has adequate failure return paths") + else: + print("āš ļø Function may need more failure return paths") + all_passed = False + + return all_passed + + except Exception as e: + print(f"āŒ Test failed: {e}") + import traceback + traceback.print_exc() + return False + + +def test_web_search_results_container_usage(): + """ + Test that web_search_results_container is used to inject system messages. + """ + print("\nšŸ” Testing web_search_results_container for system message injection...") + + try: + file_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', 'application', 'single_app', 'route_backend_chats.py' + ) + + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Find the perform_web_search function + func_start = content.find('def perform_web_search(') + if func_start == -1: + print("āŒ Could not find perform_web_search function") + return False + + func_end = content.find('\ndef ', func_start + 1) + if func_end == -1: + func_end = len(content) + + func_content = content[func_start:func_end] + + # Check for container append with system role + has_container_param = 'web_search_results_container' in func_content + has_append_call = 'web_search_results_container.append' in func_content + has_system_message = "'role': 'system'" in func_content or '"role": "system"' in func_content + + checks = { + 'has_container_parameter': has_container_param, + 'has_append_call': has_append_call, + 'has_system_role': has_system_message, + } + + all_passed = True + for check_name, passed in checks.items(): + status = "āœ…" if passed else "āŒ" + print(f" {status} {check_name}") + if not passed: + all_passed = False + + if all_passed: + print("āœ… System message injection mechanism verified") + else: + print("āŒ System message injection may not be properly implemented") + + return all_passed + + except Exception as e: + print(f"āŒ Test failed: {e}") + import traceback + traceback.print_exc() + return False + + +def run_all_tests(): + """Run all tests and report results.""" + print("=" * 60) + print("Web Search Failure Graceful Handling Fix - Functional Tests") + print("Version: 0.236.013") + print("=" * 60) + + tests = [ + ("Return Boolean Values", test_perform_web_search_returns_boolean), + ("Failure Message Patterns", test_failure_message_injection_patterns), + ("Error Scenarios Return False", test_error_scenarios_have_return_false), + ("Container System Message Injection", test_web_search_results_container_usage), + ] + + results = [] + for test_name, test_func in tests: + print(f"\n{'─' * 60}") + print(f"Test: {test_name}") + print('─' * 60) + try: + result = test_func() + results.append((test_name, result)) + except Exception as e: + print(f"āŒ Test '{test_name}' raised exception: {e}") + results.append((test_name, False)) + + print("\n" + "=" * 60) + print("SUMMARY") + print("=" * 60) + + passed = sum(1 for _, r in results if r) + total = len(results) + + for test_name, result in results: + status = "āœ… PASS" if result else "āŒ FAIL" + print(f" {status}: {test_name}") + + print(f"\nšŸ“Š Results: {passed}/{total} tests passed") + + if passed == total: + print("\nšŸŽ‰ All tests passed!") + return True + else: + print(f"\nāš ļø {total - passed} test(s) failed") + return False + + +if __name__ == "__main__": + success = run_all_tests() + sys.exit(0 if success else 1)