From dd693e8bd977bdb6e413033bc638bb226d5516e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:44:12 -0300 Subject: [PATCH 1/6] chore(deps): bump azure-keyvault-secrets from 4.9.0 to 4.11.0 in /config (#502) Bumps [azure-keyvault-secrets](https://github.com/Azure/azure-sdk-for-python) from 4.9.0 to 4.11.0. - [Release notes](https://github.com/Azure/azure-sdk-for-python/releases) - [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-keyvault-secrets_4.9.0...azure-keyvault-secrets_4.11.0) --- updated-dependencies: - dependency-name: azure-keyvault-secrets dependency-version: 4.11.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- config/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/requirements.txt b/config/requirements.txt index fe56a3b2..8c34e199 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -14,5 +14,5 @@ azure-mgmt-cognitiveservices==13.6.0 # search requests==2.34.2 -azure-keyvault-secrets==4.9.0 +azure-keyvault-secrets==4.11.0 jinja2==3.1.6 \ No newline at end of file From d1feff83f2e89c4d92d9932a2c1662be42e1fadf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:44:52 -0300 Subject: [PATCH 2/6] chore(deps): bump azure-appconfiguration from 1.8.1 to 1.9.0 in /config (#540) Bumps [azure-appconfiguration](https://github.com/Azure/azure-sdk-for-python) from 1.8.1 to 1.9.0. - [Release notes](https://github.com/Azure/azure-sdk-for-python/releases) - [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-appconfiguration_1.8.1...azure-appconfiguration_1.9.0) --- updated-dependencies: - dependency-name: azure-appconfiguration dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- config/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/requirements.txt b/config/requirements.txt index 8c34e199..b50c7acd 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -1,7 +1,7 @@ # general azure-identity==1.25.3 -azure-appconfiguration==1.8.1 +azure-appconfiguration==1.9.0 # app container apps From bfca4a4d8bf606fa55c8714f0d9947ccf00983b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:47:35 -0300 Subject: [PATCH 3/6] chore(deps): bump azure-mgmt-appcontainers in /config (#566) Bumps [azure-mgmt-appcontainers](https://github.com/Azure/azure-sdk-for-python) from 3.2.0 to 5.0.0. - [Release notes](https://github.com/Azure/azure-sdk-for-python/releases) - [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-mgmt-appcontainers_3.2.0...azure-mgmt-appcontainers_5.0.0) --- updated-dependencies: - dependency-name: azure-mgmt-appcontainers dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- config/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/requirements.txt b/config/requirements.txt index b50c7acd..f8e865ff 100644 --- a/config/requirements.txt +++ b/config/requirements.txt @@ -5,7 +5,7 @@ azure-appconfiguration==1.9.0 # app container apps -azure-mgmt-appcontainers==3.2.0 +azure-mgmt-appcontainers==5.0.0 # aifoundry From e841185fa24ed473db62c2f28f76207fd96422fd Mon Sep 17 00:00:00 2001 From: Paulo Lacerda Date: Sun, 19 Jul 2026 12:09:26 -0300 Subject: [PATCH 4/6] feat(search): add generic MCP Server knowledge source template (preview) (#568) Add opt-in, disabled-by-default generic Foundry IQ MCP Server knowledge source provisioning with fail-closed canonical validation, safe request-time credential metadata, and comprehensive template and preflight coverage. Pin gpt-rag-orchestrator v3.7.0 as the minimum compatible runtime and lock the shared canonical source fixture to that released contract. Refs #567. The issue remains open until the umbrella release is published. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 + config/search/foundry_iq_mcp_setup.py | 734 +++++ config/search/search.j2 | 42 +- config/search/search.settings.j2 | 5 + config/search/setup.py | 40 + ...foundry_iq_mcp_canonical_source_3_7_0.json | 42 + .../search/tests/test_foundry_iq_templates.py | 2767 ++++++++++++----- manifest.json | 2 +- scripts/postProvision.ps1 | 79 +- 9 files changed, 2974 insertions(+), 745 deletions(-) create mode 100644 config/search/foundry_iq_mcp_setup.py create mode 100644 config/search/tests/fixtures/foundry_iq_mcp_canonical_source_3_7_0.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff30dd9..020e0d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,16 @@ ### Added +- **Generic MCP Server knowledge source template (preview, [Azure/GPT-RAG#567](https://github.com/Azure/GPT-RAG/issues/567), PR #568).** `config/search/search.j2` renders the `mcpServer` Foundry IQ knowledge source (Search API `2026-05-01-preview`) when `FOUNDRY_IQ_MCP_ENABLED=true` and at least one source is configured in `FOUNDRY_IQ_MCP_SOURCES_JSON`. Off by default; disabled rendering is byte-for-byte unchanged (`models: []`, `retrievalReasoningEffort: minimal`). When enabled, the knowledge base's `models` array reuses the same Azure OpenAI model object already rendered for standard-mode Blob content extraction as the planning model, and `retrievalReasoningEffort` is set to `low` or `medium` via `FOUNDRY_IQ_MCP_REASONING_EFFORT`. Each tool uses the official `outputParsing` object shape (`{"kind": "auto"|"none"|"split"}` or `{"kind": "json", "jsonParameters": {"documentsPath": ...}}`) and `inclusionMode` accepts `reranked` or `always`. Coexists with all existing knowledge sources (Blob, Search index, Work IQ, Fabric ontology/Data Agent, SharePoint remote/indexed, OneLake, Web). +- **`config/search/foundry_iq_mcp_setup.py`: fail-closed validation for MCP sources.** Unlike the soft/best-effort preflight pattern used by some other opt-in sources, `config/search/setup.py` now aborts provisioning with an actionable error when `FOUNDRY_IQ_MCP_ENABLED=true` and the configuration is invalid: malformed JSON, zero sources or tools, duplicate names, non-`https` `serverURL`, userinfo/query string/fragment/IP-literal/local/reserved hosts, a host outside `FOUNDRY_IQ_MCP_TRUSTED_HOSTS`, unsupported `outputParsing`/`inclusionMode` values, invalid output-parsing parameters, non-positive or over-cap (`8192`) `maxOutputTokens`, an `alwaysQuerySource` field, or any `auth`/`authentication` key or literal credential-shaped field anywhere in the JSON. Optional `queryHeaders` accepts only non-secret runtime metadata: managed identity or OBO with an explicit scope, Key Vault with a secret name, or `none` with no credential fields. Header names must be valid HTTP tokens and cannot be `Host`, `Content-Length`, or hop-by-hop headers. The canonical metadata is persisted for orchestrator request-time resolution but is never rendered into Search knowledge-source registration or knowledge-base parameters. The PowerShell preflight validates and canonicalizes the source JSON before any App Configuration write. When disabled, it skips source parsing and writes canonical `[]` plus safe MCP defaults. +- **Global knowledge source name uniqueness.** `config/search/setup.py` now validates, case-insensitively and before any Search API provisioning call, that every enabled/renderable Foundry IQ knowledge source name (Blob/Search Index, conversation upload, Work IQ, Fabric IQ, Fabric Data Agent, SharePoint Indexed, Web grounding, and every MCP Server source) is globally unique, aborting with an actionable error on the first collision. +- **`FOUNDRY_IQ_MCP_*` App Configuration passthrough.** `postProvision.ps1` seeds `FOUNDRY_IQ_MCP_ENABLED`, canonical `FOUNDRY_IQ_MCP_SOURCES_JSON`, `FOUNDRY_IQ_MCP_REASONING_EFFORT`, `FOUNDRY_IQ_MCP_TRUSTED_HOSTS`, and `FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS` with safe defaults so a fresh provision registers the keys without any config action from operators who do not opt in. Only validated non-secret `queryHeaders` references are retained in source JSON. +- **Docs: [Foundry IQ: Generic MCP server](https://azure.github.io/GPT-RAG/howto_grounding_mcp_server/) how-to (preview).** Covers the trusted-host and tool allowlist model, request-time managed identity/OBO/Key Vault header resolution, API Management / gateway and rate-limiting guidance, the Azure Monitor MCP reference scenario (workspace-scoped Log Analytics Data Reader, bounded time range and row caps, auditable KQL), rollout/canary/rollback via the existing disable gate, and production blockers. + ### Changed +- **`manifest.json`: orchestrator pin bumped from `v3.5.0` to [`v3.7.0`](https://github.com/Azure/gpt-rag-orchestrator/releases/tag/v3.7.0) for [#567](https://github.com/Azure/GPT-RAG/issues/567) / PR #568.** Orchestrator `v3.7.0` is the minimum compatible runtime for this preview because it consumes the canonical MCP source schema at request time, including `serverURL`, `outputParsing.jsonParameters`, `outputParsing.splitParameters`, `reranked`/`always` inclusion modes, and non-secret managed identity, OBO, Key Vault, or `none` query-header metadata. Earlier pins do not provide the complete runtime contract. Existing deployments must redeploy the `v3.7.0` orchestrator before enabling `FOUNDRY_IQ_MCP_ENABLED`. + ### Fixed ## [v3.5.0] - 2026-07-13 diff --git a/config/search/foundry_iq_mcp_setup.py b/config/search/foundry_iq_mcp_setup.py new file mode 100644 index 00000000..a9a37fe7 --- /dev/null +++ b/config/search/foundry_iq_mcp_setup.py @@ -0,0 +1,734 @@ +"""Foundry IQ generic MCP Server knowledge source validation (preview). + +MCP Server knowledge sources (kind ``mcpServer``, Search API +``2026-05-01-preview``) let a Foundry IQ knowledge base call tools exposed by +an arbitrary remote MCP server (for example, Azure Monitor MCP over +workspace-based Application Insights). Unlike the other knowledge sources in +this directory, MCP servers are attacker-reachable, operator-supplied remote +endpoints, so this module fails deployment closed (raises ``ValueError``) +instead of silently dropping a misconfigured source the way +``sharepoint_indexed_setup.filter_sharepoint_indexed_sources`` does. + +This module intentionally does NOT PUT the knowledge source itself; rendering +happens through the standard ``search.j2`` template (see the +``foundry_iq_mcp_enabled`` block) and registration goes through the existing +``provision_knowledge_sources`` path in ``config/search/setup.py``. This +module owns: + +1. ``is_foundry_iq_mcp_enabled`` -- the same enable gate used by the template + and by ``setup.py``. +2. ``validate_foundry_iq_mcp_settings`` -- the single entry point called from + ``setup.py`` before rendering. It parses and validates + ``FOUNDRY_IQ_MCP_SOURCES_JSON``, the trusted-host allowlist, the reasoning + effort, and the planning-model prerequisites, raising ``ValueError`` with + an actionable message on the first problem found. +3. A standalone CLI entry point (``python -m config.search.foundry_iq_mcp_setup``) + that validates ``FOUNDRY_IQ_MCP_SOURCES_JSON`` straight from process + environment variables. ``scripts/postProvision.ps1`` runs this as a + pre-flight gate, before it imports any settings into Azure App + Configuration, so an invalid/malicious MCP source is rejected before it is + ever persisted -- not just before the knowledge source is registered. + +``queryHeaders`` is runtime-only credential-resolution metadata. This module +accepts only strict, non-secret references (managed identity/OBO scopes, a Key +Vault secret name, or ``none``), persists that canonical metadata for the +orchestrator, and never renders it into the Search knowledge-source +registration payload. Literal credentials and every ``auth`` or +``authentication`` shape are rejected before any App Configuration write. +""" + +from __future__ import annotations + +import ipaddress +import json +import os +import re +import sys +from typing import Any +from urllib.parse import urlsplit + +MCP_KIND = "mcpServer" +MCP_PARAMS_KEY = "mcpServerParameters" + +LOCAL_MAX_OUTPUT_TOKENS_CAP = 8192 + +ALLOWED_INCLUSION_MODES = {"reranked", "always"} +ALLOWED_OUTPUT_PARSING_MODES = {"auto", "json", "split", "none"} +ALLOWED_REASONING_EFFORTS = {"low", "medium"} +DISALLOWED_HOSTNAMES = {"localhost", "localhost.localdomain", "home.arpa"} +DISALLOWED_HOST_SUFFIXES = ( + ".localhost", + ".local", + ".localdomain", + ".internal", + ".home", + ".home.arpa", + ".lan", + ".corp", + ".intranet", + ".private", + ".invalid", + ".test", + ".example", +) + +ALLOWED_SOURCE_KEYS = { + "name", + "description", + "serverURL", + "failOnError", + "maxOutputDocuments", + "tools", + "queryHeaders", +} +ALLOWED_TOOL_KEYS = {"name", "outputParsing", "inclusionMode", "maxOutputTokens"} +ALLOWED_OUTPUT_PARSING_KEYS = {"kind", "jsonParameters", "splitParameters"} +ALLOWED_JSON_PARAMETER_KEYS = {"documentsPath", "includeContext"} +ALLOWED_SPLIT_PARAMETER_KEYS = { + "textSplitMode", + "maximumPageLength", + "pageOverlapLength", + "maximumPagesToTake", + "defaultLanguageCode", +} +ALLOWED_TEXT_SPLIT_MODES = {"pages", "sentences"} +ALLOWED_QUERY_HEADER_KEYS = {"name", "valueFrom"} +ALLOWED_VALUE_FROM_KEYS = {"kind", "scope", "secretName"} +ALLOWED_VALUE_FROM_KINDS = {"managedIdentity", "obo", "keyVaultSecret", "none"} + +HEADER_NAME_PATTERN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +KNOWLEDGE_SOURCE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +KEY_VAULT_SECRET_NAME_PATTERN = re.compile(r"^[0-9A-Za-z-]{1,127}$") +DENIED_HEADER_NAMES = { + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} + +# Keys that must never appear anywhere in FOUNDRY_IQ_MCP_SOURCES_JSON, no +# matter how deeply nested, checked before any other validation runs. +# Key comparisons ignore punctuation and casing so variants such as +# ``api_key`` and ``connection-string`` cannot bypass the scan. Metadata keys +# explicitly allowed by the schema (``queryHeaders``, ``valueFrom``, +# ``secretName``, and ``scope``) do not collide with these exact normalized +# names. +SECRET_LIKE_KEYS_ANY_DEPTH = { + "auth", + "authentication", + "apikey", + "accesstoken", + "authorizationvalue", + "bearer", + "clientsecret", + "connectionstring", + "connectionstrings", + "cookie", + "cookies", + "credential", + "credentials", + "header", + "headerblob", + "headers", + "secret", + "secretvalue", + "storedheaders", + "refreshtoken", + "token", + "password", + "key", + "idtoken", + "authorization", + "value", +} + + +def _is_truthy(value: Any) -> bool: + return str(value).strip().lower() in {"1", "true", "t", "yes", "y"} + + +def _parse_trusted_hosts(raw: Any) -> set[str]: + if not raw: + return set() + if isinstance(raw, str): + text = raw.strip() + if text.startswith("["): + try: + raw = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError("FOUNDRY_IQ_MCP_TRUSTED_HOSTS is not valid JSON.") from exc + else: + raw = re.split(r"[,\r\n]+", text) + if not isinstance(raw, (list, tuple, set)): + raise ValueError("FOUNDRY_IQ_MCP_TRUSTED_HOSTS must be a host list.") + + hosts: set[str] = set() + for item in raw: + host = str(item).strip().rstrip(".").lower() + if not host or "://" in host or "/" in host or ":" in host: + raise ValueError( + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS entries must be hostnames only." + ) + hosts.add(host) + return hosts + + +def is_foundry_iq_mcp_enabled(context: dict) -> bool: + """Return True when generic MCP Server knowledge sources should be + registered on the Foundry IQ knowledge base. + + Requires the retrieval backend to be Foundry IQ and ``FOUNDRY_IQ_MCP_ENABLED`` + to be truthy. Whether ``FOUNDRY_IQ_MCP_SOURCES_JSON`` is well-formed and + non-empty is a validation concern, not a gating concern: an operator who + sets ``FOUNDRY_IQ_MCP_ENABLED=true`` with no (or invalid) sources must get + a hard failure from ``validate_and_get_mcp_sources``, not a silent no-op. + """ + + if str(context.get("RETRIEVAL_BACKEND") or "").lower() != "foundry_iq": + return False + return _is_truthy(context.get("FOUNDRY_IQ_MCP_ENABLED")) + + +def _validate_server_url(label: str, server_url: str, trusted_hosts: set[str]) -> None: + try: + parsed = urlsplit(server_url) + # Accessing ``port`` makes urllib validate non-numeric and out-of-range + # ports instead of leaving them in an otherwise parseable netloc. + _ = parsed.port + except ValueError as exc: + raise ValueError(f"{label}: 'serverURL' is not a valid URL.") from exc + + if parsed.scheme.lower() != "https": + raise ValueError(f"{label}: 'serverURL' must use https, got scheme '{parsed.scheme or '(none)'}'.") + if parsed.username or parsed.password: + raise ValueError(f"{label}: 'serverURL' must not contain userinfo (a username or password).") + if parsed.query or "?" in server_url: + raise ValueError(f"{label}: 'serverURL' must not contain a query string.") + if parsed.fragment: + raise ValueError(f"{label}: 'serverURL' must not contain a fragment.") + + hostname = parsed.hostname + if not hostname: + raise ValueError(f"{label}: 'serverURL' is missing a host.") + hostname_l = hostname.rstrip(".").lower() + + if ( + hostname_l in DISALLOWED_HOSTNAMES + or hostname_l.endswith(DISALLOWED_HOST_SUFFIXES) + or "." not in hostname_l + ): + raise ValueError(f"{label}: 'serverURL' must not use a local or reserved host.") + + try: + ipaddress.ip_address(hostname_l.strip("[]")) + is_ip_literal = True + except ValueError: + is_ip_literal = False + if is_ip_literal: + raise ValueError( + f"{label}: 'serverURL' host must be a DNS hostname, not an IP literal ('{hostname}'). " + "This blocks loopback, link-local, and other reserved IP ranges by construction." + ) + if hostname_l.rsplit(".", 1)[-1].isdigit(): + raise ValueError(f"{label}: 'serverURL' must not use a local or reserved host.") + + if not trusted_hosts: + raise ValueError( + f"{label}: FOUNDRY_IQ_MCP_TRUSTED_HOSTS is empty. Add the exact host '{hostname}' to the " + "allowlist before enabling this MCP source." + ) + if hostname_l not in trusted_hosts: + raise ValueError( + f"{label}: 'serverURL' host '{hostname}' is not an exact match in FOUNDRY_IQ_MCP_TRUSTED_HOSTS." + ) + + +def _normalized_key(key: Any) -> str: + return re.sub(r"[^a-z0-9]", "", str(key).strip().lower()) + + +def _scan_for_disallowed_keys_anywhere(node: Any, label: str) -> None: + """Recursively reject literal credential-shaped keys at any nesting depth. + + This runs before any other structural validation so a rejected key is + always reported clearly, regardless of where in the JSON it appears or + whether it also happens to be an otherwise-unexpected key. + """ + + if isinstance(node, dict): + for key, value in node.items(): + if _normalized_key(key) in SECRET_LIKE_KEYS_ANY_DEPTH: + raise ValueError( + f"{label}: key '{key}' is not allowed because literal credentials, auth objects, " + "stored headers, cookies, and connection strings must never appear in " + "FOUNDRY_IQ_MCP_SOURCES_JSON." + ) + _scan_for_disallowed_keys_anywhere(value, f"{label}.{key}") + elif isinstance(node, list): + for index, item in enumerate(node): + _scan_for_disallowed_keys_anywhere(item, f"{label}[{index}]") + + +def _validate_output_parsing(tool_label: str, value: Any) -> dict: + if not isinstance(value, dict): + raise ValueError(f"{tool_label}: 'outputParsing' must be a JSON object.") + + unexpected = set(value.keys()) - ALLOWED_OUTPUT_PARSING_KEYS + if unexpected: + raise ValueError(f"{tool_label}: 'outputParsing' has unexpected key(s) {sorted(unexpected)}.") + + kind = value.get("kind") + if not isinstance(kind, str) or kind not in ALLOWED_OUTPUT_PARSING_MODES: + raise ValueError( + f"{tool_label}: 'outputParsing.kind' must be one of " + f"{sorted(ALLOWED_OUTPUT_PARSING_MODES)}." + ) + + json_parameters = value.get("jsonParameters") + split_parameters = value.get("splitParameters") + if kind == "json": + if split_parameters is not None: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters' is only valid when kind is 'split'." + ) + if not isinstance(json_parameters, dict): + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters.documentsPath' is required " + "when kind is 'json'." + ) + unexpected = set(json_parameters.keys()) - ALLOWED_JSON_PARAMETER_KEYS + if unexpected: + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters' has unexpected key(s) " + f"{sorted(unexpected)}." + ) + documents_path = json_parameters.get("documentsPath") + if not isinstance(documents_path, str) or not documents_path.strip(): + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters.documentsPath' must be a non-empty string." + ) + canonical_json_parameters = {"documentsPath": documents_path.strip()} + include_context = json_parameters.get("includeContext") + if include_context is not None: + if not isinstance(include_context, bool): + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters.includeContext' must be a boolean." + ) + canonical_json_parameters["includeContext"] = include_context + return { + "kind": "json", + "jsonParameters": canonical_json_parameters, + } + + if json_parameters is not None: + raise ValueError( + f"{tool_label}: 'outputParsing.jsonParameters' is only valid when kind is 'json'." + ) + if kind == "split": + if split_parameters is None: + return {"kind": "split"} + if not isinstance(split_parameters, dict): + raise ValueError(f"{tool_label}: 'outputParsing.splitParameters' must be a JSON object.") + unexpected = set(split_parameters.keys()) - ALLOWED_SPLIT_PARAMETER_KEYS + if unexpected: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters' has unexpected key(s) " + f"{sorted(unexpected)}." + ) + + canonical_split_parameters: dict[str, Any] = {} + text_split_mode = split_parameters.get("textSplitMode") + if text_split_mode is not None: + if text_split_mode not in ALLOWED_TEXT_SPLIT_MODES: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.textSplitMode' must be one of " + f"{sorted(ALLOWED_TEXT_SPLIT_MODES)}." + ) + canonical_split_parameters["textSplitMode"] = text_split_mode + + for key in ("maximumPageLength", "maximumPagesToTake"): + parameter = split_parameters.get(key) + if parameter is not None: + if isinstance(parameter, bool) or not isinstance(parameter, int) or parameter <= 0: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.{key}' must be a positive integer." + ) + canonical_split_parameters[key] = parameter + + page_overlap_length = split_parameters.get("pageOverlapLength") + if page_overlap_length is not None: + if ( + isinstance(page_overlap_length, bool) + or not isinstance(page_overlap_length, int) + or page_overlap_length < 0 + ): + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.pageOverlapLength' " + "must be a non-negative integer." + ) + maximum_page_length = split_parameters.get("maximumPageLength") + if maximum_page_length is not None and page_overlap_length >= maximum_page_length: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.pageOverlapLength' " + "must be less than maximumPageLength." + ) + canonical_split_parameters["pageOverlapLength"] = page_overlap_length + + default_language_code = split_parameters.get("defaultLanguageCode") + if default_language_code is not None: + if not isinstance(default_language_code, str) or not default_language_code.strip(): + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters.defaultLanguageCode' " + "must be a non-empty string." + ) + canonical_split_parameters["defaultLanguageCode"] = default_language_code.strip() + + return {"kind": "split", "splitParameters": canonical_split_parameters} + if split_parameters is not None: + raise ValueError( + f"{tool_label}: 'outputParsing.splitParameters' is only valid when kind is 'split'." + ) + return {"kind": kind} + + +def _validate_tool(source_label: str, tool: Any, seen_tool_names: set[str]) -> dict: + if not isinstance(tool, dict): + raise ValueError(f"{source_label}: each tool must be a JSON object.") + + unexpected = set(tool.keys()) - ALLOWED_TOOL_KEYS + if unexpected: + raise ValueError(f"{source_label}: tool has unexpected key(s) {sorted(unexpected)}.") + + name_value = tool.get("name") + if not isinstance(name_value, str) or not name_value.strip(): + raise ValueError(f"{source_label}: tool 'name' is required.") + name = name_value.strip() + normalized_name = name.casefold() + if normalized_name in seen_tool_names: + raise ValueError(f"{source_label}: tool name '{name}' is used more than once; tool names must be unique.") + seen_tool_names.add(normalized_name) + tool_label = f"{source_label} tool '{name}'" + + output_parsing = _validate_output_parsing(tool_label, tool.get("outputParsing")) + + inclusion_mode = tool.get("inclusionMode") + if inclusion_mode not in ALLOWED_INCLUSION_MODES: + raise ValueError( + f"{tool_label}: 'inclusionMode' must be one of {sorted(ALLOWED_INCLUSION_MODES)}." + ) + + max_output_tokens = tool.get("maxOutputTokens") + if isinstance(max_output_tokens, bool) or not isinstance(max_output_tokens, int) or max_output_tokens <= 0: + raise ValueError(f"{tool_label}: 'maxOutputTokens' must be a positive integer.") + if max_output_tokens > LOCAL_MAX_OUTPUT_TOKENS_CAP: + raise ValueError( + f"{tool_label}: 'maxOutputTokens' ({max_output_tokens}) exceeds the local cap of " + f"{LOCAL_MAX_OUTPUT_TOKENS_CAP}." + ) + return { + "name": name, + "outputParsing": output_parsing, + "inclusionMode": inclusion_mode, + "maxOutputTokens": max_output_tokens, + } + + +def _validate_query_header(source_label: str, header: Any, seen_names: set[str]) -> dict: + if not isinstance(header, dict): + raise ValueError(f"{source_label}: each queryHeaders entry must be a JSON object.") + + unexpected = set(header.keys()) - ALLOWED_QUERY_HEADER_KEYS + if unexpected: + raise ValueError(f"{source_label}: queryHeaders entry has unexpected key(s) {sorted(unexpected)}.") + + name_value = header.get("name") + if not isinstance(name_value, str): + raise ValueError(f"{source_label}: query header 'name' must be a string.") + name = name_value.strip() + if not HEADER_NAME_PATTERN.fullmatch(name): + raise ValueError(f"{source_label}: query header name is not a valid HTTP field name.") + if name.lower() in DENIED_HEADER_NAMES: + raise ValueError(f"{source_label}: query header '{name}' is not allowed.") + if name.casefold() in seen_names: + raise ValueError(f"{source_label}: query header names must be unique within a source.") + seen_names.add(name.casefold()) + + value_from = header.get("valueFrom") + if not isinstance(value_from, dict): + raise ValueError(f"{source_label}: query header 'valueFrom' must be a JSON object.") + unexpected = set(value_from.keys()) - ALLOWED_VALUE_FROM_KEYS + if unexpected: + raise ValueError( + f"{source_label}: query header 'valueFrom' has unexpected key(s) {sorted(unexpected)}." + ) + + kind = value_from.get("kind") + if kind not in ALLOWED_VALUE_FROM_KINDS: + raise ValueError( + f"{source_label}: query header 'valueFrom.kind' must be one of " + f"{sorted(ALLOWED_VALUE_FROM_KINDS)}." + ) + + scope = value_from.get("scope") + secret_name = value_from.get("secretName") + if kind in {"managedIdentity", "obo"}: + if not isinstance(scope, str) or not scope.strip(): + raise ValueError(f"{source_label}: query header kind '{kind}' requires an explicit scope.") + if any(ord(character) < 0x20 or ord(character) == 0x7F for character in scope): + raise ValueError(f"{source_label}: query header scope contains control characters.") + if secret_name is not None: + raise ValueError(f"{source_label}: 'secretName' is not valid for query header kind '{kind}'.") + canonical_value_from = {"kind": kind, "scope": scope.strip()} + elif kind == "keyVaultSecret": + if scope is not None: + raise ValueError(f"{source_label}: 'scope' is not valid for query header kind 'keyVaultSecret'.") + if not isinstance(secret_name, str) or not KEY_VAULT_SECRET_NAME_PATTERN.fullmatch(secret_name): + raise ValueError( + f"{source_label}: query header kind 'keyVaultSecret' requires a valid secretName." + ) + canonical_value_from = {"kind": kind, "secretName": secret_name} + else: + if scope is not None or secret_name is not None: + raise ValueError( + f"{source_label}: query header kind 'none' must not carry scope or secretName." + ) + canonical_value_from = {"kind": "none"} + + return {"name": name, "valueFrom": canonical_value_from} + + +def validate_and_get_mcp_sources(context: dict) -> list[dict]: + """Parse, validate, and return the configured MCP sources. + + Returns an empty list when the feature is not enabled. Raises + ``ValueError`` with an actionable message on the first invalid field + found when it is enabled. + """ + + if not is_foundry_iq_mcp_enabled(context): + return [] + + sources = context.get("FOUNDRY_IQ_MCP_SOURCES_JSON") + if not isinstance(sources, list): + raise ValueError( + "FOUNDRY_IQ_MCP_SOURCES_JSON must be a JSON array of MCP source objects; " + f"got {type(sources).__name__}." + ) + if not sources: + raise ValueError( + "FOUNDRY_IQ_MCP_ENABLED is true but FOUNDRY_IQ_MCP_SOURCES_JSON has no sources. " + "Add at least one MCP source or set FOUNDRY_IQ_MCP_ENABLED=false." + ) + + # Reject auth objects and literal credential-shaped keys anywhere in the + # raw JSON before any structural validation runs. + _scan_for_disallowed_keys_anywhere(sources, "FOUNDRY_IQ_MCP_SOURCES_JSON") + + trusted_hosts = _parse_trusted_hosts(context.get("FOUNDRY_IQ_MCP_TRUSTED_HOSTS")) + reasoning_effort = str(context.get("FOUNDRY_IQ_MCP_REASONING_EFFORT") or "low").strip().lower() + if reasoning_effort not in ALLOWED_REASONING_EFFORTS: + raise ValueError( + f"FOUNDRY_IQ_MCP_REASONING_EFFORT must be one of {sorted(ALLOWED_REASONING_EFFORTS)}, " + f"got '{reasoning_effort}'. MCP knowledge sources require the query planner to run." + ) + + canonical_sources: list[dict] = [] + seen_names: set[str] = set() + for index, source in enumerate(sources): + label = f"FOUNDRY_IQ_MCP_SOURCES_JSON[{index}]" + if not isinstance(source, dict): + raise ValueError(f"{label}: must be a JSON object.") + + unexpected = set(source.keys()) - ALLOWED_SOURCE_KEYS + if unexpected: + raise ValueError(f"{label}: unexpected key(s) {sorted(unexpected)}.") + # Note: 'alwaysQuerySource' is deliberately absent from + # ALLOWED_SOURCE_KEYS, so the unexpected-key check above already + # rejects it. MCP sources are never forced into every retrieval. + + name_value = source.get("name") + if not isinstance(name_value, str) or not name_value.strip(): + raise ValueError(f"{label}: 'name' is required.") + name = name_value.strip() + if not KNOWLEDGE_SOURCE_NAME_PATTERN.fullmatch(name): + raise ValueError( + f"{label}: 'name' must start with a letter or number, contain only letters, " + "numbers, '.', '_' or '-', and be at most 128 characters." + ) + normalized_name = name.casefold() + if normalized_name in seen_names: + raise ValueError(f"MCP source name '{name}' is used more than once; source names must be unique.") + seen_names.add(normalized_name) + label = f"MCP source '{name}'" + + description_value = source.get("description") + if description_value is not None and not isinstance(description_value, str): + raise ValueError(f"{label}: 'description' must be a string when provided.") + + server_url_value = source.get("serverURL") + if not isinstance(server_url_value, str) or not server_url_value.strip(): + raise ValueError(f"{label}: 'serverURL' is required.") + server_url = server_url_value.strip() + _validate_server_url(label, server_url, trusted_hosts) + + fail_on_error = source.get("failOnError") + if "failOnError" in source and not isinstance(fail_on_error, bool): + raise ValueError(f"{label}: 'failOnError' must be a boolean when provided.") + + max_output_documents = source.get("maxOutputDocuments") + if max_output_documents is not None and ( + isinstance(max_output_documents, bool) + or not isinstance(max_output_documents, int) + or not 1 <= max_output_documents <= 50 + ): + raise ValueError( + f"{label}: 'maxOutputDocuments' must be an integer between 1 and 50 when provided." + ) + + tools = source.get("tools") + if not isinstance(tools, list) or not tools: + raise ValueError(f"{label}: 'tools' must be a non-empty array.") + seen_tool_names: set[str] = set() + canonical_tools: list[dict] = [] + for tool in tools: + canonical_tools.append(_validate_tool(label, tool, seen_tool_names)) + + query_headers = source.get("queryHeaders", []) + if not isinstance(query_headers, list): + raise ValueError(f"{label}: 'queryHeaders' must be an array when provided.") + seen_header_names: set[str] = set() + canonical_headers = [ + _validate_query_header(label, header, seen_header_names) + for header in query_headers + ] + + canonical_source = { + "name": name, + "serverURL": server_url, + "tools": canonical_tools, + } + if description_value is not None: + canonical_source["description"] = description_value.strip() + if "failOnError" in source: + canonical_source["failOnError"] = fail_on_error + if "maxOutputDocuments" in source: + canonical_source["maxOutputDocuments"] = max_output_documents + if "queryHeaders" in source: + canonical_source["queryHeaders"] = canonical_headers + canonical_sources.append(canonical_source) + + return canonical_sources + + +def validate_foundry_iq_mcp_settings(context: dict) -> None: + """Top-level fail-closed validation entry point, called from + ``setup.py`` right after ``validate_foundry_iq_settings``. + + Raises ``ValueError`` (aborting the whole provisioning run) when + ``FOUNDRY_IQ_MCP_ENABLED`` is true and the configuration is invalid, or + when no planning model is available for the knowledge base. When the + feature is disabled this is a no-op so disabled deployments render + exactly as before. + """ + + if not is_foundry_iq_mcp_enabled(context): + # Do not inspect or parse stale source content while disabled. Keeping + # the context canonical also prevents a later App Configuration write + # from re-persisting disabled source data. + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [] + return + + # Validate and recursively scan all source content before checking later + # prerequisites or allowing setup.py to persist the rendered settings. + sources = validate_and_get_mcp_sources(context) + if not context.get("GPT_MODEL_INFO"): + raise ValueError( + "FOUNDRY_IQ_MCP_ENABLED is true but no chat model was found in MODEL_DEPLOYMENTS (canonical_name " + "'CHAT_DEPLOYMENT_NAME'). MCP knowledge sources require a planning model for tool selection and " + "argument generation." + ) + if not context.get("FOUNDRY_IQ_AI_SERVICES_ENDPOINT"): + raise ValueError( + "FOUNDRY_IQ_MCP_ENABLED is true but no AI Services endpoint could be derived. Set " + "FOUNDRY_IQ_AI_SERVICES_ENDPOINT, AI_FOUNDRY_PROJECT_ENDPOINT, or AI_FOUNDRY_ACCOUNT_NAME." + ) + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = sources + + +def _parse_env_sources_json(raw: str) -> Any: + """Parse the raw FOUNDRY_IQ_MCP_SOURCES_JSON environment variable value. + + Used only by the standalone CLI pre-flight (below); ``setup.py`` itself + reads already-parsed values via ``load_appconfig_settings``. + """ + + text = (raw or "").strip() + if not text: + return [] + try: + return json.loads(text) + except json.JSONDecodeError as je: + raise ValueError(f"FOUNDRY_IQ_MCP_SOURCES_JSON is not valid JSON: {je}") from je + + +def build_preflight_context_from_environ() -> dict: + """Build the minimal validation context ``validate_and_get_mcp_sources`` + needs, sourced directly from process environment variables. + + This lets this module be invoked standalone, as a provisioning + pre-flight gate, against the operator's raw candidate settings -- + before ``scripts/postProvision.ps1`` imports anything into Azure App + Configuration. Only the fields ``validate_and_get_mcp_sources`` reads + are included; the planning-model/AI-Services-endpoint checks in + ``validate_foundry_iq_mcp_settings`` need App-Configuration-derived data + (``MODEL_DEPLOYMENTS``) that does not exist yet at this point in + provisioning, so they are intentionally not part of this pre-flight gate + and remain covered later by ``config.search.setup`` itself. + """ + + context = { + "RETRIEVAL_BACKEND": os.environ.get("RETRIEVAL_BACKEND", "foundry_iq"), + "FOUNDRY_IQ_MCP_ENABLED": os.environ.get("FOUNDRY_IQ_MCP_ENABLED", "false"), + "FOUNDRY_IQ_MCP_SOURCES_JSON": [], + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS": os.environ.get("FOUNDRY_IQ_MCP_TRUSTED_HOSTS", ""), + "FOUNDRY_IQ_MCP_REASONING_EFFORT": os.environ.get("FOUNDRY_IQ_MCP_REASONING_EFFORT", "low"), + } + # Check the explicit enablement gate before touching the raw JSON. This + # keeps disabled provisioning independent of stale or malformed source + # content left in the environment. + if is_foundry_iq_mcp_enabled(context): + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = _parse_env_sources_json( + os.environ.get("FOUNDRY_IQ_MCP_SOURCES_JSON", "[]") + ) + return context + + +def main(*, emit_canonical: bool = False) -> int: + """Pre-flight CLI entry point. + + Validates FOUNDRY_IQ_MCP_SOURCES_JSON (and the other FOUNDRY_IQ_MCP_* + environment variables) before import. With ``emit_canonical=True`` it + writes the canonical, metadata-only source JSON to stdout for the + PowerShell preflight to persist. Errors go to stderr without rejected + values. This module never writes App Configuration itself. + """ + + try: + context = build_preflight_context_from_environ() + sources = validate_and_get_mcp_sources(context) + except ValueError as ve: + print(f"❗️ FOUNDRY_IQ_MCP_SOURCES_JSON validation failed: {ve}", file=sys.stderr) + return 1 + if emit_canonical: + print(json.dumps(sources, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(emit_canonical="--canonical" in sys.argv[1:])) diff --git a/config/search/search.j2 b/config/search/search.j2 index 14ca82e9..d1920935 100644 --- a/config/search/search.j2 +++ b/config/search/search.j2 @@ -15,6 +15,9 @@ {% for _d in (WEB_GROUNDING_BLOCKED_DOMAINS | default('', true)).replace(';', ',').replace('\n', ',').split(',') %} {% if _d.strip() %}{% set _ = web_blocked_domains.append(_d.strip().lower()) %}{% endif %} {% endfor %} +{% set foundry_iq_mcp_sources = FOUNDRY_IQ_MCP_SOURCES_JSON if (FOUNDRY_IQ_MCP_SOURCES_JSON is iterable and FOUNDRY_IQ_MCP_SOURCES_JSON is not string and FOUNDRY_IQ_MCP_SOURCES_JSON is not mapping) else [] %} +{% set foundry_iq_mcp_enabled = RETRIEVAL_BACKEND == 'foundry_iq' and (FOUNDRY_IQ_MCP_ENABLED | default('false') | string | lower) in ['true', '1', 'yes', 'y', 't'] and (foundry_iq_mcp_sources | length) > 0 %} +{% set foundry_iq_mcp_reasoning_effort = FOUNDRY_IQ_MCP_REASONING_EFFORT | default('low', true) %} { "indexes": [ { @@ -483,6 +486,29 @@ "encryptionKey": null } {% endif %} + {% if foundry_iq_mcp_enabled %} + {% for mcp_src in foundry_iq_mcp_sources %}, + { + "name": {{ mcp_src.name | tojson }}, + "kind": "mcpServer", + "description": {{ (mcp_src.description | default('MCP server knowledge source (' ~ mcp_src.name ~ ').', true)) | tojson }}, + "mcpServerParameters": { + "serverURL": {{ mcp_src.serverURL | tojson }}, + "tools": [ + {% for tool in mcp_src.tools %}{% if not loop.first %},{% endif %} + { + "name": {{ tool.name | tojson }}, + "outputParsing": {{ tool.outputParsing | tojson }}, + "inclusionMode": {{ tool.inclusionMode | tojson }}, + "maxOutputTokens": {{ tool.maxOutputTokens }} + } + {% endfor %} + ] + }, + "encryptionKey": null + } + {% endfor %} + {% endif %} ]{% else %}[]{% endif %}, "knowledgeBases": {% if RETRIEVAL_BACKEND == 'foundry_iq' %}[ { @@ -498,11 +524,21 @@ { "name": "{{FABRIC_IQ_KNOWLEDGE_SOURCE_NAME}}" }{% endif %}{% if fabric_data_agent_enabled %}, { "name": "{{FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME}}" }{% endif %}{% if sharepoint_indexed_enabled %}, { "name": "{{SHAREPOINT_INDEXED_KNOWLEDGE_SOURCE_NAME}}" }{% endif %}{% if web_grounding_enabled %}, - { "name": "{{WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME}}" }{% endif %} + { "name": "{{WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME}}" }{% endif %}{% if foundry_iq_mcp_enabled %}{% for mcp_src in foundry_iq_mcp_sources %}, + { "name": {{ mcp_src.name | tojson }} }{% endfor %}{% endif %} ], - "models": [], + "models": {% if foundry_iq_mcp_enabled %}[ + { + "kind": "azureOpenAI", + "azureOpenAIParameters": { + "resourceUri": "{{FOUNDRY_IQ_AI_SERVICES_ENDPOINT}}", + "deploymentId": "{{GPT_MODEL_INFO.deployment_name}}", + "modelName": "{{GPT_MODEL_INFO.model_name}}" + } + } + ]{% else %}[]{% endif %}, "encryptionKey": null, - "retrievalReasoningEffort": { "kind": "minimal" } + "retrievalReasoningEffort": { "kind": "{% if foundry_iq_mcp_enabled %}{{foundry_iq_mcp_reasoning_effort}}{% else %}minimal{% endif %}" } } ]{% else %}[]{% endif %} } diff --git a/config/search/search.settings.j2 b/config/search/search.settings.j2 index 0fe6dee0..9f6054cb 100644 --- a/config/search/search.settings.j2 +++ b/config/search/search.settings.j2 @@ -55,6 +55,11 @@ "WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME": "{{WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME | default('')}}", "WEB_GROUNDING_ALLOWED_DOMAINS": "{{WEB_GROUNDING_ALLOWED_DOMAINS | default('')}}", "WEB_GROUNDING_BLOCKED_DOMAINS": "{{WEB_GROUNDING_BLOCKED_DOMAINS | default('')}}", + "FOUNDRY_IQ_MCP_ENABLED": "{{FOUNDRY_IQ_MCP_ENABLED | default('false')}}", + "FOUNDRY_IQ_MCP_SOURCES_JSON": {{FOUNDRY_IQ_MCP_SOURCES_JSON | default([]) | tojson}}, + "FOUNDRY_IQ_MCP_REASONING_EFFORT": "{{FOUNDRY_IQ_MCP_REASONING_EFFORT | default('low')}}", + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS": "{{FOUNDRY_IQ_MCP_TRUSTED_HOSTS | default('')}}", + "FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS": "{{FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS | default('false')}}", {% if GPT_MODEL_INFO | default({}) %} "GPT_MODEL_DEPLOYMENT_NAME": "{{GPT_MODEL_INFO.deployment_name}}", "GPT_MODEL_NAME": "{{GPT_MODEL_INFO.model_name}}" diff --git a/config/search/setup.py b/config/search/setup.py index 26c49975..f91066b0 100644 --- a/config/search/setup.py +++ b/config/search/setup.py @@ -38,6 +38,8 @@ from azure.appconfiguration import AzureAppConfigurationClient, ConfigurationSetting from jinja2 import Environment, FileSystemLoader, StrictUndefined, TemplateError +from config.search.foundry_iq_mcp_setup import validate_foundry_iq_mcp_settings + # ── Silence verbose logging ───────────────────────────────────────────────── for logger_name in ( "azure.core.pipeline.policies.http_logging_policy", @@ -320,9 +322,15 @@ def render_and_parse_json(template_name_inner: str, ctx: dict) -> Optional[dict] vars_dict["FOUNDRY_IQ_AI_SERVICES_ENDPOINT"] = ai_services_endpoint try: validate_foundry_iq_settings(context) + validate_foundry_iq_mcp_settings(context) except ValueError as ve: logging.error(str(ve)) return None, context + # The MCP validator returns a canonical disabled value or a + # deep-copied, normalized source model. Persist that model rather + # than the raw App Configuration value. + if "FOUNDRY_IQ_MCP_SOURCES_JSON" in context: + vars_dict["FOUNDRY_IQ_MCP_SOURCES_JSON"] = context["FOUNDRY_IQ_MCP_SOURCES_JSON"] for key, val in vars_dict.items(): if isinstance(val, (dict, list)): final_val = json.dumps(val) @@ -599,6 +607,37 @@ def filter_work_iq_sources(defs: dict, context: dict, cred: ChainedTokenCredenti ] +def validate_unique_knowledge_source_names(defs: dict) -> None: + """Fail closed when the rendered template would register two or more + knowledge sources under the same name, case-insensitively. + + Azure AI Search knowledge source names are unique per search service; a + case-only collision between, say, the Blob source and an MCP source + would silently overwrite one of them at registration time. This checks + ``defs["knowledgeSources"]`` -- the already-rendered list, which by + construction only contains sources that search.j2's own per-kind + enablement gates decided are enabled/renderable (Blob or Search Index, + the conversation-upload Search Index, Work IQ, Fabric IQ, Fabric Data + Agent, SharePoint Indexed, Web grounding, and every MCP Server source) -- + rather than re-deriving each kind's enablement logic here. + """ + + seen: dict[str, str] = {} + for ks in defs.get("knowledgeSources") or []: + name = str(ks.get("name") or "").strip() + if not name: + continue + key = name.lower() + if key in seen: + raise ValueError( + f"Knowledge source name '{name}' collides (case-insensitive) with '{seen[key]}'. " + "Knowledge source names must be globally unique across every enabled source " + "(Blob/Search Index, Work IQ, Fabric IQ, Fabric Data Agent, SharePoint Indexed, Web " + "grounding, MCP Server); rename one of them before provisioning." + ) + seen[key] = name + + def provision_knowledge_sources(defs: dict, context: dict, cred: ChainedTokenCredential, search_endpoint: str): """Create or update Foundry IQ knowledge sources. @@ -698,6 +737,7 @@ def provision_knowledge_bases(defs: dict, context: dict, cred: ChainedTokenCrede def execute_setup(defs: Optional[dict], context: dict): if defs is None: raise RuntimeError("No search definitions were rendered; aborting Azure Search setup") + validate_unique_knowledge_source_names(defs) cred = ChainedTokenCredential(AzureCliCredential(),ManagedIdentityCredential()) indexers = defs.get("indexers", []) ds_to_indexers = {} diff --git a/config/search/tests/fixtures/foundry_iq_mcp_canonical_source_3_7_0.json b/config/search/tests/fixtures/foundry_iq_mcp_canonical_source_3_7_0.json new file mode 100644 index 00000000..c2dc7960 --- /dev/null +++ b/config/search/tests/fixtures/foundry_iq_mcp_canonical_source_3_7_0.json @@ -0,0 +1,42 @@ +{ + "name": "monitor-mcp", + "description": "Read-only Azure Monitor MCP source.", + "serverURL": "https://mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results[*]", + "includeContext": true + } + }, + "inclusionMode": "reranked", + "maxOutputTokens": 2048 + }, + { + "name": "get_status", + "outputParsing": { + "kind": "auto" + }, + "inclusionMode": "always", + "maxOutputTokens": 512 + }, + { + "name": "split_report", + "outputParsing": { + "kind": "split", + "splitParameters": { + "textSplitMode": "pages", + "maximumPageLength": 4000, + "pageOverlapLength": 500, + "maximumPagesToTake": 10, + "defaultLanguageCode": "en" + } + }, + "inclusionMode": "reranked", + "maxOutputTokens": 1024 + } + ] +} diff --git a/config/search/tests/test_foundry_iq_templates.py b/config/search/tests/test_foundry_iq_templates.py index 7747c634..db06f082 100644 --- a/config/search/tests/test_foundry_iq_templates.py +++ b/config/search/tests/test_foundry_iq_templates.py @@ -1,731 +1,2036 @@ -import json -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -from jinja2 import Environment, FileSystemLoader, StrictUndefined - -from config.search import setup - - -TEMPLATE_DIR = Path(__file__).resolve().parents[1] - - -def render_json_template(template_name, context): - env = Environment( - loader=FileSystemLoader(TEMPLATE_DIR), - undefined=StrictUndefined, - keep_trailing_newline=True, - ) - rendered = env.get_template(template_name).render(**context) - return json.loads(rendered) - - -class FoundryIqTemplateTests(unittest.TestCase): - def test_settings_template_derives_search_index_before_dependent_defaults(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_PROJECT_ENDPOINT": "https://aif-abc123.services.ai.azure.com/api/projects/proj", - }, - ) - - self.assertEqual(settings["SEARCH_RAG_INDEX_NAME"], "ragindex-abc123") - self.assertEqual(settings["KNOWLEDGE_BASE_NAME"], "ragindex-abc123-rag-kb") - self.assertEqual(settings["FOUNDRY_IQ_KNOWLEDGE_SOURCE_NAME"], "ragindex-abc123-blob-ks") - self.assertEqual( - settings["FOUNDRY_IQ_AI_SERVICES_ENDPOINT"], - "https://aif-abc123.services.ai.azure.com/", - ) - - def test_standard_blob_knowledge_source_includes_ai_services_endpoint_without_unsupported_chat_model(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - "FOUNDRY_IQ_CONTENT_EXTRACTION_MODE": "standard", - }, - ) - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - search_definitions = render_json_template("search.j2", context) - knowledge_source = search_definitions["knowledgeSources"][0] - ingestion_parameters = knowledge_source["azureBlobParameters"]["ingestionParameters"] - - self.assertEqual(ingestion_parameters["contentExtractionMode"], "standard") - self.assertEqual( - ingestion_parameters["aiServices"]["uri"], - "https://aif-abc123.services.ai.azure.com/", - ) - self.assertIsNone(ingestion_parameters["chatCompletionModel"]) - - def test_standard_blob_knowledge_source_includes_supported_chat_model(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - "FOUNDRY_IQ_CONTENT_EXTRACTION_MODE": "standard", - }, - ) - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5.2", - }, - } - - search_definitions = render_json_template("search.j2", context) - knowledge_source = search_definitions["knowledgeSources"][0] - ingestion_parameters = knowledge_source["azureBlobParameters"]["ingestionParameters"] - - self.assertEqual( - ingestion_parameters["chatCompletionModel"]["azureOpenAIParameters"]["resourceUri"], - ingestion_parameters["aiServices"]["uri"], - ) - self.assertEqual( - ingestion_parameters["chatCompletionModel"]["azureOpenAIParameters"]["modelName"], - "gpt-5.2", - ) - - def test_network_isolated_blob_knowledge_source_sets_generated_indexer_private(self): - credential = Mock() - credential.get_token.return_value.token = "token" - get_response = Mock() - get_response.status_code = 200 - get_response.json.return_value = { - "@odata.context": "https://search/$metadata#indexers/$entity", - "name": "blob-ks-indexer", - "dataSourceName": "blob-ks-datasource", - "targetIndexName": "blob-ks-index", - "parameters": { - "configuration": { - "dataToExtract": "contentAndMetadata", - } - }, - } - put_response = Mock() - put_response.status_code = 200 - - with patch.object(setup.requests, "get", return_value=get_response), patch.object( - setup.requests, "put", return_value=put_response - ) as put: - setup.enforce_private_execution_for_generated_indexers( - { - "knowledgeSources": [ - { - "name": "blob-ks", - "kind": "azureBlob", - "azureBlobParameters": {}, - } - ] - }, - {"NETWORK_ISOLATION": "true"}, - credential, - "https://search.search.windows.net", - "2025-05-01-preview", - ) - - body = put.call_args.kwargs["json"] - self.assertNotIn("@odata.context", body) - self.assertEqual(body["parameters"]["configuration"]["executionEnvironment"], "Private") - - def test_non_network_isolated_setup_does_not_update_generated_indexer(self): - credential = Mock() - - with patch.object(setup.requests, "get") as get: - setup.enforce_private_execution_for_generated_indexers( - { - "knowledgeSources": [ - { - "name": "blob-ks", - "kind": "azureBlob", - "azureBlobParameters": {}, - } - ] - }, - {"NETWORK_ISOLATION": "false"}, - credential, - "https://search.search.windows.net", - "2025-05-01-preview", - ) - - get.assert_not_called() - - - def test_conversation_upload_disabled_by_default_keeps_single_knowledge_source(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED"], "false") - self.assertEqual( - settings["FOUNDRY_IQ_CONVERSATION_KNOWLEDGE_SOURCE_NAME"], - "ragindex-abc123-conv-ks", - ) - - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - search_definitions = render_json_template("search.j2", context) - self.assertEqual(len(search_definitions["knowledgeSources"]), 1) - self.assertEqual( - search_definitions["knowledgeSources"][0]["kind"], "azureBlob" - ) - self.assertEqual( - len(search_definitions["knowledgeBases"][0]["knowledgeSources"]), 1 - ) - - def test_conversation_upload_enabled_adds_conversational_search_index_source(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", - }, - ) - self.assertEqual(settings["FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED"], "true") - - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - search_definitions = render_json_template("search.j2", context) - knowledge_sources = search_definitions["knowledgeSources"] - self.assertEqual(len(knowledge_sources), 2) - self.assertEqual(knowledge_sources[0]["kind"], "azureBlob") - - conv_source = knowledge_sources[1] - self.assertEqual(conv_source["name"], "ragindex-abc123-conv-ks") - self.assertEqual(conv_source["kind"], "searchIndex") - self.assertEqual( - conv_source["searchIndexParameters"]["searchIndexName"], - "ragindex-abc123", - ) - self.assertEqual( - conv_source["searchIndexParameters"]["semanticConfigurationName"], - "semantic-config", - ) - - kb_sources = search_definitions["knowledgeBases"][0]["knowledgeSources"] - self.assertEqual( - [s["name"] for s in kb_sources], - ["ragindex-abc123-blob-ks", "ragindex-abc123-conv-ks"], - ) - - def test_conversation_upload_not_added_for_search_index_pattern(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - "FOUNDRY_IQ_PATTERN": "searchIndex", - "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", - }, - ) - - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - search_definitions = render_json_template("search.j2", context) - self.assertEqual(len(search_definitions["knowledgeSources"]), 1) - self.assertEqual( - search_definitions["knowledgeSources"][0]["kind"], "searchIndex" - ) - self.assertEqual( - len(search_definitions["knowledgeBases"][0]["knowledgeSources"]), 1 - ) - - -class WorkIqTemplateTests(unittest.TestCase): - """Work IQ is opt-in and default-off. Rendered output must be byte-identical - to the pre-Work IQ template when the feature is disabled. - """ - - def _foundry_iq_context(self, **overrides): - settings_input = { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - } - settings_input.update(overrides) - settings = render_json_template("search.settings.j2", settings_input) - return settings, { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - - def test_settings_defaults_work_iq_disabled_and_empty_name(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["WORK_IQ_ENABLED"], "false") - self.assertEqual(settings["WORK_IQ_KNOWLEDGE_SOURCE_NAME"], "") - - def test_work_iq_disabled_by_default_produces_no_workiq_entry(self): - _, context = self._foundry_iq_context() - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "workIQ") - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertNotIn("work-iq-ks", kb_source_names) - - def test_work_iq_enabled_without_name_produces_no_workiq_entry(self): - _, context = self._foundry_iq_context(WORK_IQ_ENABLED="true") - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "workIQ") - - def test_work_iq_enabled_adds_workiq_knowledge_source_and_kb_reference(self): - _, context = self._foundry_iq_context( - WORK_IQ_ENABLED="true", - WORK_IQ_KNOWLEDGE_SOURCE_NAME="work-iq-ks", - ) - search_definitions = render_json_template("search.j2", context) - - work_iq_sources = [ - ks for ks in search_definitions["knowledgeSources"] if ks["kind"] == "workIQ" - ] - self.assertEqual(len(work_iq_sources), 1) - work_iq = work_iq_sources[0] - self.assertEqual(work_iq["name"], "work-iq-ks") - self.assertEqual(work_iq["kind"], "workIQ") - self.assertIsNone(work_iq["encryptionKey"]) - # Work IQ is service-managed. It must not carry a filterAddOn, blob - # parameters, or search-index parameters. - self.assertNotIn("filterAddOn", work_iq) - self.assertNotIn("azureBlobParameters", work_iq) - self.assertNotIn("searchIndexParameters", work_iq) - - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertIn("work-iq-ks", kb_source_names) - - def test_work_iq_not_added_when_retrieval_backend_is_ai_search(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "WORK_IQ_ENABLED": "true", - "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", - }, - ) - context = { - **settings, - "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", - "EMBEDDING_MODEL_INFO": { - "endpoint": "https://aif-abc123.openai.azure.com/", - "deployment_name": "text-embedding", - "model_name": "text-embedding-3-large", - }, - "GPT_MODEL_INFO": { - "deployment_name": "chat", - "model_name": "gpt-5-nano", - }, - } - search_definitions = render_json_template("search.j2", context) - # ai_search backend never emits knowledge sources at all. - self.assertEqual(search_definitions["knowledgeSources"], []) - self.assertEqual(search_definitions["knowledgeBases"], []) - - def test_settings_defaults_fabric_iq_disabled_and_empty(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["FABRIC_IQ_ENABLED"], "false") - self.assertEqual(settings["FABRIC_IQ_KNOWLEDGE_SOURCE_NAME"], "") - self.assertEqual(settings["FABRIC_IQ_WORKSPACE_ID"], "") - self.assertEqual(settings["FABRIC_IQ_ONTOLOGY_ID"], "") - - def test_fabric_iq_disabled_by_default_produces_no_fabric_entry(self): - _, context = self._foundry_iq_context() - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "fabricOntology") - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertNotIn("fabric-iq-ks", kb_source_names) - - def test_fabric_iq_enabled_without_binding_fields_produces_no_entry(self): - # Enabled + name but missing workspace and ontology ids must not emit. - _, context = self._foundry_iq_context( - FABRIC_IQ_ENABLED="true", - FABRIC_IQ_KNOWLEDGE_SOURCE_NAME="fabric-iq-ks", - ) - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "fabricOntology") - - def test_fabric_iq_enabled_adds_fabric_knowledge_source_and_kb_reference(self): - _, context = self._foundry_iq_context( - FABRIC_IQ_ENABLED="true", - FABRIC_IQ_KNOWLEDGE_SOURCE_NAME="fabric-iq-ks", - FABRIC_IQ_WORKSPACE_ID="ws-guid-1", - FABRIC_IQ_ONTOLOGY_ID="ont-guid-1", - ) - search_definitions = render_json_template("search.j2", context) - - fabric_sources = [ - ks - for ks in search_definitions["knowledgeSources"] - if ks["kind"] == "fabricOntology" - ] - self.assertEqual(len(fabric_sources), 1) - fabric = fabric_sources[0] - self.assertEqual(fabric["name"], "fabric-iq-ks") - self.assertEqual(fabric["kind"], "fabricOntology") - self.assertIsNone(fabric["encryptionKey"]) - # Fabric IQ is service-managed. It must not carry a filterAddOn. - self.assertNotIn("filterAddOn", fabric) - self.assertEqual( - fabric["fabricOntologyParameters"], - {"workspaceId": "ws-guid-1", "ontologyId": "ont-guid-1"}, - ) - - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertIn("fabric-iq-ks", kb_source_names) - - def test_settings_defaults_fabric_data_agent_disabled_and_empty(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["FABRIC_DATA_AGENT_ENABLED"], "false") - self.assertEqual(settings["FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME"], "") - self.assertEqual(settings["FABRIC_DATA_AGENT_WORKSPACE_ID"], "") - self.assertEqual(settings["FABRIC_DATA_AGENT_DATA_AGENT_ID"], "") - - def test_fabric_data_agent_disabled_by_default_produces_no_entry(self): - _, context = self._foundry_iq_context() - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "fabricDataAgent") - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertNotIn("fabric-data-agent-ks", kb_source_names) - - def test_fabric_data_agent_enabled_without_binding_fields_produces_no_entry(self): - # Enabled + name but missing workspace and data agent ids must not emit. - _, context = self._foundry_iq_context( - FABRIC_DATA_AGENT_ENABLED="true", - FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME="fabric-data-agent-ks", - ) - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "fabricDataAgent") - - def test_fabric_data_agent_enabled_adds_knowledge_source_and_kb_reference(self): - _, context = self._foundry_iq_context( - FABRIC_DATA_AGENT_ENABLED="true", - FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME="fabric-data-agent-ks", - FABRIC_DATA_AGENT_WORKSPACE_ID="ws-guid-2", - FABRIC_DATA_AGENT_DATA_AGENT_ID="da-guid-2", - ) - search_definitions = render_json_template("search.j2", context) - - agent_sources = [ - ks - for ks in search_definitions["knowledgeSources"] - if ks["kind"] == "fabricDataAgent" - ] - self.assertEqual(len(agent_sources), 1) - agent = agent_sources[0] - self.assertEqual(agent["name"], "fabric-data-agent-ks") - self.assertEqual(agent["kind"], "fabricDataAgent") - self.assertIsNone(agent["encryptionKey"]) - # Fabric Data Agent is service-managed. It must not carry a filterAddOn. - self.assertNotIn("filterAddOn", agent) - self.assertEqual( - agent["fabricDataAgentParameters"], - {"workspaceId": "ws-guid-2", "dataAgentId": "da-guid-2"}, - ) - - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertIn("fabric-data-agent-ks", kb_source_names) - - def test_settings_defaults_web_grounding_disabled_and_empty(self): - settings = render_json_template( - "search.settings.j2", - { - "RESOURCE_TOKEN": "abc123", - "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", - "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", - "RETRIEVAL_BACKEND": "foundry_iq", - }, - ) - self.assertEqual(settings["WEB_GROUNDING_ENABLED"], "false") - self.assertEqual(settings["WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME"], "") - self.assertEqual(settings["WEB_GROUNDING_ALLOWED_DOMAINS"], "") - self.assertEqual(settings["WEB_GROUNDING_BLOCKED_DOMAINS"], "") - - def test_web_grounding_disabled_by_default_produces_no_entry(self): - _, context = self._foundry_iq_context() - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "web") - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertNotIn("web-ks", kb_source_names) - - def test_web_grounding_enabled_without_name_produces_no_entry(self): - _, context = self._foundry_iq_context( - WEB_GROUNDING_ENABLED="true", - WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="", - ) - search_definitions = render_json_template("search.j2", context) - for ks in search_definitions["knowledgeSources"]: - self.assertNotEqual(ks["kind"], "web") - - def test_web_grounding_enabled_adds_knowledge_source_and_kb_reference(self): - _, context = self._foundry_iq_context( - WEB_GROUNDING_ENABLED="true", - WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", - WEB_GROUNDING_ALLOWED_DOMAINS="Learn.Microsoft.com, azure.microsoft.com", - WEB_GROUNDING_BLOCKED_DOMAINS="example.com", - ) - search_definitions = render_json_template("search.j2", context) - - web_sources = [ - ks - for ks in search_definitions["knowledgeSources"] - if ks["kind"] == "web" - ] - self.assertEqual(len(web_sources), 1) - web = web_sources[0] - self.assertEqual(web["name"], "web-ks") - self.assertEqual(web["kind"], "web") - self.assertIsNone(web["encryptionKey"]) - # Public data - no ACL, no filterAddOn. - self.assertNotIn("filterAddOn", web) - self.assertEqual( - web["webParameters"], - { - "domains": { - "allowedDomains": ["learn.microsoft.com", "azure.microsoft.com"], - "blockedDomains": ["example.com"], - } - }, - ) - - kb_source_names = [ - s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] - ] - self.assertIn("web-ks", kb_source_names) - - def test_web_grounding_enabled_without_domains_emits_empty_lists(self): - _, context = self._foundry_iq_context( - WEB_GROUNDING_ENABLED="true", - WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", - ) - search_definitions = render_json_template("search.j2", context) - web_sources = [ - ks - for ks in search_definitions["knowledgeSources"] - if ks["kind"] == "web" - ] - self.assertEqual(len(web_sources), 1) - self.assertEqual( - web_sources[0]["webParameters"], - {"domains": {"allowedDomains": [], "blockedDomains": []}}, - ) - - -class WorkIqPreflightTests(unittest.TestCase): - def test_filter_work_iq_sources_no_op_when_disabled(self): - defs = { - "knowledgeSources": [ - {"name": "blob-ks", "kind": "azureBlob"}, - ], - "knowledgeBases": [ - {"name": "kb", "knowledgeSources": [{"name": "blob-ks"}]}, - ], - } - setup.filter_work_iq_sources( - defs, - {"RETRIEVAL_BACKEND": "foundry_iq", "WORK_IQ_ENABLED": "false"}, - Mock(), - ) - self.assertEqual(defs["knowledgeSources"], [{"name": "blob-ks", "kind": "azureBlob"}]) - self.assertEqual(defs["knowledgeBases"][0]["knowledgeSources"], [{"name": "blob-ks"}]) - - def test_filter_work_iq_sources_removes_source_when_not_consented(self): - defs = { - "knowledgeSources": [ - {"name": "blob-ks", "kind": "azureBlob"}, - {"name": "work-iq-ks", "kind": "workIQ"}, - ], - "knowledgeBases": [ - { - "name": "kb", - "knowledgeSources": [{"name": "blob-ks"}, {"name": "work-iq-ks"}], - } - ], - } - credential = Mock() - with patch.object(setup, "check_work_iq_admin_consent", return_value=False): - setup.filter_work_iq_sources( - defs, - { - "RETRIEVAL_BACKEND": "foundry_iq", - "WORK_IQ_ENABLED": "true", - "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", - }, - credential, - ) - self.assertEqual([ks["name"] for ks in defs["knowledgeSources"]], ["blob-ks"]) - self.assertEqual( - [ref["name"] for ref in defs["knowledgeBases"][0]["knowledgeSources"]], - ["blob-ks"], - ) - - def test_filter_work_iq_sources_keeps_source_when_consented(self): - defs = { - "knowledgeSources": [ - {"name": "work-iq-ks", "kind": "workIQ"}, - ], - "knowledgeBases": [ - {"name": "kb", "knowledgeSources": [{"name": "work-iq-ks"}]} - ], - } - with patch.object(setup, "check_work_iq_admin_consent", return_value=True): - setup.filter_work_iq_sources( - defs, - { - "RETRIEVAL_BACKEND": "foundry_iq", - "WORK_IQ_ENABLED": "true", - "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", - }, - Mock(), - ) - self.assertEqual([ks["name"] for ks in defs["knowledgeSources"]], ["work-iq-ks"]) - - def test_filter_work_iq_sources_removes_source_when_preflight_inconclusive(self): - defs = { - "knowledgeSources": [{"name": "work-iq-ks", "kind": "workIQ"}], - "knowledgeBases": [ - {"name": "kb", "knowledgeSources": [{"name": "work-iq-ks"}]} - ], - } - with patch.object(setup, "check_work_iq_admin_consent", return_value=None): - setup.filter_work_iq_sources( - defs, - { - "RETRIEVAL_BACKEND": "foundry_iq", - "WORK_IQ_ENABLED": "true", - "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", - }, - Mock(), - ) - self.assertEqual(defs["knowledgeSources"], []) - self.assertEqual(defs["knowledgeBases"][0]["knowledgeSources"], []) - - -if __name__ == "__main__": - unittest.main() +import inspect +import io +import itertools +import json +import os +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest.mock import Mock, patch + +from jinja2 import Environment, FileSystemLoader, StrictUndefined + +from config.search import setup +from config.search import foundry_iq_mcp_setup as mcp_setup + + +TEMPLATE_DIR = Path(__file__).resolve().parents[1] +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" +MCP_RUNTIME_CONTRACT_VERSION = "v3.7.0" + + +def render_json_template(template_name, context): + env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + undefined=StrictUndefined, + keep_trailing_newline=True, + ) + rendered = env.get_template(template_name).render(**context) + return json.loads(rendered) + + +class FoundryIqTemplateTests(unittest.TestCase): + def test_settings_template_derives_search_index_before_dependent_defaults(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_PROJECT_ENDPOINT": "https://aif-abc123.services.ai.azure.com/api/projects/proj", + }, + ) + + self.assertEqual(settings["SEARCH_RAG_INDEX_NAME"], "ragindex-abc123") + self.assertEqual(settings["KNOWLEDGE_BASE_NAME"], "ragindex-abc123-rag-kb") + self.assertEqual(settings["FOUNDRY_IQ_KNOWLEDGE_SOURCE_NAME"], "ragindex-abc123-blob-ks") + self.assertEqual( + settings["FOUNDRY_IQ_AI_SERVICES_ENDPOINT"], + "https://aif-abc123.services.ai.azure.com/", + ) + + def test_standard_blob_knowledge_source_includes_ai_services_endpoint_without_unsupported_chat_model(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_CONTENT_EXTRACTION_MODE": "standard", + }, + ) + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + search_definitions = render_json_template("search.j2", context) + knowledge_source = search_definitions["knowledgeSources"][0] + ingestion_parameters = knowledge_source["azureBlobParameters"]["ingestionParameters"] + + self.assertEqual(ingestion_parameters["contentExtractionMode"], "standard") + self.assertEqual( + ingestion_parameters["aiServices"]["uri"], + "https://aif-abc123.services.ai.azure.com/", + ) + self.assertIsNone(ingestion_parameters["chatCompletionModel"]) + + def test_standard_blob_knowledge_source_includes_supported_chat_model(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_CONTENT_EXTRACTION_MODE": "standard", + }, + ) + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5.2", + }, + } + + search_definitions = render_json_template("search.j2", context) + knowledge_source = search_definitions["knowledgeSources"][0] + ingestion_parameters = knowledge_source["azureBlobParameters"]["ingestionParameters"] + + self.assertEqual( + ingestion_parameters["chatCompletionModel"]["azureOpenAIParameters"]["resourceUri"], + ingestion_parameters["aiServices"]["uri"], + ) + self.assertEqual( + ingestion_parameters["chatCompletionModel"]["azureOpenAIParameters"]["modelName"], + "gpt-5.2", + ) + + def test_network_isolated_blob_knowledge_source_sets_generated_indexer_private(self): + credential = Mock() + credential.get_token.return_value.token = "token" + get_response = Mock() + get_response.status_code = 200 + get_response.json.return_value = { + "@odata.context": "https://search/$metadata#indexers/$entity", + "name": "blob-ks-indexer", + "dataSourceName": "blob-ks-datasource", + "targetIndexName": "blob-ks-index", + "parameters": { + "configuration": { + "dataToExtract": "contentAndMetadata", + } + }, + } + put_response = Mock() + put_response.status_code = 200 + + with patch.object(setup.requests, "get", return_value=get_response), patch.object( + setup.requests, "put", return_value=put_response + ) as put: + setup.enforce_private_execution_for_generated_indexers( + { + "knowledgeSources": [ + { + "name": "blob-ks", + "kind": "azureBlob", + "azureBlobParameters": {}, + } + ] + }, + {"NETWORK_ISOLATION": "true"}, + credential, + "https://search.search.windows.net", + "2025-05-01-preview", + ) + + body = put.call_args.kwargs["json"] + self.assertNotIn("@odata.context", body) + self.assertEqual(body["parameters"]["configuration"]["executionEnvironment"], "Private") + + def test_non_network_isolated_setup_does_not_update_generated_indexer(self): + credential = Mock() + + with patch.object(setup.requests, "get") as get: + setup.enforce_private_execution_for_generated_indexers( + { + "knowledgeSources": [ + { + "name": "blob-ks", + "kind": "azureBlob", + "azureBlobParameters": {}, + } + ] + }, + {"NETWORK_ISOLATION": "false"}, + credential, + "https://search.search.windows.net", + "2025-05-01-preview", + ) + + get.assert_not_called() + + + def test_conversation_upload_disabled_by_default_keeps_single_knowledge_source(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED"], "false") + self.assertEqual( + settings["FOUNDRY_IQ_CONVERSATION_KNOWLEDGE_SOURCE_NAME"], + "ragindex-abc123-conv-ks", + ) + + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + search_definitions = render_json_template("search.j2", context) + self.assertEqual(len(search_definitions["knowledgeSources"]), 1) + self.assertEqual( + search_definitions["knowledgeSources"][0]["kind"], "azureBlob" + ) + self.assertEqual( + len(search_definitions["knowledgeBases"][0]["knowledgeSources"]), 1 + ) + + def test_conversation_upload_enabled_adds_conversational_search_index_source(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", + }, + ) + self.assertEqual(settings["FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED"], "true") + + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + search_definitions = render_json_template("search.j2", context) + knowledge_sources = search_definitions["knowledgeSources"] + self.assertEqual(len(knowledge_sources), 2) + self.assertEqual(knowledge_sources[0]["kind"], "azureBlob") + + conv_source = knowledge_sources[1] + self.assertEqual(conv_source["name"], "ragindex-abc123-conv-ks") + self.assertEqual(conv_source["kind"], "searchIndex") + self.assertEqual( + conv_source["searchIndexParameters"]["searchIndexName"], + "ragindex-abc123", + ) + self.assertEqual( + conv_source["searchIndexParameters"]["semanticConfigurationName"], + "semantic-config", + ) + + kb_sources = search_definitions["knowledgeBases"][0]["knowledgeSources"] + self.assertEqual( + [s["name"] for s in kb_sources], + ["ragindex-abc123-blob-ks", "ragindex-abc123-conv-ks"], + ) + + def test_conversation_upload_not_added_for_search_index_pattern(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_PATTERN": "searchIndex", + "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", + }, + ) + + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + search_definitions = render_json_template("search.j2", context) + self.assertEqual(len(search_definitions["knowledgeSources"]), 1) + self.assertEqual( + search_definitions["knowledgeSources"][0]["kind"], "searchIndex" + ) + self.assertEqual( + len(search_definitions["knowledgeBases"][0]["knowledgeSources"]), 1 + ) + + +class WorkIqTemplateTests(unittest.TestCase): + """Work IQ is opt-in and default-off. Rendered output must be byte-identical + to the pre-Work IQ template when the feature is disabled. + """ + + def _foundry_iq_context(self, **overrides): + settings_input = { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + } + settings_input.update(overrides) + settings = render_json_template("search.settings.j2", settings_input) + return settings, { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + def test_settings_defaults_work_iq_disabled_and_empty_name(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["WORK_IQ_ENABLED"], "false") + self.assertEqual(settings["WORK_IQ_KNOWLEDGE_SOURCE_NAME"], "") + + def test_work_iq_disabled_by_default_produces_no_workiq_entry(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "workIQ") + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertNotIn("work-iq-ks", kb_source_names) + + def test_work_iq_enabled_without_name_produces_no_workiq_entry(self): + _, context = self._foundry_iq_context(WORK_IQ_ENABLED="true") + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "workIQ") + + def test_work_iq_enabled_adds_workiq_knowledge_source_and_kb_reference(self): + _, context = self._foundry_iq_context( + WORK_IQ_ENABLED="true", + WORK_IQ_KNOWLEDGE_SOURCE_NAME="work-iq-ks", + ) + search_definitions = render_json_template("search.j2", context) + + work_iq_sources = [ + ks for ks in search_definitions["knowledgeSources"] if ks["kind"] == "workIQ" + ] + self.assertEqual(len(work_iq_sources), 1) + work_iq = work_iq_sources[0] + self.assertEqual(work_iq["name"], "work-iq-ks") + self.assertEqual(work_iq["kind"], "workIQ") + self.assertIsNone(work_iq["encryptionKey"]) + # Work IQ is service-managed. It must not carry a filterAddOn, blob + # parameters, or search-index parameters. + self.assertNotIn("filterAddOn", work_iq) + self.assertNotIn("azureBlobParameters", work_iq) + self.assertNotIn("searchIndexParameters", work_iq) + + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertIn("work-iq-ks", kb_source_names) + + def test_work_iq_not_added_when_retrieval_backend_is_ai_search(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", + }, + ) + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + search_definitions = render_json_template("search.j2", context) + # ai_search backend never emits knowledge sources at all. + self.assertEqual(search_definitions["knowledgeSources"], []) + self.assertEqual(search_definitions["knowledgeBases"], []) + + def test_settings_defaults_fabric_iq_disabled_and_empty(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["FABRIC_IQ_ENABLED"], "false") + self.assertEqual(settings["FABRIC_IQ_KNOWLEDGE_SOURCE_NAME"], "") + self.assertEqual(settings["FABRIC_IQ_WORKSPACE_ID"], "") + self.assertEqual(settings["FABRIC_IQ_ONTOLOGY_ID"], "") + + def test_fabric_iq_disabled_by_default_produces_no_fabric_entry(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "fabricOntology") + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertNotIn("fabric-iq-ks", kb_source_names) + + def test_fabric_iq_enabled_without_binding_fields_produces_no_entry(self): + # Enabled + name but missing workspace and ontology ids must not emit. + _, context = self._foundry_iq_context( + FABRIC_IQ_ENABLED="true", + FABRIC_IQ_KNOWLEDGE_SOURCE_NAME="fabric-iq-ks", + ) + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "fabricOntology") + + def test_fabric_iq_enabled_adds_fabric_knowledge_source_and_kb_reference(self): + _, context = self._foundry_iq_context( + FABRIC_IQ_ENABLED="true", + FABRIC_IQ_KNOWLEDGE_SOURCE_NAME="fabric-iq-ks", + FABRIC_IQ_WORKSPACE_ID="ws-guid-1", + FABRIC_IQ_ONTOLOGY_ID="ont-guid-1", + ) + search_definitions = render_json_template("search.j2", context) + + fabric_sources = [ + ks + for ks in search_definitions["knowledgeSources"] + if ks["kind"] == "fabricOntology" + ] + self.assertEqual(len(fabric_sources), 1) + fabric = fabric_sources[0] + self.assertEqual(fabric["name"], "fabric-iq-ks") + self.assertEqual(fabric["kind"], "fabricOntology") + self.assertIsNone(fabric["encryptionKey"]) + # Fabric IQ is service-managed. It must not carry a filterAddOn. + self.assertNotIn("filterAddOn", fabric) + self.assertEqual( + fabric["fabricOntologyParameters"], + {"workspaceId": "ws-guid-1", "ontologyId": "ont-guid-1"}, + ) + + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertIn("fabric-iq-ks", kb_source_names) + + def test_settings_defaults_fabric_data_agent_disabled_and_empty(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["FABRIC_DATA_AGENT_ENABLED"], "false") + self.assertEqual(settings["FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME"], "") + self.assertEqual(settings["FABRIC_DATA_AGENT_WORKSPACE_ID"], "") + self.assertEqual(settings["FABRIC_DATA_AGENT_DATA_AGENT_ID"], "") + + def test_fabric_data_agent_disabled_by_default_produces_no_entry(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "fabricDataAgent") + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertNotIn("fabric-data-agent-ks", kb_source_names) + + def test_fabric_data_agent_enabled_without_binding_fields_produces_no_entry(self): + # Enabled + name but missing workspace and data agent ids must not emit. + _, context = self._foundry_iq_context( + FABRIC_DATA_AGENT_ENABLED="true", + FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME="fabric-data-agent-ks", + ) + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "fabricDataAgent") + + def test_fabric_data_agent_enabled_adds_knowledge_source_and_kb_reference(self): + _, context = self._foundry_iq_context( + FABRIC_DATA_AGENT_ENABLED="true", + FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME="fabric-data-agent-ks", + FABRIC_DATA_AGENT_WORKSPACE_ID="ws-guid-2", + FABRIC_DATA_AGENT_DATA_AGENT_ID="da-guid-2", + ) + search_definitions = render_json_template("search.j2", context) + + agent_sources = [ + ks + for ks in search_definitions["knowledgeSources"] + if ks["kind"] == "fabricDataAgent" + ] + self.assertEqual(len(agent_sources), 1) + agent = agent_sources[0] + self.assertEqual(agent["name"], "fabric-data-agent-ks") + self.assertEqual(agent["kind"], "fabricDataAgent") + self.assertIsNone(agent["encryptionKey"]) + # Fabric Data Agent is service-managed. It must not carry a filterAddOn. + self.assertNotIn("filterAddOn", agent) + self.assertEqual( + agent["fabricDataAgentParameters"], + {"workspaceId": "ws-guid-2", "dataAgentId": "da-guid-2"}, + ) + + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertIn("fabric-data-agent-ks", kb_source_names) + + def test_settings_defaults_web_grounding_disabled_and_empty(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["WEB_GROUNDING_ENABLED"], "false") + self.assertEqual(settings["WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME"], "") + self.assertEqual(settings["WEB_GROUNDING_ALLOWED_DOMAINS"], "") + self.assertEqual(settings["WEB_GROUNDING_BLOCKED_DOMAINS"], "") + + def test_web_grounding_disabled_by_default_produces_no_entry(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "web") + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertNotIn("web-ks", kb_source_names) + + def test_web_grounding_enabled_without_name_produces_no_entry(self): + _, context = self._foundry_iq_context( + WEB_GROUNDING_ENABLED="true", + WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="", + ) + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "web") + + def test_web_grounding_enabled_adds_knowledge_source_and_kb_reference(self): + _, context = self._foundry_iq_context( + WEB_GROUNDING_ENABLED="true", + WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", + WEB_GROUNDING_ALLOWED_DOMAINS="Learn.Microsoft.com, azure.microsoft.com", + WEB_GROUNDING_BLOCKED_DOMAINS="example.com", + ) + search_definitions = render_json_template("search.j2", context) + + web_sources = [ + ks + for ks in search_definitions["knowledgeSources"] + if ks["kind"] == "web" + ] + self.assertEqual(len(web_sources), 1) + web = web_sources[0] + self.assertEqual(web["name"], "web-ks") + self.assertEqual(web["kind"], "web") + self.assertIsNone(web["encryptionKey"]) + # Public data - no ACL, no filterAddOn. + self.assertNotIn("filterAddOn", web) + self.assertEqual( + web["webParameters"], + { + "domains": { + "allowedDomains": ["learn.microsoft.com", "azure.microsoft.com"], + "blockedDomains": ["example.com"], + } + }, + ) + + kb_source_names = [ + s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"] + ] + self.assertIn("web-ks", kb_source_names) + + def test_web_grounding_enabled_without_domains_emits_empty_lists(self): + _, context = self._foundry_iq_context( + WEB_GROUNDING_ENABLED="true", + WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", + ) + search_definitions = render_json_template("search.j2", context) + web_sources = [ + ks + for ks in search_definitions["knowledgeSources"] + if ks["kind"] == "web" + ] + self.assertEqual(len(web_sources), 1) + self.assertEqual( + web_sources[0]["webParameters"], + {"domains": {"allowedDomains": [], "blockedDomains": []}}, + ) + + +class FoundryIqMcpTemplateTests(unittest.TestCase): + """Generic MCP Server knowledge sources are opt-in and default-off. + Rendered output must be unchanged when the feature is disabled. + """ + + def _foundry_iq_context(self, **overrides): + settings_input = { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + } + settings_input.update(overrides) + settings = render_json_template("search.settings.j2", settings_input) + return settings, { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5-nano", + }, + } + + def _mcp_source(self, **overrides): + source = { + "name": "monitor-mcp-ks", + "description": "Azure Monitor MCP", + "serverURL": "https://monitor-mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 4096, + } + ], + } + source.update(overrides) + return source + + def _query_headers(self): + return [ + { + "name": "Authorization", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + }, + }, + { + "name": "x-user-token", + "valueFrom": { + "kind": "obo", + "scope": "api://monitor/user_impersonation", + }, + }, + { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "secretName": "monitor-api-key", + }, + }, + {"name": "x-no-auth", "valueFrom": {"kind": "none"}}, + ] + + def test_settings_defaults_mcp_disabled_and_empty(self): + settings = render_json_template( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + self.assertEqual(settings["FOUNDRY_IQ_MCP_ENABLED"], "false") + self.assertEqual(settings["FOUNDRY_IQ_MCP_SOURCES_JSON"], []) + self.assertEqual(settings["FOUNDRY_IQ_MCP_REASONING_EFFORT"], "low") + self.assertEqual(settings["FOUNDRY_IQ_MCP_TRUSTED_HOSTS"], "") + self.assertEqual(settings["FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS"], "false") + + def test_mcp_disabled_by_default_produces_no_entry_and_preserves_minimal_reasoning(self): + _, context = self._foundry_iq_context() + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "mcpServer") + kb = search_definitions["knowledgeBases"][0] + self.assertNotIn("monitor-mcp-ks", [s["name"] for s in kb["knowledgeSources"]]) + self.assertEqual(kb["models"], []) + self.assertEqual(kb["retrievalReasoningEffort"], {"kind": "minimal"}) + + def test_mcp_enabled_without_sources_produces_no_entry(self): + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + search_definitions = render_json_template("search.j2", context) + for ks in search_definitions["knowledgeSources"]: + self.assertNotEqual(ks["kind"], "mcpServer") + kb = search_definitions["knowledgeBases"][0] + self.assertEqual(kb["models"], []) + self.assertEqual(kb["retrievalReasoningEffort"], {"kind": "minimal"}) + + def test_mcp_enabled_registers_source_with_kb_planning_model_and_reasoning(self): + _, context = self._foundry_iq_context( + FOUNDRY_IQ_MCP_ENABLED="true", + FOUNDRY_IQ_MCP_REASONING_EFFORT="medium", + ) + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [ + self._mcp_source( + failOnError=False, + maxOutputDocuments=25, + queryHeaders=self._query_headers(), + ) + ] + search_definitions = render_json_template("search.j2", context) + + mcp_sources = [ks for ks in search_definitions["knowledgeSources"] if ks["kind"] == "mcpServer"] + self.assertEqual(len(mcp_sources), 1) + source = mcp_sources[0] + self.assertEqual(source["name"], "monitor-mcp-ks") + self.assertIsNone(source["encryptionKey"]) + self.assertEqual( + source["mcpServerParameters"], + { + "serverURL": "https://monitor-mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 4096, + } + ], + }, + ) + # Runtime query-header metadata must never be forwarded into Search + # registration or knowledge-base retrieve parameters. + self.assertNotIn("queryHeaders", json.dumps(source)) + self.assertNotIn("queryHeaders", json.dumps(search_definitions["knowledgeBases"])) + self.assertNotIn("auth", source["mcpServerParameters"]) + self.assertNotIn("authentication", source["mcpServerParameters"]) + self.assertNotIn("authIdentity", source["mcpServerParameters"]) + + kb = search_definitions["knowledgeBases"][0] + self.assertIn("monitor-mcp-ks", [s["name"] for s in kb["knowledgeSources"]]) + # Never emit alwaysQuerySource for MCP knowledge source references. + mcp_ref = next(s for s in kb["knowledgeSources"] if s["name"] == "monitor-mcp-ks") + self.assertEqual(mcp_ref, {"name": "monitor-mcp-ks"}) + + self.assertEqual( + kb["models"], + [ + { + "kind": "azureOpenAI", + "azureOpenAIParameters": { + "resourceUri": context["FOUNDRY_IQ_AI_SERVICES_ENDPOINT"], + "deploymentId": "chat", + "modelName": "gpt-5-nano", + }, + } + ], + ) + self.assertEqual(kb["retrievalReasoningEffort"], {"kind": "medium"}) + + def test_mcp_tool_output_parsing_kinds_render_as_official_rest_shapes(self): + """auto/none/split render as {"kind": "..."}; json nests documentsPath + under jsonParameters, matching Search API 2026-05-01-preview + (McpServerOutputParsing / McpServerJsonOutputParsing).""" + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [ + self._mcp_source( + tools=[ + { + "name": "auto_tool", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 1024, + }, + { + "name": "none_tool", + "outputParsing": {"kind": "none"}, + "inclusionMode": "reranked", + "maxOutputTokens": 1024, + }, + { + "name": "split_tool", + "outputParsing": { + "kind": "split", + "splitParameters": { + "textSplitMode": "pages", + "maximumPageLength": 4000, + "pageOverlapLength": 500, + "maximumPagesToTake": 10, + "defaultLanguageCode": "en", + }, + }, + "inclusionMode": "reranked", + "maxOutputTokens": 1024, + }, + { + "name": "json_tool", + "outputParsing": { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results", + "includeContext": True, + }, + }, + "inclusionMode": "always", + "maxOutputTokens": 2048, + }, + ] + ) + ] + search_definitions = render_json_template("search.j2", context) + tools = search_definitions["knowledgeSources"][-1]["mcpServerParameters"]["tools"] + rendered = {tool["name"]: tool for tool in tools} + + self.assertEqual(rendered["auto_tool"]["outputParsing"], {"kind": "auto"}) + self.assertEqual(rendered["none_tool"]["outputParsing"], {"kind": "none"}) + self.assertEqual( + rendered["split_tool"]["outputParsing"], + { + "kind": "split", + "splitParameters": { + "textSplitMode": "pages", + "maximumPageLength": 4000, + "pageOverlapLength": 500, + "maximumPagesToTake": 10, + "defaultLanguageCode": "en", + }, + }, + ) + self.assertEqual( + rendered["json_tool"]["outputParsing"], + { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results", + "includeContext": True, + }, + }, + ) + # documentsPath must never be a tool-level sibling key. + for tool in tools: + self.assertNotIn("documentsPath", tool) + + def test_canonical_fixture_shared_with_runtime_validates_and_renders(self): + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + manifest = json.loads((REPO_ROOT / "manifest.json").read_text(encoding="utf-8")) + orchestrator = next( + component + for component in manifest["components"] + if component["name"] == "gpt-rag-orchestrator" + ) + self.assertEqual(orchestrator["tag"], MCP_RUNTIME_CONTRACT_VERSION) + + fixture_path = ( + FIXTURE_DIR + / f"foundry_iq_mcp_canonical_source_{MCP_RUNTIME_CONTRACT_VERSION.removeprefix('v').replace('.', '_')}.json" + ) + source = json.loads(fixture_path.read_text(encoding="utf-8")) + context["FOUNDRY_IQ_MCP_TRUSTED_HOSTS"] = "mcp.contoso.com" + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [source] + + mcp_setup.validate_foundry_iq_mcp_settings(context) + self.assertEqual(context["FOUNDRY_IQ_MCP_SOURCES_JSON"], [source]) + + search_definitions = render_json_template("search.j2", context) + rendered_source = next( + knowledge_source + for knowledge_source in search_definitions["knowledgeSources"] + if knowledge_source["name"] == source["name"] + ) + self.assertEqual( + rendered_source["mcpServerParameters"], + { + "serverURL": source["serverURL"], + "tools": source["tools"], + }, + ) + + def test_multiple_mcp_sources_all_registered_and_referenced(self): + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [ + self._mcp_source(name="monitor-mcp-ks", serverURL="https://monitor-mcp.contoso.com/mcp"), + self._mcp_source(name="billing-mcp-ks", serverURL="https://billing-mcp.contoso.com/mcp"), + ] + search_definitions = render_json_template("search.j2", context) + + mcp_names = [ + ks["name"] for ks in search_definitions["knowledgeSources"] if ks["kind"] == "mcpServer" + ] + self.assertEqual(mcp_names, ["monitor-mcp-ks", "billing-mcp-ks"]) + + kb_names = [s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"]] + self.assertIn("monitor-mcp-ks", kb_names) + self.assertIn("billing-mcp-ks", kb_names) + + def test_mcp_coexists_with_web_grounding_and_sharepoint_indexed(self): + _, context = self._foundry_iq_context( + FOUNDRY_IQ_MCP_ENABLED="true", + WEB_GROUNDING_ENABLED="true", + WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME="web-ks", + SHAREPOINT_INDEXED_ENABLED="true", + SHAREPOINT_INDEXED_KNOWLEDGE_SOURCE_NAME="sp-ks", + SHAREPOINT_INDEXED_INDEX_NAME="sp-index", + SHAREPOINT_INDEXED_SITE_URL="https://contoso.sharepoint.com/sites/eng", + SHAREPOINT_INDEXED_TENANT_ID="tenant-guid", + ) + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [self._mcp_source()] + search_definitions = render_json_template("search.j2", context) + + kinds = {ks["kind"] for ks in search_definitions["knowledgeSources"]} + self.assertTrue({"azureBlob", "web", "indexedSharePoint", "mcpServer"}.issubset(kinds)) + + kb_names = [s["name"] for s in search_definitions["knowledgeBases"][0]["knowledgeSources"]] + self.assertIn("web-ks", kb_names) + self.assertIn("sp-ks", kb_names) + self.assertIn("monitor-mcp-ks", kb_names) + + def test_rendering_is_deterministic(self): + _, context = self._foundry_iq_context(FOUNDRY_IQ_MCP_ENABLED="true") + context["FOUNDRY_IQ_MCP_SOURCES_JSON"] = [ + self._mcp_source(name="monitor-mcp-ks"), + self._mcp_source(name="billing-mcp-ks", serverURL="https://billing-mcp.contoso.com/mcp"), + ] + first = render_json_template("search.j2", context) + second = render_json_template("search.j2", context) + self.assertEqual(first, second) + self.assertEqual(json.dumps(first, sort_keys=True), json.dumps(second, sort_keys=True)) + + +class FoundryIqMcpValidationTests(unittest.TestCase): + """Unit tests for the fail-closed validation in foundry_iq_mcp_setup.""" + + def _context(self, sources=None, **overrides): + context = { + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_MCP_ENABLED": "true", + "FOUNDRY_IQ_MCP_SOURCES_JSON": sources if sources is not None else [], + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS": "monitor-mcp.contoso.com", + "FOUNDRY_IQ_MCP_REASONING_EFFORT": "low", + "GPT_MODEL_INFO": {"deployment_name": "chat", "model_name": "gpt-5-nano"}, + "FOUNDRY_IQ_AI_SERVICES_ENDPOINT": "https://aif-abc123.services.ai.azure.com/", + } + context.update(overrides) + return context + + def _source(self, **overrides): + source = { + "name": "monitor-mcp-ks", + "serverURL": "https://monitor-mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 4096, + } + ], + } + source.update(overrides) + return source + + def _query_headers(self): + return [ + { + "name": "Authorization", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + }, + }, + { + "name": "x-user-token", + "valueFrom": { + "kind": "obo", + "scope": "api://monitor/user_impersonation", + }, + }, + { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "secretName": "monitor-api-key", + }, + }, + {"name": "x-no-auth", "valueFrom": {"kind": "none"}}, + ] + + def test_valid_query_header_metadata_is_preserved_in_canonical_json(self): + source = self._source(queryHeaders=self._query_headers()) + context = self._context(sources=[source]) + + canonical = mcp_setup.validate_and_get_mcp_sources(context) + + self.assertEqual(canonical[0]["queryHeaders"], self._query_headers()) + serialized = json.dumps(canonical) + self.assertNotIn("literal-secret", serialized) + self.assertNotIn('"value":', serialized) + + def test_query_headers_reject_literal_and_nested_credentials_without_echoing_values(self): + cases = { + "literal value": { + "name": "Authorization", + "value": "literal-secret-marker", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + }, + }, + "nested token": { + "name": "Authorization", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + "access_token": "literal-secret-marker", + }, + }, + "headers blob": { + "name": "x-custom", + "valueFrom": { + "kind": "none", + "storedHeaders": {"x-api-key": "literal-secret-marker"}, + }, + }, + "connection string": { + "name": "x-custom", + "valueFrom": { + "kind": "none", + "connection-string": "literal-secret-marker", + }, + }, + } + for name, query_header in cases.items(): + with self.subTest(name=name): + context = self._context( + sources=[self._source(queryHeaders=[query_header])] + ) + with self.assertRaises(ValueError) as exc_info: + mcp_setup.validate_and_get_mcp_sources(context) + self.assertIn("literal credentials", str(exc_info.exception)) + self.assertNotIn("literal-secret-marker", str(exc_info.exception)) + + def test_query_headers_reject_forbidden_names_and_invalid_value_from_shapes(self): + cases = { + "invalid token": { + "name": "bad header", + "valueFrom": {"kind": "none"}, + }, + "host": { + "name": "Host", + "valueFrom": {"kind": "none"}, + }, + "content length": { + "name": "Content-Length", + "valueFrom": {"kind": "none"}, + }, + "hop by hop": { + "name": "Connection", + "valueFrom": {"kind": "none"}, + }, + "managed identity missing scope": { + "name": "Authorization", + "valueFrom": {"kind": "managedIdentity"}, + }, + "obo with secretName": { + "name": "Authorization", + "valueFrom": { + "kind": "obo", + "scope": "api://monitor/user_impersonation", + "secretName": "not-applicable", + }, + }, + "key vault with scope": { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "scope": "api://not-applicable", + "secretName": "monitor-api-key", + }, + }, + "none with credential field": { + "name": "x-none", + "valueFrom": {"kind": "none", "scope": "api://not-applicable"}, + }, + "unknown nested field": { + "name": "x-custom", + "valueFrom": {"kind": "none", "issuer": "contoso"}, + }, + } + for name, query_header in cases.items(): + with self.subTest(name=name): + context = self._context( + sources=[self._source(queryHeaders=[query_header])] + ) + with self.assertRaises(ValueError): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_duplicate_query_header_names_are_rejected_case_insensitively(self): + headers = [ + {"name": "X-Custom", "valueFrom": {"kind": "none"}}, + {"name": "x-custom", "valueFrom": {"kind": "none"}}, + ] + context = self._context(sources=[self._source(queryHeaders=headers)]) + with self.assertRaisesRegex(ValueError, "unique"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_disabled_is_a_no_op(self): + context = self._context(sources=[], FOUNDRY_IQ_MCP_ENABLED="false") + mcp_setup.validate_foundry_iq_mcp_settings(context) # must not raise + + def test_enabled_with_no_sources_raises(self): + context = self._context(sources=[]) + with self.assertRaisesRegex(ValueError, "no sources"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_missing_planning_model_raises(self): + context = self._context(sources=[self._source()], GPT_MODEL_INFO={}) + with self.assertRaisesRegex(ValueError, "planning model"): + mcp_setup.validate_foundry_iq_mcp_settings(context) + + def test_missing_ai_services_endpoint_raises(self): + context = self._context(sources=[self._source()], FOUNDRY_IQ_AI_SERVICES_ENDPOINT="") + with self.assertRaisesRegex(ValueError, "AI Services endpoint"): + mcp_setup.validate_foundry_iq_mcp_settings(context) + + def test_invalid_sources_json_type_raises(self): + context = self._context(sources={"not": "a list"}) + with self.assertRaisesRegex(ValueError, "JSON array"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_duplicate_source_names_raise(self): + context = self._context( + sources=[self._source(), self._source(name="MONITOR-MCP-KS")] + ) + with self.assertRaisesRegex(ValueError, "used more than once"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_source_name_raises_before_search_api_use(self): + context = self._context(sources=[self._source(name="../indexes/victim")]) + with self.assertRaisesRegex(ValueError, "must start with a letter or number"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_non_https_scheme_raises(self): + context = self._context(sources=[self._source(serverURL="http://monitor-mcp.contoso.com/mcp")]) + with self.assertRaisesRegex(ValueError, "https"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_malformed_port_raises(self): + context = self._context( + sources=[self._source(serverURL="https://monitor-mcp.contoso.com:notaport/mcp")] + ) + with self.assertRaisesRegex(ValueError, "not a valid URL"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_userinfo_in_url_raises(self): + context = self._context( + sources=[self._source(serverURL="https://user:pass@monitor-mcp.contoso.com/mcp")] + ) + with self.assertRaisesRegex(ValueError, "userinfo"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_fragment_in_url_raises(self): + context = self._context(sources=[self._source(serverURL="https://monitor-mcp.contoso.com/mcp#frag")]) + with self.assertRaisesRegex(ValueError, "fragment"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_query_string_in_url_raises(self): + for server_url in ( + "https://monitor-mcp.contoso.com/mcp?api-version=1", + "https://monitor-mcp.contoso.com/mcp?", + ): + with self.subTest(server_url=server_url): + context = self._context(sources=[self._source(serverURL=server_url)]) + with self.assertRaisesRegex(ValueError, "query string"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_source_scan_precedes_planning_model_validation(self): + context = self._context( + sources=[self._source(auth={"kind": "managedIdentity"})], + GPT_MODEL_INFO={}, + ) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_foundry_iq_mcp_settings(context) + + def test_disabled_stale_source_content_is_discarded_without_parsing(self): + context = self._context( + sources="{not valid JSON", + FOUNDRY_IQ_MCP_ENABLED="false", + ) + mcp_setup.validate_foundry_iq_mcp_settings(context) + self.assertEqual(context["FOUNDRY_IQ_MCP_SOURCES_JSON"], []) + + def test_ip_literal_host_raises(self): + context = self._context( + sources=[self._source(serverURL="https://203.0.113.10/mcp")], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="203.0.113.10", + ) + with self.assertRaisesRegex(ValueError, "IP literal"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_loopback_ip_literal_host_raises(self): + context = self._context( + sources=[self._source(serverURL="https://127.0.0.1/mcp")], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="127.0.0.1", + ) + with self.assertRaisesRegex(ValueError, "IP literal"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_localhost_host_raises(self): + context = self._context( + sources=[self._source(serverURL="https://localhost/mcp")], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="localhost", + ) + with self.assertRaisesRegex(ValueError, "local or reserved"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_reserved_or_single_label_host_raises(self): + for host in ( + "home.arpa", + "mcp.internal", + "mcp.example", + "mcp.corp", + "mcp.intranet", + "mcp.private", + "mcp.123", + "singlelabel", + ): + with self.subTest(host=host): + context = self._context( + sources=[self._source(serverURL=f"https://{host}/mcp")], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS=host, + ) + with self.assertRaisesRegex(ValueError, "local or reserved"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_host_not_in_trusted_allowlist_raises(self): + context = self._context( + sources=[self._source()], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="other-host.contoso.com", + ) + with self.assertRaisesRegex(ValueError, "not an exact match"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_empty_trusted_hosts_raises(self): + context = self._context(sources=[self._source()], FOUNDRY_IQ_MCP_TRUSTED_HOSTS="") + with self.assertRaisesRegex(ValueError, "TRUSTED_HOSTS is empty"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_trusted_hosts_accept_runtime_json_array_shape(self): + context = self._context( + sources=[self._source()], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS='["monitor-mcp.contoso.com"]', + ) + self.assertEqual( + mcp_setup.validate_and_get_mcp_sources(context)[0]["serverURL"], + "https://monitor-mcp.contoso.com/mcp", + ) + + def test_trusted_hosts_reject_non_hostname_entries(self): + for trusted_hosts in ( + "https://monitor-mcp.contoso.com", + "monitor-mcp.contoso.com/mcp", + "monitor-mcp.contoso.com:443", + ): + with self.subTest(trusted_hosts=trusted_hosts): + context = self._context( + sources=[self._source()], + FOUNDRY_IQ_MCP_TRUSTED_HOSTS=trusted_hosts, + ) + with self.assertRaisesRegex(ValueError, "hostnames only"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_no_tools_raises(self): + context = self._context(sources=[self._source(tools=[])]) + with self.assertRaisesRegex(ValueError, "non-empty array"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_duplicate_tool_names_raise(self): + tool = { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 100, + } + context = self._context( + sources=[ + self._source( + tools=[tool, {**tool, "name": tool["name"].upper()}] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "used more than once"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_inclusion_mode_raises(self): + context = self._context( + sources=[self._source(tools=[{**self._source()["tools"][0], "inclusionMode": "sometimes"}])] + ) + with self.assertRaisesRegex(ValueError, "inclusionMode"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_output_parsing_raises(self): + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": {"kind": "yaml"}, + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "outputParsing"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_legacy_string_output_parsing_is_rejected(self): + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": "auto", + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "JSON object"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_json_output_parsing_without_documents_path_raises(self): + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": {"kind": "json"}, + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "jsonParameters"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_documents_path_with_non_json_output_parsing_raises(self): + """documentsPath only has an effect (and is only rendered) nested + under outputParsing.jsonParameters when outputParsing is 'json'; + silently accepting it for other kinds would mislead operators.""" + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": { + "kind": "auto", + "jsonParameters": {"documentsPath": "$.results"}, + }, + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, "jsonParameters"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_documented_output_parsing_parameters_raise(self): + cases = [ + ( + "includeContext must be boolean", + { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results", + "includeContext": "true", + }, + }, + "includeContext", + ), + ( + "JSON parameter keys are strict", + { + "kind": "json", + "jsonParameters": { + "documentsPath": "$.results", + "unexpected": True, + }, + }, + "unexpected key", + ), + ( + "split parameters require split kind", + { + "kind": "auto", + "splitParameters": {"textSplitMode": "pages"}, + }, + "splitParameters", + ), + ( + "split parameter keys are strict", + { + "kind": "split", + "splitParameters": {"unit": "azureOpenAITokens"}, + }, + "unexpected key", + ), + ( + "split mode is constrained", + { + "kind": "split", + "splitParameters": {"textSplitMode": "paragraphs"}, + }, + "textSplitMode", + ), + ( + "split lengths must be integers", + { + "kind": "split", + "splitParameters": {"maximumPageLength": True}, + }, + "positive integer", + ), + ( + "split overlap must be smaller than page length", + { + "kind": "split", + "splitParameters": { + "maximumPageLength": 100, + "pageOverlapLength": 100, + }, + }, + "less than maximumPageLength", + ), + ] + + for label, output_parsing, expected_error in cases: + with self.subTest(label): + context = self._context( + sources=[ + self._source( + tools=[ + { + **self._source()["tools"][0], + "outputParsing": output_parsing, + } + ] + ) + ] + ) + with self.assertRaisesRegex(ValueError, expected_error): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_non_positive_max_output_tokens_raises(self): + context = self._context( + sources=[self._source(tools=[{**self._source()["tools"][0], "maxOutputTokens": 0}])] + ) + with self.assertRaisesRegex(ValueError, "positive integer"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_max_output_tokens_above_local_cap_raises(self): + context = self._context( + sources=[self._source(tools=[{**self._source()["tools"][0], "maxOutputTokens": 20000}])] + ) + with self.assertRaisesRegex(ValueError, "local cap"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_always_query_source_on_tool_raises(self): + context = self._context( + sources=[self._source(tools=[{**self._source()["tools"][0], "alwaysQuerySource": True}])] + ) + with self.assertRaisesRegex(ValueError, "alwaysQuerySource"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_always_query_source_on_source_raises(self): + context = self._context(sources=[self._source(alwaysQuerySource=True)]) + with self.assertRaisesRegex(ValueError, "alwaysQuerySource"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_foundry_connection_auth_raises(self): + context = self._context(sources=[self._source(auth={"kind": "foundryConnection"})]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_secret_like_auth_key_raises(self): + context = self._context(sources=[self._source(apiKey="not-a-real-secret")]) + with self.assertRaisesRegex(ValueError, "literal credentials"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_unsupported_auth_kind_raises(self): + context = self._context(sources=[self._source(auth={"kind": "oauth2"})]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_managed_identity_auth_is_rejected(self): + """auth is rejected outright, at any nesting depth, regardless of + kind -- it is never rendered/forwarded, so even a would-be-safe + {'kind': 'managedIdentity'} value must not be silently accepted.""" + context = self._context(sources=[self._source(auth={"kind": "managedIdentity"})]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_authentication_key_is_rejected(self): + context = self._context(sources=[self._source(authentication={"kind": "foundryConnection"})]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_auth_nested_inside_tool_is_rejected(self): + tool = {**self._source()["tools"][0], "auth": {"kind": "managedIdentity"}} + context = self._context(sources=[self._source(tools=[tool])]) + with self.assertRaisesRegex(ValueError, "not allowed"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_secret_like_key_nested_inside_tool_is_rejected(self): + tool = {**self._source()["tools"][0], "token": "not-a-real-token"} + context = self._context(sources=[self._source(tools=[tool])]) + with self.assertRaisesRegex(ValueError, "literal credentials"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_secret_like_key_two_levels_deep_is_rejected(self): + """The scan must recurse arbitrarily deep, not just one level.""" + tool = {**self._source()["tools"][0], "extra": {"nested": {"bearer": "not-a-real-token"}}} + context = self._context(sources=[self._source(tools=[tool])]) + with self.assertRaisesRegex(ValueError, "literal credentials"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_absent_auth_is_valid(self): + context = self._context(sources=[self._source()]) + sources = mcp_setup.validate_and_get_mcp_sources(context) + self.assertEqual(len(sources), 1) + + def test_unexpected_source_key_raises(self): + context = self._context(sources=[self._source(unexpectedKey="value")]) + with self.assertRaisesRegex(ValueError, "unexpected key"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_runtime_retrieval_controls_are_validated_and_preserved(self): + context = self._context( + sources=[ + self._source( + failOnError=False, + maxOutputDocuments=25, + ) + ] + ) + + sources = mcp_setup.validate_and_get_mcp_sources(context) + + self.assertFalse(sources[0]["failOnError"]) + self.assertEqual(sources[0]["maxOutputDocuments"], 25) + + def test_invalid_runtime_retrieval_controls_raise(self): + cases = [ + ({"failOnError": "false"}, "failOnError"), + ({"failOnError": None}, "failOnError"), + ({"maxOutputDocuments": True}, "maxOutputDocuments"), + ({"maxOutputDocuments": 0}, "maxOutputDocuments"), + ({"maxOutputDocuments": 51}, "maxOutputDocuments"), + ] + for overrides, expected_error in cases: + with self.subTest(overrides=overrides): + context = self._context(sources=[self._source(**overrides)]) + with self.assertRaisesRegex(ValueError, expected_error): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_legacy_server_url_casing_is_rejected(self): + source = self._source() + source["serverUrl"] = source.pop("serverURL") + context = self._context(sources=[source]) + with self.assertRaisesRegex(ValueError, "unexpected key"): + mcp_setup.validate_and_get_mcp_sources(context) + + def test_invalid_reasoning_effort_raises(self): + context = self._context(sources=[self._source()], FOUNDRY_IQ_MCP_REASONING_EFFORT="minimal") + with self.assertRaisesRegex(ValueError, "REASONING_EFFORT"): + mcp_setup.validate_and_get_mcp_sources(context) + + +class FoundryIqMcpPreflightCliTests(unittest.TestCase): + """Unit tests for the standalone pre-flight CLI entry point + (mcp_setup.main / build_preflight_context_from_environ). This is the + module scripts/postProvision.ps1 runs before it imports anything into + Azure App Configuration, so a rejected configuration here proves the + ordering bug (import happening before validation) cannot recur: a + non-zero exit here happens before any write.""" + + BASE_ENV = { + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_MCP_ENABLED": "true", + "FOUNDRY_IQ_MCP_TRUSTED_HOSTS": "monitor-mcp.contoso.com", + "FOUNDRY_IQ_MCP_REASONING_EFFORT": "low", + } + + def _sources_json(self, **overrides): + source = { + "name": "monitor-mcp-ks", + "serverURL": "https://monitor-mcp.contoso.com/mcp", + "tools": [ + { + "name": "query_logs", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 4096, + } + ], + } + source.update(overrides) + return json.dumps([source]) + + def _run_main(self, **env_overrides): + env = {**self.BASE_ENV, **env_overrides} + with patch.dict(os.environ, env, clear=False): + stderr = io.StringIO() + with redirect_stderr(stderr): + result = mcp_setup.main() + return result, stderr.getvalue() + + def test_disabled_by_default_returns_zero_without_error(self): + result, stderr = self._run_main(FOUNDRY_IQ_MCP_ENABLED="false", FOUNDRY_IQ_MCP_SOURCES_JSON="[]") + self.assertEqual(result, 0) + self.assertEqual(stderr, "") + + def test_disabled_malformed_json_returns_zero_without_parsing(self): + result, stderr = self._run_main( + FOUNDRY_IQ_MCP_ENABLED="false", + FOUNDRY_IQ_MCP_SOURCES_JSON="{not valid JSON", + ) + self.assertEqual(result, 0) + self.assertEqual(stderr, "") + + def test_disabled_credential_json_returns_zero_without_scanning(self): + result, stderr = self._run_main( + FOUNDRY_IQ_MCP_ENABLED="false", + FOUNDRY_IQ_MCP_SOURCES_JSON='[{"auth":{"kind":"foundryConnection","token":"stale"}}]', + ) + self.assertEqual(result, 0) + self.assertEqual(stderr, "") + + def test_valid_configuration_returns_zero(self): + result, stderr = self._run_main(FOUNDRY_IQ_MCP_SOURCES_JSON=self._sources_json()) + self.assertEqual(result, 0) + self.assertEqual(stderr, "") + + def test_valid_configuration_emits_canonical_json_for_app_configuration(self): + query_headers = [ + { + "name": " Authorization ", + "valueFrom": { + "kind": "managedIdentity", + "scope": " api://monitor/.default ", + }, + }, + { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "secretName": "monitor-api-key", + }, + }, + ] + env = { + **self.BASE_ENV, + "FOUNDRY_IQ_MCP_SOURCES_JSON": self._sources_json( + failOnError=False, + maxOutputDocuments=25, + queryHeaders=query_headers, + ), + } + stdout = io.StringIO() + stderr = io.StringIO() + with patch.dict(os.environ, env, clear=False): + with redirect_stdout(stdout), redirect_stderr(stderr): + result = mcp_setup.main(emit_canonical=True) + + self.assertEqual(result, 0) + self.assertEqual(stderr.getvalue(), "") + canonical = json.loads(stdout.getvalue()) + self.assertFalse(canonical[0]["failOnError"]) + self.assertEqual(canonical[0]["maxOutputDocuments"], 25) + self.assertEqual( + canonical[0]["queryHeaders"], + [ + { + "name": "Authorization", + "valueFrom": { + "kind": "managedIdentity", + "scope": "api://monitor/.default", + }, + }, + { + "name": "x-api-key", + "valueFrom": { + "kind": "keyVaultSecret", + "secretName": "monitor-api-key", + }, + }, + ], + ) + + def test_malformed_json_returns_one_before_any_write(self): + result, stderr = self._run_main(FOUNDRY_IQ_MCP_SOURCES_JSON="{not valid json") + self.assertEqual(result, 1) + self.assertIn("valid JSON", stderr) + + def test_auth_key_returns_one_before_any_write(self): + """The exact regression scenario for the postProvision.ps1 ordering + fix: an operator-supplied auth key must fail this gate (exit 1) + before scripts/postProvision.ps1 ever imports + FOUNDRY_IQ_MCP_SOURCES_JSON into App Configuration.""" + result, stderr = self._run_main( + FOUNDRY_IQ_MCP_SOURCES_JSON=self._sources_json(auth={"kind": "managedIdentity"}) + ) + self.assertEqual(result, 1) + self.assertIn("not allowed", stderr) + + def test_secret_like_key_returns_one_before_any_write(self): + result, stderr = self._run_main(FOUNDRY_IQ_MCP_SOURCES_JSON=self._sources_json(apiKey="not-a-real-secret")) + self.assertEqual(result, 1) + self.assertIn("literal credentials", stderr) + + def test_untrusted_host_returns_one_before_any_write(self): + result, stderr = self._run_main( + FOUNDRY_IQ_MCP_SOURCES_JSON=self._sources_json(), + FOUNDRY_IQ_MCP_TRUSTED_HOSTS="other-host.contoso.com", + ) + self.assertEqual(result, 1) + self.assertIn("not an exact match", stderr) + + def test_module_has_no_azure_sdk_dependency(self): + """Regression guard: this module must never itself write to Azure + App Configuration (or import an SDK capable of it), so it can be run + as a pure pre-flight gate with only the system Python interpreter, + before scripts/postProvision.ps1 installs any pip dependency.""" + source = inspect.getsource(mcp_setup) + self.assertNotIn("azure.appconfiguration", source) + self.assertNotIn("AzureAppConfigurationClient", source) + self.assertNotIn("import azure", source) + + +class PostProvisionMcpSourceGuardTests(unittest.TestCase): + """Regression guards for the PowerShell validation/write ordering.""" + + SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "postProvision.ps1" + + def test_disabled_path_skips_source_read_and_writes_safe_defaults(self): + script = self.SCRIPT.read_text(encoding="utf-8") + flag = script.index("$mcpEnabled = Test-Truthy") + preflight = script.index( + "Invoke-PythonModule -ModuleName 'config.search.foundry_iq_mcp_setup'" + ) + canonical_output = script.index( + "-Arguments @('--canonical')" + ) + app_config_import = script.index("Set-GptRagAppConfiguration -Endpoint") + + self.assertLess(flag, preflight) + self.assertLess(preflight, canonical_output) + self.assertLess(canonical_output, app_config_import) + self.assertNotIn( + "$mcpSourcesJson = Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_SOURCES_JSON'", + script, + ) + self.assertIn("$mcpSourcesJson = '[]'", script) + self.assertIn("$mcpReasoningEffort = 'low'", script) + self.assertIn("$mcpTrustedHosts = ''", script) + self.assertIn("$mcpLogToolArguments = 'false'", script) + + +class WorkIqPreflightTests(unittest.TestCase): + def test_filter_work_iq_sources_no_op_when_disabled(self): + defs = { + "knowledgeSources": [ + {"name": "blob-ks", "kind": "azureBlob"}, + ], + "knowledgeBases": [ + {"name": "kb", "knowledgeSources": [{"name": "blob-ks"}]}, + ], + } + setup.filter_work_iq_sources( + defs, + {"RETRIEVAL_BACKEND": "foundry_iq", "WORK_IQ_ENABLED": "false"}, + Mock(), + ) + self.assertEqual(defs["knowledgeSources"], [{"name": "blob-ks", "kind": "azureBlob"}]) + self.assertEqual(defs["knowledgeBases"][0]["knowledgeSources"], [{"name": "blob-ks"}]) + + def test_filter_work_iq_sources_removes_source_when_not_consented(self): + defs = { + "knowledgeSources": [ + {"name": "blob-ks", "kind": "azureBlob"}, + {"name": "work-iq-ks", "kind": "workIQ"}, + ], + "knowledgeBases": [ + { + "name": "kb", + "knowledgeSources": [{"name": "blob-ks"}, {"name": "work-iq-ks"}], + } + ], + } + credential = Mock() + with patch.object(setup, "check_work_iq_admin_consent", return_value=False): + setup.filter_work_iq_sources( + defs, + { + "RETRIEVAL_BACKEND": "foundry_iq", + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", + }, + credential, + ) + self.assertEqual([ks["name"] for ks in defs["knowledgeSources"]], ["blob-ks"]) + self.assertEqual( + [ref["name"] for ref in defs["knowledgeBases"][0]["knowledgeSources"]], + ["blob-ks"], + ) + + def test_filter_work_iq_sources_keeps_source_when_consented(self): + defs = { + "knowledgeSources": [ + {"name": "work-iq-ks", "kind": "workIQ"}, + ], + "knowledgeBases": [ + {"name": "kb", "knowledgeSources": [{"name": "work-iq-ks"}]} + ], + } + with patch.object(setup, "check_work_iq_admin_consent", return_value=True): + setup.filter_work_iq_sources( + defs, + { + "RETRIEVAL_BACKEND": "foundry_iq", + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", + }, + Mock(), + ) + self.assertEqual([ks["name"] for ks in defs["knowledgeSources"]], ["work-iq-ks"]) + + def test_filter_work_iq_sources_removes_source_when_preflight_inconclusive(self): + defs = { + "knowledgeSources": [{"name": "work-iq-ks", "kind": "workIQ"}], + "knowledgeBases": [ + {"name": "kb", "knowledgeSources": [{"name": "work-iq-ks"}]} + ], + } + with patch.object(setup, "check_work_iq_admin_consent", return_value=None): + setup.filter_work_iq_sources( + defs, + { + "RETRIEVAL_BACKEND": "foundry_iq", + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": "work-iq-ks", + }, + Mock(), + ) + self.assertEqual(defs["knowledgeSources"], []) + self.assertEqual(defs["knowledgeBases"][0]["knowledgeSources"], []) + + +class KnowledgeSourceNameUniquenessTests(unittest.TestCase): + """Comprehensive collision coverage for + setup.validate_unique_knowledge_source_names across every knowledge + source kind search.j2 can render: Blob/Search Index, conversation + Search Index, Work IQ, Fabric IQ, Fabric Data Agent, SharePoint Indexed, + Web grounding, and MCP Server.""" + + ALL_CATEGORIES = ( + "blob", + "conversation", + "work_iq", + "fabric_iq", + "fabric_data_agent", + "sharepoint_indexed", + "web_grounding", + "mcp", + ) + + def _default_names(self, **overrides): + names = {category: f"{category}-ks" for category in self.ALL_CATEGORIES} + names.update(overrides) + return names + + def _defs_with_all_categories_enabled(self, **name_overrides): + names = self._default_names(**name_overrides) + settings_input = { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + "FOUNDRY_IQ_KNOWLEDGE_SOURCE_NAME": names["blob"], + "FOUNDRY_IQ_CONVERSATION_UPLOAD_ENABLED": "true", + "FOUNDRY_IQ_CONVERSATION_KNOWLEDGE_SOURCE_NAME": names["conversation"], + "WORK_IQ_ENABLED": "true", + "WORK_IQ_KNOWLEDGE_SOURCE_NAME": names["work_iq"], + "FABRIC_IQ_ENABLED": "true", + "FABRIC_IQ_KNOWLEDGE_SOURCE_NAME": names["fabric_iq"], + "FABRIC_IQ_WORKSPACE_ID": "workspace-1", + "FABRIC_IQ_ONTOLOGY_ID": "ontology-1", + "FABRIC_DATA_AGENT_ENABLED": "true", + "FABRIC_DATA_AGENT_KNOWLEDGE_SOURCE_NAME": names["fabric_data_agent"], + "FABRIC_DATA_AGENT_WORKSPACE_ID": "workspace-1", + "FABRIC_DATA_AGENT_DATA_AGENT_ID": "agent-1", + "SHAREPOINT_INDEXED_ENABLED": "true", + "SHAREPOINT_INDEXED_KNOWLEDGE_SOURCE_NAME": names["sharepoint_indexed"], + "SHAREPOINT_INDEXED_INDEX_NAME": "sp-index", + "SHAREPOINT_INDEXED_SITE_URL": "https://contoso.sharepoint.com/sites/eng", + "SHAREPOINT_INDEXED_TENANT_ID": "tenant-guid", + "WEB_GROUNDING_ENABLED": "true", + "WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME": names["web_grounding"], + "FOUNDRY_IQ_MCP_ENABLED": "true", + } + settings = render_json_template("search.settings.j2", settings_input) + context = { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/st", + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": {"deployment_name": "chat", "model_name": "gpt-5-nano"}, + "FOUNDRY_IQ_MCP_SOURCES_JSON": [ + { + "name": names["mcp"], + "serverURL": "https://mcp.contoso.com/mcp", + "tools": [ + { + "name": "query", + "outputParsing": {"kind": "auto"}, + "inclusionMode": "reranked", + "maxOutputTokens": 1024, + } + ], + } + ], + } + return render_json_template("search.j2", context) + + def test_all_categories_enabled_with_distinct_names_pass(self): + defs = self._defs_with_all_categories_enabled() + names = [ks["name"] for ks in defs["knowledgeSources"]] + self.assertEqual(len(names), len(self.ALL_CATEGORIES)) + self.assertEqual(len(names), len(set(n.lower() for n in names))) + setup.validate_unique_knowledge_source_names(defs) # must not raise + + def test_exact_case_collision_across_every_category_pair_raises(self): + for category_a, category_b in itertools.combinations(self.ALL_CATEGORIES, 2): + with self.subTest(colliding=(category_a, category_b)): + defs = self._defs_with_all_categories_enabled(**{category_b: f"{category_a}-ks"}) + with self.assertRaisesRegex(ValueError, "[Cc]ollides"): + setup.validate_unique_knowledge_source_names(defs) + + def test_case_insensitive_collision_raises(self): + defs = self._defs_with_all_categories_enabled(mcp="BLOB-KS") + with self.assertRaisesRegex(ValueError, "case-insensitive"): + setup.validate_unique_knowledge_source_names(defs) + + def test_no_knowledge_sources_passes(self): + setup.validate_unique_knowledge_source_names({"knowledgeSources": []}) # must not raise + setup.validate_unique_knowledge_source_names({}) # must not raise + + def test_single_knowledge_source_passes(self): + setup.validate_unique_knowledge_source_names({"knowledgeSources": [{"name": "blob-ks"}]}) + + def test_two_distinct_names_pass(self): + setup.validate_unique_knowledge_source_names( + {"knowledgeSources": [{"name": "blob-ks"}, {"name": "mcp-ks"}]} + ) + + def test_duplicate_same_case_raises(self): + with self.assertRaisesRegex(ValueError, "collides"): + setup.validate_unique_knowledge_source_names( + {"knowledgeSources": [{"name": "blob-ks"}, {"name": "blob-ks"}]} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/manifest.json b/manifest.json index 27c753d0..d0b0a295 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,7 @@ { "name": "gpt-rag-orchestrator", "repo": "https://github.com/azure/gpt-rag-orchestrator.git", - "tag": "v3.5.0" + "tag": "v3.7.0" }, { "name": "gpt-rag-ingestion", diff --git a/scripts/postProvision.ps1 b/scripts/postProvision.ps1 index 4ebdb7f7..cf512bde 100755 --- a/scripts/postProvision.ps1 +++ b/scripts/postProvision.ps1 @@ -145,6 +145,58 @@ function ConvertTo-FlatJsonString { return ($Value | ConvertTo-Json -Depth 50 -Compress) } +#------------------------------------------------------------------------------- +# Make config.* importable early (moved ahead of Set-GptRagAppConfiguration so +# the Foundry IQ MCP pre-flight validation below can run before any App +# Configuration write/import). No Python package installation is required for +# that pre-flight check: foundry_iq_mcp_setup.py has no external dependencies. +#------------------------------------------------------------------------------- +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$env:PYTHONPATH = if ($env:PYTHONPATH) { "$repoRoot;$($env:PYTHONPATH)" } else { $repoRoot } +$env:GPT_RAG_REPO_ROOT = $repoRoot +Set-Location $repoRoot + +function Invoke-PythonModule { + param( + [Parameter(Mandatory = $true)][string]$ModuleName, + [string[]]$Arguments = @() + ) + Invoke-NativeCommand { + & python -c "import os, runpy, sys; sys.path.insert(0, os.environ['GPT_RAG_REPO_ROOT']); sys.argv = ['$ModuleName'] + sys.argv[1:]; runpy.run_module('$ModuleName', run_name='__main__')" @Arguments + } +} + +#------------------------------------------------------------------------------- +# Foundry IQ MCP Server source pre-flight validation +# Validates FOUNDRY_IQ_MCP_SOURCES_JSON (JSON shape, security constraints: +# only non-secret queryHeaders references, no auth/authentication or literal +# credential material at any nesting depth, trusted-host allowlisting, +# output-parsing shape, token caps) before ANY of it is imported into Azure +# App Configuration below. The validator returns canonical JSON so the raw +# operator value is never persisted. +#------------------------------------------------------------------------------- +$mcpEnabled = Test-Truthy (Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_ENABLED' 'false') +if ($mcpEnabled) { + Write-Host "🔐 Validating Foundry IQ MCP Server source configuration..." + $mcpSourcesJson = Invoke-PythonModule -ModuleName 'config.search.foundry_iq_mcp_setup' -Arguments @('--canonical') + if ($LASTEXITCODE -ne 0) { + Write-Error "Foundry IQ MCP Server source validation failed; aborting before any App Configuration write." + exit 1 + } + $mcpSourcesJson = ([string]$mcpSourcesJson).Trim() + $mcpReasoningEffort = Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_REASONING_EFFORT' 'low' + $mcpTrustedHosts = Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_TRUSTED_HOSTS' '' + $mcpLogToolArguments = Get-OptionalEnvValue 'FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS' 'false' +} else { + # Disabled provisioning must not parse, preserve, or persist stale source + # content. Write canonical safe defaults instead. + Write-Host "⏭️ Foundry IQ MCP Server disabled; skipping source validation." + $mcpSourcesJson = '[]' + $mcpReasoningEffort = 'low' + $mcpTrustedHosts = '' + $mcpLogToolArguments = 'false' +} + function Set-GptRagAppConfiguration { param( [Parameter(Mandatory = $true)][string]$Endpoint, @@ -430,6 +482,23 @@ function Set-GptRagAppConfiguration { WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME = (Get-OptionalEnvValue 'WEB_GROUNDING_KNOWLEDGE_SOURCE_NAME' '') WEB_GROUNDING_ALLOWED_DOMAINS = (Get-OptionalEnvValue 'WEB_GROUNDING_ALLOWED_DOMAINS' '') WEB_GROUNDING_BLOCKED_DOMAINS = (Get-OptionalEnvValue 'WEB_GROUNDING_BLOCKED_DOMAINS' '') + # Generic MCP Server knowledge source passthrough keys (preview). + # Opt-in via FOUNDRY_IQ_MCP_ENABLED=true plus at least one source in + # FOUNDRY_IQ_MCP_SOURCES_JSON (a JSON array; see + # docs/howto_grounding_mcp_server.md for the schema). Disabled by + # default. No secrets belong in FOUNDRY_IQ_MCP_SOURCES_JSON or in + # App Configuration. queryHeaders may contain only managed identity + # or OBO scopes, Key Vault secret names, or explicit none metadata; + # the compatible orchestrator resolves values at request time. + # FOUNDRY_IQ_MCP_TRUSTED_HOSTS is a + # comma-separated allowlist of exact hostnames the provisioning + # script requires an MCP serverURL to match before it will + # register the source. + FOUNDRY_IQ_MCP_ENABLED = if ($mcpEnabled) { 'true' } else { 'false' } + FOUNDRY_IQ_MCP_SOURCES_JSON = $mcpSourcesJson + FOUNDRY_IQ_MCP_REASONING_EFFORT = $mcpReasoningEffort + FOUNDRY_IQ_MCP_TRUSTED_HOSTS = $mcpTrustedHosts + FOUNDRY_IQ_MCP_LOG_TOOL_ARGUMENTS = $mcpLogToolArguments NETWORK_ISOLATION = (Get-OptionalEnvValue 'NETWORK_ISOLATION' 'false') USE_UAI = (Get-OptionalEnvValue 'USE_UAI' 'false') USE_CAPP_API_KEY = (Get-OptionalEnvValue 'USE_CAPP_API_KEY' 'false') @@ -552,16 +621,6 @@ Set-GptRagAppConfiguration -Endpoint (Get-RequiredEnvValue 'APP_CONFIG_ENDPOINT' #------------------------------------------------------------------------------- # Setup Python environment #------------------------------------------------------------------------------- -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -$env:PYTHONPATH = if ($env:PYTHONPATH) { "$repoRoot;$($env:PYTHONPATH)" } else { $repoRoot } -$env:GPT_RAG_REPO_ROOT = $repoRoot -Set-Location $repoRoot - -function Invoke-PythonModule { - param([Parameter(Mandatory = $true)][string]$ModuleName) - Invoke-NativeCommand { & python -c "import os, runpy, sys; sys.path.insert(0, os.environ['GPT_RAG_REPO_ROOT']); runpy.run_module('$ModuleName', run_name='__main__')" } -} - Write-Host "🐍 Checking Python venv support..." Invoke-NativeCommand { & python -c "import venv" 2>$null } $venvSupported = ($LASTEXITCODE -eq 0) From 0d9fb1738c7b8edd24e420e121f7f0a13cf5987b Mon Sep 17 00:00:00 2001 From: Paulo Lacerda Date: Tue, 21 Jul 2026 10:27:38 -0300 Subject: [PATCH 5/6] feat(governance): integrate audit trail components (#573) Ship a disabled-by-default governance baseline with Key Vault-backed HMAC pseudonymization, additive Search provenance fields, and pinned orchestrator v3.8.0 plus ingestion v2.5.0.\n\nRefs #571. Keep the issue open until the v3.7.0 umbrella release is published and docs PR #574 is merged.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .azure/deployment-plan.md | 413 ++++++++++++++++++ .gitattributes | 3 + CHANGELOG.md | 39 +- config/governance/__init__.py | 1 + config/governance/setup.py | 261 +++++++++++ config/governance/tests/__init__.py | 1 + config/governance/tests/test_setup.py | 271 ++++++++++++ config/search/search.j2 | 20 +- config/search/setup.py | 215 ++++++++- .../search/tests/test_foundry_iq_templates.py | 16 +- config/search/tests/test_governance_schema.py | 261 +++++++++++ contracts/README.md | 13 + ...-event-v1.application-insights.schema.json | 98 +++++ contracts/audit-event-v1.schema.json | 208 +++++++++ contracts/audit-event-v1.sha256 | 2 + manifest.json | 6 +- scripts/postProvision.ps1 | 19 +- scripts/postProvision.sh | 14 +- 18 files changed, 1831 insertions(+), 30 deletions(-) create mode 100644 .azure/deployment-plan.md create mode 100644 .gitattributes create mode 100644 config/governance/__init__.py create mode 100644 config/governance/setup.py create mode 100644 config/governance/tests/__init__.py create mode 100644 config/governance/tests/test_setup.py create mode 100644 config/search/tests/test_governance_schema.py create mode 100644 contracts/README.md create mode 100644 contracts/audit-event-v1.application-insights.schema.json create mode 100644 contracts/audit-event-v1.schema.json create mode 100644 contracts/audit-event-v1.sha256 diff --git a/.azure/deployment-plan.md b/.azure/deployment-plan.md new file mode 100644 index 00000000..5e4d99cc --- /dev/null +++ b/.azure/deployment-plan.md @@ -0,0 +1,413 @@ +# GPT-RAG issue 571 deployment preparation plan + +## Status + +Deployment Blocked + +## Mode + +MODIFY + +## Issue + +- Azure/GPT-RAG#571: establish a practical governance baseline and auditable AI activity trail. +- The user has approved autonomous preparation and implementation. +- This plan changes the existing GPT-RAG AZD/Bicep deployment recipe in place. +- The user has authorized one new Standard validation deployment in + `swedencentral`. No destructive resource operation or resource-group deletion + is authorized. + +## Existing deployment recipe + +- Azure Developer CLI orchestrates the existing deployment through `azure.yaml`. +- The root repository supplies `main.parameters.json`, `manifest.json`, lifecycle hooks, and post-provision configuration. +- `infra/` remains the pinned `Azure/bicep-ptn-aiml-landing-zone` submodule. It will not be edited in place. +- Post-provision configuration uses Azure CLI/SDK authentication, Azure App Configuration label `gpt-rag`, Azure Key Vault, Azure Container Apps, and Azure AI Search REST APIs. + +## Architecture + +- Pin `Azure/gpt-rag-orchestrator` v3.8.0 and `Azure/gpt-rag-ingestion` v2.5.0 in the umbrella manifest and future-minor release surfaces. +- Seed metadata-only audit and provenance settings in App Configuration with all feature gates disabled by default. +- Create `AUDIT_HMAC_KEY` once as a cryptographically random 256-bit Key Vault secret when Key Vault is deployed. Reuse the existing value on later runs so ordinary reprovisioning is stable; rotation is an explicit operator action that creates a new Key Vault version. If Key Vault is explicitly disabled, seed only the disabled feature gates and keep audit events off. +- Publish only an App Configuration Key Vault reference for `AUDIT_HMAC_KEY`; never persist or display the secret value in admin settings, files, outputs, or logs. +- Add the ten optional provenance/governance fields to the RAG Azure AI Search index. +- Replace unconditional index delete/recreate with an in-place `PUT` update for existing indexes. Azure AI Search receives the full desired schema while preserving documents for supported additive changes; incompatible changes fail instead of falling back to deletion. +- Preserve the existing creation path for missing indexes and leave unrelated datasource, skillset, and indexer behavior unchanged. +- Keep the orchestrator v3.8.0 logical contract hash `825db8ef40a81e2c19e5d80d37c565b6b47fc9a6540e9881d35cc12b8fde5aab` and wire hash `066c8f5408610ab839d5121d06ca5bc59e8797e551d5c47c875c5ba52f7e0588` aligned in tests and documentation. + +## Configuration defaults + +### Orchestrator + +- `AUDIT_EVENTS_ENABLED=false` +- `AUDIT_SENSITIVE_CONTENT_ENABLED=false` +- `AUDIT_SENSITIVE_CONTENT_FIELDS=` +- `AUDIT_ACTOR_PSEUDONYM_ENABLED=false` +- `AUDIT_SOURCE_EVENT_LIMIT=25` +- `AUDIT_HMAC_KEY_ID=v1` +- `AUDIT_ADDITIONAL_REDACTED_KEYS=` + +### Ingestion + +- `INGESTION_PROVENANCE_ENABLED=false` +- `INGESTION_REQUIRE_GOVERNANCE_METADATA=false` +- `INGESTION_DEFAULT_CLASSIFICATION=unclassified` +- `INGESTION_DEFAULT_RIGHT_TO_USE=not_asserted` + +## Azure AI Search schema + +The RAG index receives these optional, retrievable fields: + +| Field | Azure AI Search type | Attributes | +| --- | --- | --- | +| `provenance_id` | `Edm.String` | filterable | +| `source_uri_id` | `Edm.String` | filterable | +| `source_version_id` | `Edm.String` | filterable | +| `content_checksum_sha256` | `Edm.String` | filterable | +| `ingested_at` | `Edm.DateTimeOffset` | filterable, sortable | +| `ingest_run_id` | `Edm.String` | filterable | +| `data_classification` | `Edm.String` | filterable, facetable | +| `right_to_use` | `Edm.String` | filterable, facetable | +| `retention_class` | `Edm.String` | filterable, facetable | +| `delete_after` | `Edm.String` | filterable, sortable | + +`delete_after` records policy intent only. This solution does not automatically purge documents based on that field. + +## Security + +- Generate key material with Python's operating-system-backed `secrets` module and require exactly 32 random bytes before encoding. +- Read existing secret metadata/value only to preserve idempotency; do not print or serialize the secret. +- Use the existing App Configuration Key Vault reference content type. +- Do not add the secret to the normal App Configuration plaintext import, AZD outputs, Bicep outputs, logs, changelog, or documentation examples. +- Keep sensitive-content capture disabled independently from metadata audit events. +- Preserve fail-closed component validation and prohibited-field redaction in the pinned runtime releases. +- No credentials, secrets, generated resource names, or personal validation + environment names will be committed. Non-secret Azure scope and principal IDs + may be recorded when required for exact validation proof. + +## Migration and rollback + +- Existing deployments receive disabled defaults, so runtime behavior remains unchanged until operators opt in. +- The Search schema update is additive and performed in place. Existing indexed documents remain valid. Ingestion v2.5.0 emits provenance in audit events but does not populate these index fields; they remain empty unless the indexing pipeline explicitly supplies them. +- No automatic or fallback index deletion is allowed. Unsupported schema mutations fail with an actionable error and leave the existing index intact. +- Upgrade sequence: deploy umbrella configuration with the v3.8.0/v2.5.0 pins, run post-provisioning, verify the Key Vault reference and additive index schema, then enable metadata audit/provenance in a non-production environment. +- Rollback sequence: set `AUDIT_EVENTS_ENABLED=false` and `INGESTION_PROVENANCE_ENABLED=false`, keep sensitive capture disabled, and redeploy the prior component tags if required. +- Additive Search fields are harmless to older component versions and do not need removal during rollback. +- Rotating `AUDIT_HMAC_KEY` is explicit: create a new Key Vault secret version and update `AUDIT_HMAC_KEY_ID` so evidence consumers can distinguish pseudonymization epochs. + +## UX and documentation advisory + +- Steve confirmed the main operator risk is the current unconditional index deletion. The implementation must use in-place updates and must not claim a safe additive migration until tests prove no delete call occurs. +- Provide one umbrella configuration table, link to component guidance, and state that ingestion settings contain no secrets. +- State that audit evidence supports adopter governance work but is not proof of legal or regulatory compliance. +- State that enabling audit events requires the Key Vault secret reference and that `delete_after` does not enforce retention. +- Update the GPT-RAG docs branch in a companion documentation PR. + +## Validation + +- [x] All validation checks pass + - [x] AZD installation and authentication + - [x] `azure.yaml` stable-schema validation + - [x] Bicep compilation + - [x] Bicep lint execution + - [x] Python/config build verification + - [x] Focused and full relevant tests + - [x] Static RBAC verification + - [x] AZD environment/subscription/location validation + - [x] Required pre-provision hooks + - [x] `azd provision --preview --no-prompt` + - [x] AI Landing Zone compiled-template size gate + - [x] AZD package validation + - [x] Azure Policy validation + +- Focused Python unit tests for App Configuration defaults, stable secret creation/reference behavior, contract pins, Search field types, and no-delete index updates. +- Full relevant GPT-RAG configuration test suite. +- Python compile and JSON/schema assertions. +- PowerShell parser and script validation; shell syntax validation when Bash is available. +- `az bicep build --file infra/main.bicep` and the repository template-size gate when applicable. +- Manifest/version/tag assertions against published v3.8.0 and v2.5.0 releases. +- Docker/config validation through existing repository scripts where applicable. +- `azd provision --preview` or the repository's non-deploy preflight only when the current authenticated AZD context is complete. Treat any empty environment resource group that AZD creates before what-if as a preview-tool side effect, not as workload provisioning. +- Preparation and preview remain non-destructive. After region-aware validation + passes, hand off one `azd provision` and `azd deploy` attempt to the + `azure-deploy` recovery workflow. + +## Role Assignment Verification + +- Status: Verified for this change. +- Orchestrator identity: `AppConfigurationDataReader`, + `KeyVaultSecretsUser`, `SearchIndexDataReader`, and the existing model, + Cosmos, ACR, and Storage data-plane roles remain scoped through the landing + zone's per-resource role modules. +- Ingestion identity: `AppConfigurationDataReader` and + `SearchIndexDataContributor` remain present for provenance configuration and + indexed-document writes. +- No new management-plane role, resource-group role, subscription role, or + role-assignment resource was added by this branch. + +## Section 7: Validation Proof + +| Check | Command / proof | Result | +| --- | --- | --- | +| Exact source | `git rev-parse HEAD`; compare local and remote PR branch | Passed at requested and remote PR head `5ea5f7c983660db0c2ece99b9eb4fd6cd1688485` | +| CLI versions | `azd version`; `az version --output json` | Passed, AZD v1.27.1 and Azure CLI v2.79.0 | +| Authentication | `azd auth login --check-status`; `az account show`; `az account get-access-token --resource https://management.azure.com/` | Passed for `paulolacerda@microsoft.com`; default subscription `mcaps-paulolacerda` (`9788a92c-2f71-4629-8173-7ad449cb50e1`), tenant `16b3c013-d300-468d-ac64-7eda0820b6d3`; token tenant matched | +| Deploying principal | `az role assignment list --assignee-object-id 88e31ae8-65b8-46a2-8b26-fad4e6c305f3 --scope /subscriptions/9788a92c-2f71-4629-8173-7ad449cb50e1 --include-inherited` | Passed; principal has inherited `Owner` at subscription scope, sufficient for previewing resource-scoped role assignments | +| AZD schema | Azure AZD `validate_azure_yaml` on root `azure.yaml` | Passed against the official stable schema | +| AZD environment | `azd env new --subscription 9788a92c-2f71-4629-8173-7ad449cb50e1 --location eastus2 --no-prompt`; `azd env set ...`; `azd env get-values` | Passed; isolated local environment used `eastus2`, deploying principal `88e31ae8-65b8-46a2-8b26-fad4e6c305f3`, type `User`, and Standard-mode safe preview values: `NETWORK_ISOLATION=false`, `USE_UAI=false`, `USE_CAPP_API_KEY=false`, `ENABLE_AGENTIC_RETRIEVAL=false`, `RETRIEVAL_BACKEND=foundry_iq`, `DEPLOYMENT_MODE=standalone`, and VM/Bastion/NAT/Firewall deployment flags disabled | +| Required hook | Load `azd env get-values`; `pwsh -NoProfile -File scripts/preProvision.ps1` | Passed. GPT-RAG regional preflight: 0 failures, 2 capacity warnings; required providers and eastus2 locations passed, Container Apps quota passed, both model deployments and quotas passed. Landing-zone preflight: 0 failures, 3 warnings, 1 information item | +| Bicep compile | `az bicep build --file infra/main.bicep` | Passed with existing pinned-submodule warnings only | +| Bicep lint | `az bicep lint --file infra/main.bicep` | Passed with existing pinned-submodule warnings only | +| Template size | `pwsh infra/scripts/Measure-MainJsonSize.ps1 -SkipBuild -WorkingBudgetMB 3.5 -FailThresholdMB 4.7 -ArmHardCeilingMB 5.0` | Passed authoritative gate: 4,872,869 bytes (4.647 MB). Warning above 3.5 MB working budget, below 4.7 MB fail threshold and 5.0 MB ARM ceiling | +| Relevant tests | `python -m unittest config.governance.tests.test_setup config.search.tests.test_governance_schema config.search.tests.test_foundry_iq_templates` | Passed, 131 tests | +| Static checks | `python -m ruff check ...`; `python -m compileall -q ...`; PowerShell AST parse; Git Bash `-n scripts/preProvision.sh scripts/postProvision.sh` | Passed | +| Package | `azd package --no-prompt` | Passed; umbrella package completed successfully | +| Docker | Repository scan | Not applicable: no umbrella Dockerfile or AZD service package | +| Azure Policy | Azure Policy `policy_assignment_list` at subscription scope, followed by ARM what-if | Passed; assignments, including inherited MCAPSGov deny/audit/deploy initiatives, were reviewed and did not block the preview | +| Static RBAC | Review `main.parameters.json`, `infra/main.bicep:3153-3525`, and `infra/constants/roles.json`; assert required role keys and Bicep mappings | Passed. Orchestrator has resource-scoped App Configuration Data Reader, Key Vault Secrets User, and Search Index Data Reader. Ingestion has resource-scoped App Configuration Data Reader, Key Vault Secrets User, and Search Index Data Contributor. The deploying principal receives resource-scoped Key Vault Contributor/Secrets Officer, App Configuration Data Owner, Search Service Contributor, and Search data roles needed by post-provision setup. This PR changes neither `infra/` nor `main.parameters.json` and adds no RBAC assignment | +| Provision preview | `azd provision --preview --no-prompt` | Passed in 1 minute 6 seconds. ARM what-if returned a create-only workload plan with no modify/delete operations and applied no workload resources | +| Preview side effect | `az group show`; `az resource list`; Azure activity log | AZD v1.27.1 created the empty, tagged environment resource group before running what-if at `2026-07-21T12:12:15Z`. The group contains 0 resources. It was not deleted because this validation explicitly prohibited Azure resource or resource-group deletion | + +Validation status is **Validated**. All required formal checks passed at exact PR +head `5ea5f7c983660db0c2ece99b9eb4fd6cd1688485`. The change is ready for the +`azure-deploy` workflow, but deployment remains explicitly out of scope for this +validation. + +## Handoff proof required + +- Feature branch `feature/governance-audit-umbrella-571` was created from + `origin/develop` commit `e6ba89f3f609d900c0dd32c26999cf7fe4e8dca0`. +- Focused and full relevant configuration suites passed: 131 tests. +- Ruff passed for all changed Python implementation and test files. +- Python compilation, JSON/schema parsing, contract SHA-256 assertions, and + PowerShell AST parsing passed. +- Tests prove all ten Search fields and their types/attributes, preservation of + operator-added fields, update-before-cleanup ordering, no index DELETE, + ETag-guarded updates, and `If-None-Match: *` guarded creation. +- Tests prove 32-byte OS-random key generation, stable secret reuse, plaintext + key migration without rotation, preservation of operator-managed Key Vault + references, no plaintext deployment parameter, and fail-closed behavior when + audit is enabled without Key Vault. +- The full documentation site passed `mkdocs build --strict`. +- `az bicep build --file infra/main.bicep` succeeded with existing submodule + warnings. The existing AI Landing Zone v2.3.0 template is 4.647 MB and passed + the authoritative 4.7 MB fail threshold and 5.0 MB ARM ceiling, with a warning + above the 3.5 MB working budget. +- `azd provision --preview --no-prompt` succeeded at exact PR head in eastus2 + and returned a create-only ARM what-if plan with no modify/delete operations. + AZD v1.27.1 created an empty environment resource group before what-if; it has + 0 resources and was left untouched because resource-group deletion was + prohibited. +- Git Bash syntax validation passed for both lifecycle hooks. PowerShell AST + parsing and the Python module invoked by both hooks also passed. +- Docker validation is not applicable because the umbrella repository has no + Dockerfile; the pinned component releases were verified as published, + non-draft, non-prerelease tags. +- No workload resources were deployed, modified, or deleted. AZD preview itself + created one empty tagged environment resource group before ARM what-if; no + deletion was attempted. +- Implementation commits: + `11d65b294cc7a2270e579deddf42c770dc9e1761` and + `5ea5f7c`. +- Implementation PR, unmerged: + `https://github.com/Azure/GPT-RAG/pull/573`. +- Documentation commits: + `2cca8c68f16e0f2ade8fdf4939cf8e9b6335fc5d` and + `8f00c16`. +- Documentation PR, unmerged: + `https://github.com/Azure/GPT-RAG/pull/574`. + +## Section 8: Deployment Proof + +- Deployment source: exact PR evidence commit + `a7c98f187b37360cecd1500301a47dcecfcaac78`. +- Deployment context: the exact validated AZD environment was recreated in + `eastus2` with the validated Standard-mode settings, subscription, tenant, + deploying principal, and principal type. The empty resource group created by + preview was reused after confirming it had only the AZD environment tag, had + no `keep=true` tag, contained no resources, and had no Container Apps + environment or conflicting `azd-service-name` tags. +- Pre-deploy checklist: passed. Authentication, subscription, tenant, + deploying principal, region, Azure Policy assignments, environment values, + repository commit, resource-group location/tags, and AZD recipe were + rechecked before provisioning. +- Provisioning attempts: `azd provision --no-prompt` was attempted three times + on 2026-07-21. The first retry followed a 60-second wait; the final retry + followed a 300-second backoff. Every attempt stopped at Azure AI Search with + `InsufficientResourcesAvailable` for `eastus2`. Azure request IDs: + `d73cd9b4-834a-93c5-fa68-7012f02c5f88`, + `59685ea6-8b58-1525-25e4-89697b8fbb55`, and + `8bc5f789-b35f-848b-fbcb-05bc90fe71d6`. +- Partial result: the resource group was retained with successfully created + platform resources, including Log Analytics, Application Insights, App + Configuration, Key Vault, Container Registry, Storage, Cosmos DB, and a + Container Apps environment. Azure AI Search, Container Apps, component + images, and application endpoints were not created. +- Deployment gate: `azd deploy --no-prompt` was not run because infrastructure + provisioning did not complete. The mandatory ACR `AcrPull` propagation gate, + endpoint/health checks, live application RBAC verification, App + Configuration default verification, Key Vault audit-key semantics/reference + verification, Search schema checks, component-pin checks, safe request and + ingestion flows, KQL audit reconstruction, sensitive-property checks, + root-sentinel/event-name/event-budget checks, and overhead measurement remain + blocked. +- Security and retention: no secret value was read or exposed. Key Vault + metadata verification was unavailable because the data-plane role assignment + had not been provisioned. No resource, resource group, role assignment, or + deployment was deleted; `azd down` and all deletion commands were not run. + The partial validation environment remains running for follow-up. +- Status: **Deployment Blocked** by regional Azure AI Search capacity. A safe + retry is to rerun `azd provision --no-prompt` when `eastus2` capacity is + available, then continue with the required ACR RBAC propagation check, + `azd deploy --no-prompt`, and the post-deployment verification matrix. + +## Section 9: Sweden Central recovery plan + +- Authorization: on 2026-07-21 the user authorized one new, unique Standard + validation AZD environment in `swedencentral`, using subscription + `9788a92c-2f71-4629-8173-7ad449cb50e1`, tenant + `16b3c013-d300-468d-ac64-7eda0820b6d3`, and deploying principal + `88e31ae8-65b8-46a2-8b26-fad4e6c305f3` with principal type `User`. +- Isolation: the recovery environment must be fresh and must not reuse Search + service `srch-c6emckf22jxl4`. That existing service proves prior regional + viability only; it does not guarantee new Search capacity. +- Protected partial environment: do not modify or delete + `rg-gptrag-pr573-7xsurm`. Do not run `azd down` or any deletion command. +- Configuration drift gate: preserve the validated Standard settings, including + `NETWORK_ISOLATION=false`, `USE_UAI=false`, `USE_CAPP_API_KEY=false`, + `ENABLE_AGENTIC_RETRIEVAL=false`, `RETRIEVAL_BACKEND=foundry_iq`, + `DEPLOYMENT_MODE=standalone`, and disabled VM, Bastion, NAT, and Firewall + deployment flags. Only environment/resource names and required regional + values may differ. +- Region-aware gate: rerun the required pre-provision checks for + `swedencentral`, then run one fresh `azd provision --preview --no-prompt`. + Continue only if the plan is create-only and contains no modify/delete + operations. +- Capacity stop condition: run one provision attempt. If Azure AI Search again + returns `InsufficientResourcesAvailable`, stop without retrying another + region and report the blocker. +- Successful deployment gate: after provisioning, verify ACR pull roles have + propagated before `azd deploy --no-prompt`. +- Verification: fully qualified endpoints and health, live RBAC, manifest + component versions, App Configuration safe defaults, Key Vault + `AUDIT-HMAC-KEY` existence and 32-byte semantics without exposing its value, + Key Vault reference wiring, additive Search provenance fields, minimal safe + request and ingestion flows, Application Insights `gptrag.audit.*` custom + events, root sentinel/privacy/event names, and KQL reconstruction. Keep + sensitive capture disabled. +- Evidence: append exact environment, resource group, endpoints, commands, + timestamps, verification results, and blockers to this file; commit and push + the evidence to PR #573. Leave the successful environment active. + +## Section 10: Sweden Central validation proof + +- Validation source: PR head + `b7766419aa03375ef61de9fbe05e90b80f02a0db`. The only committed delta from + implementation head `5ea5f7c983660db0c2ece99b9eb4fd6cd1688485` + is `.azure/deployment-plan.md`; no code, Bicep, parameters, manifest, or + lifecycle hook changed. +- Environment: new local AZD environment `gptrag-pr573-sw-a333b`, target resource + group `rg-gptrag-pr573-sw-a333b`, subscription + `9788a92c-2f71-4629-8173-7ad449cb50e1`, tenant + `16b3c013-d300-468d-ac64-7eda0820b6d3`, region `swedencentral`, deploying + principal `88e31ae8-65b8-46a2-8b26-fad4e6c305f3`, principal type `User`. +- Configuration: `azd env get-values` confirmed the approved Standard settings, + including `NETWORK_ISOLATION=false`, `USE_UAI=false`, + `USE_CAPP_API_KEY=false`, `ENABLE_AGENTIC_RETRIEVAL=false`, + `RETRIEVAL_BACKEND=foundry_iq`, `DEPLOYMENT_MODE=standalone`, and disabled VM, + software, Jumpbox, Bastion, NAT Gateway, and Azure Firewall flags. Foundry, + Search, and Cosmos regional values are all `swedencentral`. +- Authentication and authorization: `az account show`, + `az ad signed-in-user show`, and inherited role lookup confirmed the requested + subscription, tenant, exact principal, and inherited `Owner` at subscription + scope. +- Regional evidence: existing Standard Search service + `srch-c6emckf22jxl4` is running in Sweden Central in the same subscription. + It is not referenced or reused by this environment. +- Policy: Azure Policy assignments were refreshed. Subscription and inherited + MCAPSGov audit, deny, deploy/modify, security-baseline, and MFA assignments + remain applicable; preview found no policy blocker. +- Provider/quota preflight: `scripts/preProvision.ps1` passed with 0 failures, + 2 capacity warnings, and landing-zone preflight passed with 0 failures, + 1 warning, and 1 information item. All required providers and + `swedencentral` locations passed. Container Apps had 50 managed environments + remaining. `gpt-5-nano` had 15,000 quota units remaining for a 100-unit + request; `text-embedding-3-large` had 350 remaining for a 100-unit request. + Search and Cosmos live capacity cannot be guaranteed before provisioning. +- AZD schema: the Azure AZD stable-schema validator passed `azure.yaml`. +- Build/what-if: the first `azd provision --preview --no-prompt` stopped before + Azure access because the fresh clone had not initialized the pinned `infra` + submodule. After `git submodule update --init --recursive` checked out + `1616ddd940b796c32f86e4459b079eebc254de08`, the preview passed in 1 minute + 18 seconds. It compiled the pinned Bicep and returned create-only resources + with no modify/delete operations. +- Preview safety: the preview-created resource group contains 0 resources, has + only the `azd-env-name=gptrag-pr573-sw-a333b` tag, and has no Container Apps + environment or `azd-service-name` conflict. +- Protected environment: read-only checks confirmed + `rg-gptrag-pr573-7xsurm` remains in `eastus2`; no operation targeted, modified, + or deleted it. +- Static RBAC and package/build proof from Section 7 remains applicable because + no implementation, infrastructure, parameter, manifest, or hook file changed. +- Status: **Validated** for one `swedencentral` recovery deployment attempt. Any + modify/delete plan or Azure AI Search `InsufficientResourcesAvailable` result + is a hard stop. No deletion or `azd down` is authorized. + +## Section 11: Sweden Central deployment recovery proof + +- Attempt: one `azd provision --no-prompt` run against validated environment + `gptrag-pr573-sw-a333b` and resource group + `rg-gptrag-pr573-sw-a333b` in `swedencentral`. +- Source: PR evidence head + `b7766419aa03375ef61de9fbe05e90b80f02a0db`, plus this uncommitted evidence + update. No implementation, Bicep, parameter, manifest, or lifecycle-hook file + changed. +- Preflight: repeated during provisioning and passed with 0 failures. Provider, + location, Container Apps quota, model availability, and model quota checks + remained successful. Search and Cosmos live-capacity warnings remained. +- ARM deployment: + `gptrag-pr573-sw-a333b-1784639848`, terminal state `Failed` at + `2026-07-21T13:21:12.160467Z`. +- Capacity blocker: Container Apps managed environment + `cae-n7t6ey-gptrag-pr573-sw-a333b` failed with + `ManagedEnvironmentCapacityHeavyUsageError` / + `AKSCapacityHeavyUsage` because a new managed-environment cluster was + unavailable in `swedencentral`. Azure request ID: + `2757ce4e-9f1a-4e61-8f23-62978db7d9fd`. +- Independent naming blocker: the Cosmos nested deployment name + `umd7bs2my5f6a-sqldb-cosmosdb-n7t6ey-gptrag-pr573-sw-a333b-sdc-001` + was 65 characters and exceeded the ARM deployment-name maximum of 64. +- Stop condition: the requested one-region attempt limit was reached by the + platform-capacity failure. No retry, alternate region, shorter environment, + `azd deploy`, `azd down`, or deletion command was run. +- Partial resources retained in the Sweden Central resource group: + Key Vault `kv-n7t6ey-gptrag-pr573-s`, Log Analytics workspace + `log-n7t6ey-gptrag-pr573-sw-a333b-sdc-001`, App Configuration + `appcs-n7t6ey-gptrag-pr573-sw-a333b-sdc-001`, Container Registry + `crn7t6eygptragpr573swa333bsdc001`, Cosmos DB account + `cosmos-n7t6ey-gptrag-pr573-sw-a333b-sdc-001`, Storage account + `stn7t6eygptragpr573swa33`, Application Insights + `appi-n7t6ey-gptrag-pr573-sw-a333b-sdc-001`, and failed Container Apps + environment `cae-n7t6ey-gptrag-pr573-sw-a333b`. +- Partial platform endpoints: + `https://kv-n7t6ey-gptrag-pr573-s.vault.azure.net/` and + `https://appcs-n7t6ey-gptrag-pr573-sw-a333b-sdc-001.azconfig.io`. + These resources reached management-plane `Succeeded`, but application health + was not testable. +- Not created: Azure AI Search services and Container Apps. No application FQDN + or health endpoint exists. +- Blocked verification: ACR `AcrPull` propagation, `azd deploy`, component + versions in running apps, live application RBAC, App Configuration governance + defaults, Key Vault `AUDIT-HMAC-KEY` existence/32-byte semantics/reference, + additive Search fields, safe request and ingestion flows, Application + Insights `gptrag.audit.*` custom events, root sentinel/privacy/event names, + and KQL reconstruction. +- Security: no Key Vault secret value, connection string, token, payload, or + sensitive event content was read or recorded. Sensitive capture was never + enabled. +- Retention: all Sweden Central partial resources remain active. The eastus2 + group `rg-gptrag-pr573-7xsurm` remains unchanged with 9 resources. No + resource, group, role assignment, or deployment was deleted. +- Status: **Deployment Blocked** by Sweden Central Container Apps platform + capacity, with an additional environment-name length defect that must be + corrected before any future attempt. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4c9bffe3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +contracts/*.json text eol=lf +contracts/*.sha256 text eol=lf +config/search/tests/test_foundry_iq_templates.py -text whitespace=cr-at-eol diff --git a/CHANGELOG.md b/CHANGELOG.md index b87911e3..73ceb17c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,48 @@ ## [Unreleased] +### User and operator impact + +Prepares the next minor GPT-RAG release to deploy [orchestrator `v3.8.0`](https://github.com/Azure/gpt-rag-orchestrator/releases/tag/v3.8.0) and [ingestion `v2.5.0`](https://github.com/Azure/gpt-rag-ingestion/releases/tag/v2.5.0) as one validated governance baseline. Metadata audit events and ingestion provenance remain disabled by default, sensitive-content capture remains independently disabled, and existing deployments do not change behavior until an operator opts in. + +The shared `audit-event-v1` contract is pinned to logical hash `825db8ef40a81e2c19e5d80d37c565b6b47fc9a6540e9881d35cc12b8fde5aab` and Application Insights wire hash `066c8f5408610ab839d5121d06ca5bc59e8797e551d5c47c875c5ba52f7e0588`. These audit events provide technical evidence for adopter governance and incident response; they do not establish legal or regulatory compliance. + ### Added +- **Safe governance defaults in App Configuration.** Fresh and upgraded deployments seed missing keys as `AUDIT_EVENTS_ENABLED=false`, `AUDIT_SENSITIVE_CONTENT_ENABLED=false`, `AUDIT_SENSITIVE_CONTENT_FIELDS=''`, `AUDIT_ACTOR_PSEUDONYM_ENABLED=false`, `AUDIT_SOURCE_EVENT_LIMIT=25`, `AUDIT_HMAC_KEY_ID=v1`, `AUDIT_ADDITIONAL_REDACTED_KEYS=''`, `INGESTION_PROVENANCE_ENABLED=false`, `INGESTION_REQUIRE_GOVERNANCE_METADATA=false`, `INGESTION_DEFAULT_CLASSIFICATION=unclassified`, and `INGESTION_DEFAULT_RIGHT_TO_USE=not_asserted`. Reprovisioning preserves operator-managed values unless the corresponding AZD environment value explicitly overrides them. +- **Key Vault-backed audit pseudonymization key.** When `AUDIT_HMAC_KEY` is absent, post-provisioning creates `AUDIT-HMAC-KEY` from 256 cryptographically random bits and registers only its Key Vault reference in App Configuration. Later runs reuse it; an existing operator-managed Key Vault reference is preserved, and a valid legacy plaintext key is migrated without changing its value. The value is not placed in plaintext deployment configuration, output, or logs. Rotation is explicit: create a new Key Vault secret version and advance `AUDIT_HMAC_KEY_ID`. +- **Additive provenance fields in the RAG Search index.** The index adds `provenance_id`, `source_uri_id`, `source_version_id`, `content_checksum_sha256`, `ingested_at`, `ingest_run_id`, `data_classification`, `right_to_use`, `retention_class`, and `delete_after`. Identifier/checksum fields are retrievable and filterable; guaranteed UTC `ingested_at` values use sortable `Edm.DateTimeOffset`; `delete_after` remains a sortable string because ingestion v2.5.0 intentionally passes through operator policy values without imposing a date format; governance classification fields are facetable. +- **Shared contract source.** `contracts/` now carries the byte-pinned logical and Application Insights wire schemas consumed by orchestrator v3.8.0 and ingestion v2.5.0. + ### Changed -### Fixed +- **Future minor release manifest.** `manifest.json` identifies the planned umbrella `v3.7.0` combination and pins orchestrator `v3.8.0` plus ingestion `v2.5.0`; UI `v2.3.13` and infra `v2.3.0` remain unchanged. No release is published by this change. +- **Non-destructive existing-index migration.** Search post-provisioning no longer deletes an index before updating it. Missing fields are merged into the current definition and submitted with an in-place `PUT`; operator-owned fields and existing documents are preserved. An incompatible same-name field fails before any update, and there is no delete/recreate fallback. + +### Security and operational guidance + +Keep `AUDIT_SENSITIVE_CONTENT_ENABLED=false` unless an explicit, reviewed capture policy is in place. Tokens, credentials, authorization material, cookies, connection strings, and configured redacted keys remain prohibited even when sensitive capture is enabled. The audit HMAC secret is intended for orchestrator pseudonymization only; ingestion does not consume it. + +`delete_after` records retention policy intent only. GPT-RAG does not schedule or perform automatic deletion from this field. Operators remain responsible for retention enforcement, confirmed deletion, telemetry access, and export controls. + +Deployments that explicitly disable Key Vault still receive the disabled governance defaults, but no audit HMAC key/reference is created. Audit events must remain disabled in that deployment mode. + +### Migration and rollback + +Upgrade the umbrella configuration and both pinned runtime components together, run post-provisioning to register the Key Vault reference and additive Search fields, verify metadata-only events in a non-production environment, and only then enable `AUDIT_EVENTS_ENABLED` or `INGESTION_PROVENANCE_ENABLED`. + +Rollback by setting both feature gates to `false` and redeploying prior component tags if needed. The additive Search fields can remain in place because older components ignore unknown fields. Post-provisioning never recreates an existing index for this migration. + +### Validation + +| Component | Version | +| --- | --- | +| gpt-rag-ui | v2.3.13 | +| gpt-rag-orchestrator | v3.8.0 | +| gpt-rag-ingestion | v2.5.0 | +| infra / AI Landing Zone | v2.3.0 | + +Validation evidence will be recorded on the implementation pull request before merge. No Azure deployment or release publication is part of this change. ## [v3.6.0] - 2026-07-19 diff --git a/config/governance/__init__.py b/config/governance/__init__.py new file mode 100644 index 00000000..9f67bed8 --- /dev/null +++ b/config/governance/__init__.py @@ -0,0 +1 @@ +"""Governance and audit deployment configuration.""" diff --git a/config/governance/setup.py b/config/governance/setup.py new file mode 100644 index 00000000..8ea61236 --- /dev/null +++ b/config/governance/setup.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Seed safe governance defaults and the audit pseudonymization key reference.""" + +from __future__ import annotations + +import base64 +import hmac +import json +import logging +import os +import secrets +from collections.abc import Mapping +from typing import Any + +from azure.appconfiguration import AzureAppConfigurationClient, ConfigurationSetting +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import ( + AzureCliCredential, + ChainedTokenCredential, + ManagedIdentityCredential, +) +from azure.keyvault.secrets import SecretClient + + +LABEL = "gpt-rag" +AUDIT_HMAC_CONFIG_KEY = "AUDIT_HMAC_KEY" +AUDIT_HMAC_SECRET_NAME = "AUDIT-HMAC-KEY" +KEY_VAULT_REFERENCE_CONTENT_TYPE = ( + "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8" +) + +DEFAULT_SETTINGS: Mapping[str, str] = { + "AUDIT_EVENTS_ENABLED": "false", + "AUDIT_SENSITIVE_CONTENT_ENABLED": "false", + "AUDIT_SENSITIVE_CONTENT_FIELDS": "", + "AUDIT_ACTOR_PSEUDONYM_ENABLED": "false", + "AUDIT_SOURCE_EVENT_LIMIT": "25", + "AUDIT_HMAC_KEY_ID": "v1", + "AUDIT_ADDITIONAL_REDACTED_KEYS": "", + "INGESTION_PROVENANCE_ENABLED": "false", + "INGESTION_REQUIRE_GOVERNANCE_METADATA": "false", + "INGESTION_DEFAULT_CLASSIFICATION": "unclassified", + "INGESTION_DEFAULT_RIGHT_TO_USE": "not_asserted", +} + + +def create_credential() -> ChainedTokenCredential: + return ChainedTokenCredential( + AzureCliCredential(process_timeout=30), + ManagedIdentityCredential(process_timeout=30), + ) + + +def generate_audit_hmac_key() -> str: + """Return a Base64URL encoding of exactly 256 random bits.""" + return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=") + + +def _decode_audit_hmac_key(value: str) -> bytes: + candidate = value.strip() + if len(candidate) == 64: + try: + decoded = bytes.fromhex(candidate) + except ValueError: + decoded = b"" + if len(decoded) == 32: + return decoded + + padded = candidate + ("=" * (-len(candidate) % 4)) + try: + decoded = base64.urlsafe_b64decode(padded.encode("ascii")) + except (ValueError, UnicodeEncodeError): + decoded = b"" + if len(decoded) != 32: + raise ValueError( + "The existing AUDIT-HMAC-KEY secret is not an encoding of exactly " + "32 bytes. Rotate it explicitly before enabling audit events." + ) + return decoded + + +def ensure_audit_hmac_secret( + secret_client: Any, + initial_value: str | None = None, +) -> bool: + """Create the key once and return whether a new secret version was written.""" + try: + current = secret_client.get_secret(AUDIT_HMAC_SECRET_NAME) + except ResourceNotFoundError: + value = initial_value or generate_audit_hmac_key() + _decode_audit_hmac_key(value) + secret_client.set_secret( + AUDIT_HMAC_SECRET_NAME, + value, + content_type="application/octet-stream", + ) + return True + + current_key = _decode_audit_hmac_key(current.value) + if initial_value is not None: + initial_key = _decode_audit_hmac_key(initial_value) + if not hmac.compare_digest(current_key, initial_key): + raise ValueError( + "The existing AUDIT-HMAC-KEY does not match the operator-managed " + "AUDIT_HMAC_KEY value. Resolve the conflict explicitly." + ) + return False + + +def key_vault_reference(vault_uri: str) -> str: + uri = f"{vault_uri.rstrip('/')}/secrets/{AUDIT_HMAC_SECRET_NAME}" + return json.dumps({"uri": uri}, separators=(",", ":")) + + +def resolve_vault_uri(environ: Mapping[str, str] = os.environ) -> str: + configured_uri = environ.get("KEY_VAULT_URI", "").strip() + if configured_uri: + return configured_uri.rstrip("/") + "/" + + vault_name = environ.get("KEY_VAULT_NAME", "").strip() + if vault_name: + return f"https://{vault_name}.vault.azure.net/" + + raise RuntimeError( + "KEY_VAULT_URI or KEY_VAULT_NAME is required to provision the audit " + "HMAC key reference." + ) + + +def seed_governance_settings( + app_config_client: Any, + environ: Mapping[str, str] = os.environ, +) -> None: + """Seed missing defaults while preserving operator-managed values.""" + for key, default in DEFAULT_SETTINGS.items(): + if key in environ: + value = environ[key] + else: + try: + app_config_client.get_configuration_setting(key=key, label=LABEL) + except ResourceNotFoundError: + value = default + else: + continue + + app_config_client.set_configuration_setting( + ConfigurationSetting( + key=key, + label=LABEL, + value=value, + content_type="text/plain", + ) + ) + + +def get_configuration_setting_or_none(app_config_client: Any, key: str) -> Any: + try: + return app_config_client.get_configuration_setting(key=key, label=LABEL) + except ResourceNotFoundError: + return None + + +def effective_setting( + app_config_client: Any, + key: str, + default: str, + environ: Mapping[str, str] = os.environ, +) -> str: + if key in environ: + return environ[key] + setting = get_configuration_setting_or_none(app_config_client, key) + return default if setting is None else str(setting.value) + + +def is_truthy(value: str) -> bool: + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def apply_governance_configuration( + app_config_client: Any, + secret_client: Any, + vault_uri: str, + environ: Mapping[str, str] = os.environ, +) -> bool: + """Apply plaintext defaults and a Key Vault reference without exposing the key.""" + seed_governance_settings(app_config_client, environ) + current_setting = get_configuration_setting_or_none( + app_config_client, + AUDIT_HMAC_CONFIG_KEY, + ) + desired_reference = key_vault_reference(vault_uri) + + if current_setting is not None and ( + current_setting.content_type or "" + ).startswith("application/vnd.microsoft.appconfig.keyvaultref+json"): + if current_setting.value == desired_reference: + return ensure_audit_hmac_secret(secret_client) + return False + + initial_value = None + if current_setting is not None and current_setting.value: + initial_value = str(current_setting.value) + _decode_audit_hmac_key(initial_value) + + created = ensure_audit_hmac_secret(secret_client, initial_value) + + app_config_client.set_configuration_setting( + ConfigurationSetting( + key=AUDIT_HMAC_CONFIG_KEY, + label=LABEL, + value=desired_reference, + content_type=KEY_VAULT_REFERENCE_CONTENT_TYPE, + ) + ) + return created + + +def main() -> None: + app_config_endpoint = os.environ.get("APP_CONFIG_ENDPOINT", "").strip() + if not app_config_endpoint: + raise RuntimeError("APP_CONFIG_ENDPOINT is required for governance setup.") + + credential = create_credential() + app_config_client = AzureAppConfigurationClient(app_config_endpoint, credential) + deploy_key_vault = os.environ.get("DEPLOY_KEY_VAULT", "true").strip().lower() + if deploy_key_vault in {"0", "false", "no", "off"}: + seed_governance_settings(app_config_client) + audit_enabled = effective_setting( + app_config_client, + "AUDIT_EVENTS_ENABLED", + "false", + ) + if is_truthy(audit_enabled): + raise RuntimeError( + "AUDIT_EVENTS_ENABLED cannot be true when Key Vault is disabled." + ) + logging.warning( + "Governance defaults were seeded, but AUDIT_HMAC_KEY was not " + "provisioned because Key Vault is disabled. Keep audit events disabled." + ) + return + + vault_uri = resolve_vault_uri() + secret_client = SecretClient(vault_url=vault_uri, credential=credential) + created = apply_governance_configuration( + app_config_client, + secret_client, + vault_uri, + ) + + action = "created" if created else "reused" + logging.info( + "Governance configuration applied: safe defaults written, audit HMAC " + "secret %s, and Key Vault reference registered.", + action, + ) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + main() diff --git a/config/governance/tests/__init__.py b/config/governance/tests/__init__.py new file mode 100644 index 00000000..7aa831ba --- /dev/null +++ b/config/governance/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for governance deployment configuration.""" diff --git a/config/governance/tests/test_setup.py b/config/governance/tests/test_setup.py new file mode 100644 index 00000000..842ddbf8 --- /dev/null +++ b/config/governance/tests/test_setup.py @@ -0,0 +1,271 @@ +import base64 +import hashlib +import json +from pathlib import Path +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import Mock, patch + +from azure.core.exceptions import ResourceNotFoundError + +from config.governance import setup + + +REPO_ROOT = Path(__file__).resolve().parents[3] +LOGICAL_CONTRACT_HASH = ( + "825db8ef40a81e2c19e5d80d37c565b6b47fc9a6540e9881d35cc12b8fde5aab" +) +WIRE_CONTRACT_HASH = ( + "066c8f5408610ab839d5121d06ca5bc59e8797e551d5c47c875c5ba52f7e0588" +) + + +class GovernanceSetupTests(TestCase): + def test_defaults_keep_audit_and_provenance_disabled(self): + self.assertEqual(setup.DEFAULT_SETTINGS["AUDIT_EVENTS_ENABLED"], "false") + self.assertEqual( + setup.DEFAULT_SETTINGS["AUDIT_SENSITIVE_CONTENT_ENABLED"], "false" + ) + self.assertEqual( + setup.DEFAULT_SETTINGS["INGESTION_PROVENANCE_ENABLED"], "false" + ) + self.assertEqual( + setup.DEFAULT_SETTINGS["INGESTION_REQUIRE_GOVERNANCE_METADATA"], + "false", + ) + self.assertEqual( + setup.DEFAULT_SETTINGS["INGESTION_DEFAULT_CLASSIFICATION"], + "unclassified", + ) + self.assertEqual( + setup.DEFAULT_SETTINGS["INGESTION_DEFAULT_RIGHT_TO_USE"], + "not_asserted", + ) + + def test_generated_key_contains_exactly_256_random_bits(self): + with patch.object(setup.secrets, "token_bytes", return_value=b"x" * 32) as token: + encoded = setup.generate_audit_hmac_key() + + token.assert_called_once_with(32) + decoded = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)) + self.assertEqual(decoded, b"x" * 32) + + def test_vault_uri_uses_azd_key_vault_name_output(self): + self.assertEqual( + setup.resolve_vault_uri({"KEY_VAULT_NAME": "kv-example"}), + "https://kv-example.vault.azure.net/", + ) + + def test_existing_valid_key_is_reused_without_rotation(self): + secret_client = Mock() + secret_client.get_secret.return_value = SimpleNamespace(value="ab" * 32) + + created = setup.ensure_audit_hmac_secret(secret_client) + + self.assertFalse(created) + secret_client.set_secret.assert_not_called() + + def test_missing_key_is_created_without_returning_or_logging_its_value(self): + secret_client = Mock() + secret_client.get_secret.side_effect = ResourceNotFoundError("missing") + + created = setup.ensure_audit_hmac_secret(secret_client) + + self.assertTrue(created) + secret_client.set_secret.assert_called_once() + name, value = secret_client.set_secret.call_args.args + self.assertEqual(name, setup.AUDIT_HMAC_SECRET_NAME) + self.assertEqual(len(base64.urlsafe_b64decode(value + "=")), 32) + + def test_invalid_existing_key_fails_instead_of_rotating_silently(self): + secret_client = Mock() + secret_client.get_secret.return_value = SimpleNamespace(value="too-short") + + with self.assertRaisesRegex(ValueError, "exactly 32 bytes"): + setup.ensure_audit_hmac_secret(secret_client) + + secret_client.set_secret.assert_not_called() + + def test_app_configuration_contains_defaults_and_only_a_key_vault_reference(self): + app_config_client = Mock() + app_config_client.get_configuration_setting.side_effect = ResourceNotFoundError( + "missing" + ) + secret_client = Mock() + secret_client.get_secret.return_value = SimpleNamespace(value="ab" * 32) + vault_uri = "https://kv-example.vault.azure.net/" + + setup.apply_governance_configuration( + app_config_client, + secret_client, + vault_uri, + ) + + settings = { + call.args[0].key: call.args[0] + for call in app_config_client.set_configuration_setting.call_args_list + } + self.assertEqual(set(settings), {*setup.DEFAULT_SETTINGS, "AUDIT_HMAC_KEY"}) + reference = settings["AUDIT_HMAC_KEY"] + self.assertEqual( + reference.content_type, + setup.KEY_VAULT_REFERENCE_CONTENT_TYPE, + ) + self.assertEqual( + reference.value, + '{"uri":"https://kv-example.vault.azure.net/secrets/AUDIT-HMAC-KEY"}', + ) + self.assertNotIn("ab" * 32, reference.value) + + def test_existing_operator_key_vault_reference_is_preserved(self): + app_config_client = Mock() + operator_reference = SimpleNamespace( + key="AUDIT_HMAC_KEY", + value='{"uri":"https://operator-kv.vault.azure.net/secrets/audit-key"}', + content_type=setup.KEY_VAULT_REFERENCE_CONTENT_TYPE, + ) + + def get_setting(*, key, label): + if key == "AUDIT_HMAC_KEY": + return operator_reference + raise ResourceNotFoundError("missing") + + app_config_client.get_configuration_setting.side_effect = get_setting + secret_client = Mock() + + created = setup.apply_governance_configuration( + app_config_client, + secret_client, + "https://platform-kv.vault.azure.net/", + ) + + self.assertFalse(created) + secret_client.get_secret.assert_not_called() + written_keys = { + call.args[0].key + for call in app_config_client.set_configuration_setting.call_args_list + } + self.assertNotIn("AUDIT_HMAC_KEY", written_keys) + + def test_plaintext_operator_key_is_migrated_without_rotation(self): + plaintext_key = "cd" * 32 + app_config_client = Mock() + + def get_setting(*, key, label): + if key == "AUDIT_HMAC_KEY": + return SimpleNamespace( + key=key, + value=plaintext_key, + content_type="text/plain", + ) + raise ResourceNotFoundError("missing") + + app_config_client.get_configuration_setting.side_effect = get_setting + secret_client = Mock() + secret_client.get_secret.side_effect = ResourceNotFoundError("missing") + + setup.apply_governance_configuration( + app_config_client, + secret_client, + "https://platform-kv.vault.azure.net/", + ) + + self.assertEqual(secret_client.set_secret.call_args.args[1], plaintext_key) + written = { + call.args[0].key: call.args[0] + for call in app_config_client.set_configuration_setting.call_args_list + } + self.assertEqual( + written["AUDIT_HMAC_KEY"].content_type, + setup.KEY_VAULT_REFERENCE_CONTENT_TYPE, + ) + + def test_key_vault_disabled_rejects_enabled_audit_setting(self): + app_config_client = Mock() + + def get_setting(*, key, label): + if key == "AUDIT_EVENTS_ENABLED": + return SimpleNamespace(value="true") + raise ResourceNotFoundError("missing") + + app_config_client.get_configuration_setting.side_effect = get_setting + with patch.object( + setup, + "AzureAppConfigurationClient", + return_value=app_config_client, + ), patch.dict( + setup.os.environ, + { + "APP_CONFIG_ENDPOINT": "https://config.azconfig.io", + "DEPLOY_KEY_VAULT": "false", + }, + clear=True, + ): + with self.assertRaisesRegex(RuntimeError, "Key Vault is disabled"): + setup.main() + + def test_existing_operator_settings_are_preserved_without_explicit_override(self): + app_config_client = Mock() + + setup.seed_governance_settings(app_config_client) + + app_config_client.get_configuration_setting.assert_called() + app_config_client.set_configuration_setting.assert_not_called() + + def test_explicit_environment_override_updates_existing_setting(self): + app_config_client = Mock() + + setup.seed_governance_settings( + app_config_client, + {"AUDIT_EVENTS_ENABLED": "true"}, + ) + + written = [ + call.args[0] + for call in app_config_client.set_configuration_setting.call_args_list + ] + self.assertEqual( + [(setting.key, setting.value) for setting in written], + [("AUDIT_EVENTS_ENABLED", "true")], + ) + + def test_shared_contract_files_match_v3_8_0_hashes(self): + logical = REPO_ROOT / "contracts" / "audit-event-v1.schema.json" + wire = ( + REPO_ROOT + / "contracts" + / "audit-event-v1.application-insights.schema.json" + ) + + self.assertEqual(hashlib.sha256(logical.read_bytes()).hexdigest(), LOGICAL_CONTRACT_HASH) + self.assertEqual(hashlib.sha256(wire.read_bytes()).hexdigest(), WIRE_CONTRACT_HASH) + + def test_deployment_surfaces_never_publish_the_secret_as_plaintext(self): + parameters = json.loads( + (REPO_ROOT / "main.parameters.json").read_text(encoding="utf-8") + ) + serialized_parameters = json.dumps(parameters) + self.assertNotIn("AUDIT_HMAC_KEY", serialized_parameters) + + for script_name in ("postProvision.ps1", "postProvision.sh"): + script = (REPO_ROOT / "scripts" / script_name).read_text(encoding="utf-8") + self.assertIn("config.governance.setup", script) + + def test_future_minor_manifest_pins_validated_component_pair(self): + manifest = json.loads( + (REPO_ROOT / "manifest.json").read_text(encoding="utf-8") + ) + component_versions = { + component["name"]: component["tag"] + for component in manifest["components"] + } + + self.assertEqual(manifest["tag"], "v3.7.0") + self.assertEqual( + component_versions["gpt-rag-orchestrator"], + "v3.8.0", + ) + self.assertEqual( + component_versions["gpt-rag-ingestion"], + "v2.5.0", + ) diff --git a/config/search/search.j2 b/config/search/search.j2 index d1920935..392a189e 100644 --- a/config/search/search.j2 +++ b/config/search/search.j2 @@ -56,8 +56,18 @@ { "name": "relatedImages", "type": "Collection(Edm.String)", "searchable": false, "retrievable": true }, { "name": "relatedFiles", "type": "Collection(Edm.String)", "searchable": false, "retrievable": true }, { "name": "source", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true }, - { "name": "contentVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": "{{EMBEDDINGS_VECTOR_DIMENSIONS}}", "vectorSearchProfile": "default" }, - { "name": "captionVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": "{{EMBEDDINGS_VECTOR_DIMENSIONS}}", "vectorSearchProfile": "default" } + { "name": "provenance_id", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true }, + { "name": "source_uri_id", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true }, + { "name": "source_version_id", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true }, + { "name": "content_checksum_sha256", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true }, + { "name": "ingested_at", "type": "Edm.DateTimeOffset", "searchable": false, "retrievable": true, "filterable": true, "sortable": true }, + { "name": "ingest_run_id", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true }, + { "name": "data_classification", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true, "facetable": true }, + { "name": "right_to_use", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true, "facetable": true }, + { "name": "retention_class", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true, "facetable": true }, + { "name": "delete_after", "type": "Edm.String", "searchable": false, "retrievable": true, "filterable": true, "sortable": true }, + { "name": "contentVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": {{EMBEDDINGS_VECTOR_DIMENSIONS}}, "vectorSearchProfile": "default" }, + { "name": "captionVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": {{EMBEDDINGS_VECTOR_DIMENSIONS}}, "vectorSearchProfile": "default" } ], "corsOptions": { "allowedOrigins": ["*"], @@ -128,7 +138,7 @@ { "name": "question", "type": "Edm.String", "searchable": true, "retrievable": true, "analyzer": "{{SEARCH_ANALYZER_NAME}}" }, { "name": "query", "type": "Edm.String", "searchable": false }, { "name": "reasoning", "type": "Edm.String", "searchable": true }, - { "name": "contentVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": "{{EMBEDDINGS_VECTOR_DIMENSIONS}}", "vectorSearchProfile": "default" } + { "name": "contentVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": {{EMBEDDINGS_VECTOR_DIMENSIONS}}, "vectorSearchProfile": "default" } ], "corsOptions": { "allowedOrigins": ["*"], @@ -205,7 +215,7 @@ { "name": "examples", "type": "Collection(Edm.String)" } ] }, - { "name": "contentVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": "{{EMBEDDINGS_VECTOR_DIMENSIONS}}", "vectorSearchProfile": "default" } + { "name": "contentVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": {{EMBEDDINGS_VECTOR_DIMENSIONS}}, "vectorSearchProfile": "default" } ], "corsOptions": { "allowedOrigins": ["*"], @@ -276,7 +286,7 @@ { "name": "source_table", "type": "Edm.String", "searchable": true, "filterable": true }, { "name": "data_type", "type": "Edm.String", "searchable": true }, { "name": "source_model", "type": "Edm.String", "searchable": true }, - { "name": "contentVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": "{{EMBEDDINGS_VECTOR_DIMENSIONS}}", "vectorSearchProfile": "default" } + { "name": "contentVector", "type": "Collection(Edm.Single)", "searchable": true, "retrievable": true, "dimensions": {{EMBEDDINGS_VECTOR_DIMENSIONS}}, "vectorSearchProfile": "default" } ], "corsOptions": { "allowedOrigins": ["*"], diff --git a/config/search/setup.py b/config/search/setup.py index f91066b0..c118240f 100644 --- a/config/search/setup.py +++ b/config/search/setup.py @@ -358,7 +358,7 @@ def render_and_parse_json(template_name_inner: str, ctx: dict) -> Optional[dict] return result, context # ── Azure Search API Call ─────────────────────────────────────────────────-- -def call_search_api(endpoint: str, api_version: str, rtype: str, rname: str, method: str, cred: ChainedTokenCredential, body: Any = None, max_retries: int = 3) -> bool: +def call_search_api(endpoint: str, api_version: str, rtype: str, rname: str, method: str, cred: ChainedTokenCredential, body: Any = None, max_retries: int = 3, if_match: Optional[str] = None, if_none_match: Optional[str] = None) -> bool: """ Call Azure Search REST API with retry logic for authentication failures. """ @@ -367,6 +367,10 @@ def call_search_api(endpoint: str, api_version: str, rtype: str, rname: str, met # Get fresh token on each attempt token = cred.get_token("https://search.azure.com/.default").token headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + if if_match: + headers["If-Match"] = if_match + if if_none_match: + headers["If-None-Match"] = if_none_match url = f"{endpoint}/{rtype}/{rname}?api-version={api_version}" resp = getattr(requests, method.lower())(url, headers=headers, json=body) @@ -387,12 +391,135 @@ def call_search_api(endpoint: str, api_version: str, rtype: str, rname: str, met return False else: logging.warning(f"⚠️ Attempt {attempt + 1}/{max_retries} failed for {method.upper()} {rtype}/{rname}: {e}") - logging.warning(f" Retrying in 2 seconds...") + logging.warning(" Retrying in 2 seconds...") time.sleep(2) # Continue to next attempt - cred.get_token() will be called again return False # Should never reach here, but just in case + +def get_search_resource( + endpoint: str, + api_version: str, + resource_type: str, + resource_name: str, + credential: ChainedTokenCredential, +) -> Tuple[Optional[dict], Optional[str]]: + """Return an existing Search resource and ETag, or two ``None`` values.""" + token = credential.get_token("https://search.azure.com/.default").token + response = requests.get( + f"{endpoint}/{resource_type}/{resource_name}?api-version={api_version}", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + if response.status_code == 404: + return None, None + if response.status_code >= 400: + raise RuntimeError( + f"GET {resource_type}/{resource_name} failed with " + f"HTTP {response.status_code}; no update was attempted." + ) + payload = response.json() + etag = response.headers.get("ETag") or payload.get("@odata.etag") + return strip_odata_metadata(payload), etag + + +_INDEX_FIELD_COMPATIBILITY_PROPERTIES = ( + "type", + "key", + "searchable", + "filterable", + "sortable", + "facetable", + "retrievable", + "analyzer", + "normalizer", + "dimensions", + "vectorSearchProfile", +) + + +def merge_additive_index_schema( + existing_index: dict, + desired_index: dict, +) -> Tuple[dict, list[str]]: + """Preserve the current index and append fields that are absent from it.""" + merged = strip_odata_metadata(existing_index) + existing_fields = { + field["name"]: field for field in merged.get("fields", []) + } + added_fields: list[str] = [] + + for desired_field in desired_index.get("fields", []): + name = desired_field["name"] + current_field = existing_fields.get(name) + if current_field is None: + merged.setdefault("fields", []).append(desired_field) + existing_fields[name] = desired_field + added_fields.append(name) + continue + + incompatible_properties = [] + for property_name in _INDEX_FIELD_COMPATIBILITY_PROPERTIES: + if property_name not in desired_field: + continue + current_value = current_field.get(property_name) + desired_value = desired_field[property_name] + if property_name == "dimensions": + try: + current_value = int(current_value) + desired_value = int(desired_value) + except (TypeError, ValueError): + incompatible_properties.append(property_name) + continue + if current_value != desired_value: + incompatible_properties.append(property_name) + if incompatible_properties: + properties = ", ".join(incompatible_properties) + raise ValueError( + f"Index field '{name}' is incompatible with the desired schema " + f"for properties: {properties}. The existing index was not modified." + ) + + return merged, added_fields + + +def prepare_index_updates( + definitions: dict, + credential: ChainedTokenCredential, + search_endpoint: str, + api_version: str, +) -> list[Tuple[str, Optional[dict], list[str], Optional[str], Optional[str]]]: + """Build every index update before any dependent resource is deleted.""" + updates = [] + for desired_index in definitions.get("indexes", []): + name = desired_index["name"] + existing, etag = get_search_resource( + search_endpoint, + api_version, + "indexes", + name, + credential, + ) + if existing is None: + body = desired_index + added_fields = [ + field["name"] for field in desired_index.get("fields", []) + ] + if_none_match = "*" + else: + body, added_fields = merge_additive_index_schema( + existing, + desired_index, + ) + if not added_fields: + body = None + if_none_match = None + updates.append((name, body, added_fields, etag, if_none_match)) + return updates + # ── Resource Provisioning ─────────────────────────────────────────────────-- def provision_datasources(defs: dict, context: dict, cred: ChainedTokenCredential, ds_to_indexers: dict, search_endpoint: str, api_version: str): logging.info("Creating datasources...") @@ -404,13 +531,59 @@ def provision_datasources(defs: dict, context: dict, cred: ChainedTokenCredentia call_search_api(search_endpoint, api_version, "datasources", name, "delete", cred) call_search_api(search_endpoint, api_version, "datasources", name, "put", cred, body) -def provision_indexes(defs: dict, context: dict, cred: ChainedTokenCredential, search_endpoint: str, api_version: str): - logging.info("Creating indexes...") - for idx in defs.get("indexes", []): - body = idx - name = body["name"] - call_search_api(search_endpoint, api_version, "indexes", name, "delete", cred) - call_search_api(search_endpoint, api_version, "indexes", name, "put", cred, body) +def provision_indexes( + defs: dict, + context: dict, + cred: ChainedTokenCredential, + search_endpoint: str, + api_version: str, + prepared_updates: Optional[ + list[ + Tuple[ + str, + Optional[dict], + list[str], + Optional[str], + Optional[str], + ] + ] + ] = None, +): + logging.info("Creating or additively updating indexes...") + updates = prepared_updates or prepare_index_updates( + defs, + cred, + search_endpoint, + api_version, + ) + for name, body, added_fields, etag, if_none_match in updates: + if body is None: + logging.info( + "✅ Index '%s' already contains the desired fields; skipping update.", + name, + ) + continue + + if not call_search_api( + search_endpoint, + api_version, + "indexes", + name, + "put", + cred, + body, + if_match=etag, + if_none_match=if_none_match, + ): + raise RuntimeError( + f"Additive update failed for index '{name}'. The existing index " + "was not deleted." + ) + logging.info( + "✅ Index '%s' updated in place with %d field(s).", + name, + len(added_fields), + ) def provision_skillsets(defs: dict, context: dict, cred: ChainedTokenCredential, search_endpoint: str, api_version: str): logging.info("Creating skillsets...") @@ -753,12 +926,32 @@ def execute_setup(defs: Optional[dict], context: dict): logging.error("❗️ SEARCH_API_VERSION not found in search.env; skipping Azure Search setup.") return + # Validate and prepare every index update before deleting any dependent + # resources. Unsupported schema changes and transient reads fail without + # disturbing the current knowledge bases, sources, indexers, or documents. + prepared_index_updates = prepare_index_updates( + defs, + cred, + search_endpoint, + api_version, + ) + + # Apply index changes while all dependent resources are still intact. A + # conditional-write failure leaves the current Search topology untouched. + provision_indexes( + defs, + context, + cred, + search_endpoint, + api_version, + prepared_index_updates, + ) + # Step 1: Clean up knowledge base resources in correct order (KB -> KS) cleanup_knowledge_resources(defs, context, cred, search_endpoint) - # Step 2: Provision standard search resources (now indexes can be deleted safely) + # Step 2: Reconcile the remaining standard Search resources. provision_datasources(defs, context, cred, ds_to_indexers, search_endpoint, api_version) - provision_indexes(defs, context, cred, search_endpoint, api_version) provision_skillsets(defs, context, cred, search_endpoint, api_version) provision_indexers(defs, context, cred, search_endpoint, api_version) diff --git a/config/search/tests/test_foundry_iq_templates.py b/config/search/tests/test_foundry_iq_templates.py index db06f082..313afaba 100644 --- a/config/search/tests/test_foundry_iq_templates.py +++ b/config/search/tests/test_foundry_iq_templates.py @@ -17,7 +17,12 @@ TEMPLATE_DIR = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[3] FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" -MCP_RUNTIME_CONTRACT_VERSION = "v3.7.0" +MCP_RUNTIME_CONTRACT_MIN_VERSION = "v3.7.0" +MCP_FIXTURE_CONTRACT_VERSION = "v3.7.0" + + +def version_tuple(tag): + return tuple(int(part) for part in tag.removeprefix("v").split(".")) def render_json_template(template_name, context): @@ -645,7 +650,7 @@ def test_web_grounding_enabled_without_domains_emits_empty_lists(self): {"domains": {"allowedDomains": [], "blockedDomains": []}}, ) - + class FoundryIqMcpTemplateTests(unittest.TestCase): """Generic MCP Server knowledge sources are opt-in and default-off. Rendered output must be unchanged when the feature is disabled. @@ -905,11 +910,14 @@ def test_canonical_fixture_shared_with_runtime_validates_and_renders(self): for component in manifest["components"] if component["name"] == "gpt-rag-orchestrator" ) - self.assertEqual(orchestrator["tag"], MCP_RUNTIME_CONTRACT_VERSION) + self.assertGreaterEqual( + version_tuple(orchestrator["tag"]), + version_tuple(MCP_RUNTIME_CONTRACT_MIN_VERSION), + ) fixture_path = ( FIXTURE_DIR - / f"foundry_iq_mcp_canonical_source_{MCP_RUNTIME_CONTRACT_VERSION.removeprefix('v').replace('.', '_')}.json" + / f"foundry_iq_mcp_canonical_source_{MCP_FIXTURE_CONTRACT_VERSION.removeprefix('v').replace('.', '_')}.json" ) source = json.loads(fixture_path.read_text(encoding="utf-8")) context["FOUNDRY_IQ_MCP_TRUSTED_HOSTS"] = "mcp.contoso.com" diff --git a/config/search/tests/test_governance_schema.py b/config/search/tests/test_governance_schema.py new file mode 100644 index 00000000..d7894d8b --- /dev/null +++ b/config/search/tests/test_governance_schema.py @@ -0,0 +1,261 @@ +import json +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +from jinja2 import Environment, FileSystemLoader, StrictUndefined + +from config.search import setup + + +TEMPLATE_DIR = Path(__file__).resolve().parents[1] +PROVENANCE_FIELDS = { + "provenance_id": ("Edm.String", {"filterable": True}), + "source_uri_id": ("Edm.String", {"filterable": True}), + "source_version_id": ("Edm.String", {"filterable": True}), + "content_checksum_sha256": ("Edm.String", {"filterable": True}), + "ingested_at": ( + "Edm.DateTimeOffset", + {"filterable": True, "sortable": True}, + ), + "ingest_run_id": ("Edm.String", {"filterable": True}), + "data_classification": ( + "Edm.String", + {"filterable": True, "facetable": True}, + ), + "right_to_use": ("Edm.String", {"filterable": True, "facetable": True}), + "retention_class": ( + "Edm.String", + {"filterable": True, "facetable": True}, + ), + "delete_after": ( + "Edm.String", + {"filterable": True, "sortable": True}, + ), +} + + +def render(template_name, context): + environment = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + undefined=StrictUndefined, + ) + return json.loads(environment.get_template(template_name).render(**context)) + + +def search_context(): + settings = render( + "search.settings.j2", + { + "RESOURCE_TOKEN": "abc123", + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "AI_FOUNDRY_ACCOUNT_NAME": "aif-abc123", + "RETRIEVAL_BACKEND": "foundry_iq", + }, + ) + return { + **settings, + "STORAGE_ACCOUNT_RESOURCE_ID": ( + "/subscriptions/s/resourceGroups/rg/providers/" + "Microsoft.Storage/storageAccounts/st" + ), + "EMBEDDING_MODEL_INFO": { + "endpoint": "https://aif-abc123.openai.azure.com/", + "deployment_name": "text-embedding", + "model_name": "text-embedding-3-large", + }, + "GPT_MODEL_INFO": { + "deployment_name": "chat", + "model_name": "gpt-5.2", + "model_format": "OpenAI", + }, + } + + +class GovernanceSearchSchemaTests(unittest.TestCase): + def test_rag_index_contains_provenance_fields_with_safe_types(self): + definitions = render("search.j2", search_context()) + rag_index = definitions["indexes"][0] + fields = {field["name"]: field for field in rag_index["fields"]} + self.assertIsInstance(fields["contentVector"]["dimensions"], int) + + for name, (expected_type, expected_attributes) in PROVENANCE_FIELDS.items(): + with self.subTest(name=name): + self.assertEqual(fields[name]["type"], expected_type) + self.assertTrue(fields[name]["retrievable"]) + self.assertFalse(fields[name]["searchable"]) + for attribute, expected_value in expected_attributes.items(): + self.assertEqual(fields[name][attribute], expected_value) + + def test_additive_merge_preserves_documents_and_custom_fields(self): + existing = { + "@odata.etag": '"etag"', + "name": "rag-index", + "fields": [ + {"name": "id", "type": "Edm.String", "key": True}, + {"name": "operator_custom", "type": "Edm.String"}, + ], + "semantic": {"configurations": [{"name": "operator-owned"}]}, + } + desired = { + "name": "rag-index", + "fields": [ + {"name": "id", "type": "Edm.String", "key": True}, + { + "name": "provenance_id", + "type": "Edm.String", + "filterable": True, + }, + ], + } + + merged, added = setup.merge_additive_index_schema(existing, desired) + + self.assertEqual(added, ["provenance_id"]) + self.assertNotIn("@odata.etag", merged) + self.assertIn( + "operator_custom", + {field["name"] for field in merged["fields"]}, + ) + self.assertEqual( + merged["semantic"], + {"configurations": [{"name": "operator-owned"}]}, + ) + + def test_incompatible_existing_field_fails_without_mutation(self): + existing = { + "name": "rag-index", + "fields": [ + {"name": "delete_after", "type": "Edm.DateTimeOffset"} + ], + } + desired = { + "name": "rag-index", + "fields": [ + {"name": "delete_after", "type": "Edm.String"} + ], + } + + with self.assertRaisesRegex(ValueError, "existing index was not modified"): + setup.merge_additive_index_schema(existing, desired) + + self.assertEqual(existing["fields"][0]["type"], "Edm.DateTimeOffset") + + def test_existing_index_is_updated_with_put_and_never_deleted(self): + credential = Mock() + existing = { + "name": "rag-index", + "fields": [{"name": "id", "type": "Edm.String", "key": True}], + } + desired = { + "name": "rag-index", + "fields": [ + {"name": "id", "type": "Edm.String", "key": True}, + { + "name": "provenance_id", + "type": "Edm.String", + "filterable": True, + }, + ], + } + + with patch.object( + setup, + "get_search_resource", + return_value=(existing, '"etag"'), + ), patch.object(setup, "call_search_api", return_value=True) as call: + setup.provision_indexes( + {"indexes": [desired]}, + {}, + credential, + "https://search.search.windows.net", + "2025-09-01", + ) + + call.assert_called_once() + self.assertEqual(call.call_args.args[4], "put") + self.assertEqual(call.call_args.kwargs["if_match"], '"etag"') + self.assertIsNone(call.call_args.kwargs["if_none_match"]) + self.assertNotIn( + "delete", + [argument for argument in call.call_args.args if isinstance(argument, str)], + ) + + def test_schema_preflight_fails_before_dependent_cleanup(self): + definitions = {"indexes": [{"name": "rag-index", "fields": []}]} + context = { + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "SEARCH_API_VERSION": "2025-09-01", + } + + with patch.object( + setup, + "prepare_index_updates", + side_effect=ValueError("incompatible"), + ), patch.object(setup, "cleanup_knowledge_resources") as cleanup: + with self.assertRaisesRegex(ValueError, "incompatible"): + setup.execute_setup(definitions, context) + + cleanup.assert_not_called() + + def test_missing_index_uses_conditional_create(self): + desired = { + "name": "rag-index", + "fields": [{"name": "id", "type": "Edm.String", "key": True}], + } + + with patch.object( + setup, + "get_search_resource", + return_value=(None, None), + ), patch.object(setup, "call_search_api", return_value=True) as call: + setup.provision_indexes( + {"indexes": [desired]}, + {}, + Mock(), + "https://search.search.windows.net", + "2025-09-01", + ) + + self.assertEqual(call.call_args.kwargs["if_none_match"], "*") + self.assertIsNone(call.call_args.kwargs["if_match"]) + + def test_index_update_runs_before_dependent_cleanup(self): + definitions = {"indexes": [{"name": "rag-index", "fields": []}]} + context = { + "SEARCH_SERVICE_QUERY_ENDPOINT": "https://search.search.windows.net", + "SEARCH_API_VERSION": "2025-09-01", + } + events = [] + prepared = [("rag-index", None, [], '"etag"', None)] + + with patch.object( + setup, + "prepare_index_updates", + return_value=prepared, + ), patch.object( + setup, + "provision_indexes", + side_effect=lambda *args: events.append("indexes"), + ), patch.object( + setup, + "cleanup_knowledge_resources", + side_effect=lambda *args: events.append("cleanup"), + ), patch.object(setup, "provision_datasources"), patch.object( + setup, "provision_skillsets" + ), patch.object(setup, "provision_indexers"), patch.object( + setup, "filter_work_iq_sources" + ), patch.object( + setup, "provision_knowledge_sources", return_value=True + ), patch.object( + setup, "enforce_private_execution_for_generated_indexers" + ), patch.object( + setup, "provision_knowledge_bases", return_value=True + ): + setup.execute_setup(definitions, context) + + self.assertEqual(events, ["indexes", "cleanup"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 00000000..819db1af --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,13 @@ +# Audit event v1 contract + +These schemas are the shared GPT-RAG audit contract consumed by orchestrator +v3.8.0 and ingestion v2.5.0. + +`audit-event-v1.sha256` pins the exact LF-encoded bytes: + +- Logical schema: `825db8ef40a81e2c19e5d80d37c565b6b47fc9a6540e9881d35cc12b8fde5aab` +- Application Insights wire schema: `066c8f5408610ab839d5121d06ca5bc59e8797e551d5c47c875c5ba52f7e0588` + +Consumers must use `schema_version` when interpreting events and ignore unknown +optional fields. Technical audit evidence supports operator governance work but +does not establish legal or regulatory compliance. diff --git a/contracts/audit-event-v1.application-insights.schema.json b/contracts/audit-event-v1.application-insights.schema.json new file mode 100644 index 00000000..86e90f3d --- /dev/null +++ b/contracts/audit-event-v1.application-insights.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/GPT-RAG/contracts/audit-event-v1.application-insights.schema.json", + "title": "GPT-RAG audit event v1 Application Insights wire shape", + "description": "Azure Monitor represents custom-event property values as strings and drops null custom properties. Parse and type the properties according to audit-event-v1.schema.json, decode the root parent sentinel to logical null, then perform logical contract validation.", + "type": "object", + "additionalProperties": false, + "required": ["name", "properties"], + "properties": { + "name": { + "type": "string", + "enum": [ + "gptrag.audit.request.started", + "gptrag.audit.request.completed", + "gptrag.audit.request.failed", + "gptrag.audit.request.cancelled", + "gptrag.audit.route.selected", + "gptrag.audit.grounding.source.selected", + "gptrag.audit.grounding.source.rejected", + "gptrag.audit.tool.invocation.started", + "gptrag.audit.tool.invocation.completed", + "gptrag.audit.tool.invocation.failed", + "gptrag.audit.tool.invocation.cancelled", + "gptrag.audit.outcome.produced", + "gptrag.audit.outcome.rejected", + "gptrag.audit.audit.emission.failed", + "gptrag.audit.ingestion.run.started", + "gptrag.audit.ingestion.run.completed", + "gptrag.audit.ingestion.run.failed", + "gptrag.audit.ingestion.run.cancelled", + "gptrag.audit.ingestion.document.indexed", + "gptrag.audit.ingestion.document.rejected", + "gptrag.audit.ingestion.document.deleted" + ] + }, + "properties": { + "type": "object", + "maxProperties": 64, + "required": [ + "schema_version", + "event_id", + "event_type", + "event_time_utc", + "correlation_id", + "trace_id", + "span_id", + "parent_event_id", + "service_name", + "service_version", + "environment", + "operation", + "status", + "reason_code", + "capture_mode", + "redaction_applied", + "omitted_fields", + "truncated_fields" + ], + "properties": { + "event_type": { + "type": "string", + "enum": [ + "request.started", + "request.completed", + "request.failed", + "request.cancelled", + "route.selected", + "grounding.source.selected", + "grounding.source.rejected", + "tool.invocation.started", + "tool.invocation.completed", + "tool.invocation.failed", + "tool.invocation.cancelled", + "outcome.produced", + "outcome.rejected", + "audit.emission.failed", + "ingestion.run.started", + "ingestion.run.completed", + "ingestion.run.failed", + "ingestion.run.cancelled", + "ingestion.document.indexed", + "ingestion.document.rejected", + "ingestion.document.deleted" + ] + }, + "parent_event_id": { + "type": "string", + "pattern": "^evt_[0-9a-f]{32}$", + "description": "Wire parent event ID. Exporters must encode logical null as evt_00000000000000000000000000000000 because the pinned Azure Monitor exporter drops null custom properties. Consumers must decode this sentinel back to null and must never join it as an event." + } + }, + "additionalProperties": { + "type": "string", + "maxLength": 16384 + } + } + } +} diff --git a/contracts/audit-event-v1.schema.json b/contracts/audit-event-v1.schema.json new file mode 100644 index 00000000..44c65c1a --- /dev/null +++ b/contracts/audit-event-v1.schema.json @@ -0,0 +1,208 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Azure/GPT-RAG/contracts/audit-event-v1.schema.json", + "title": "GPT-RAG audit event v1", + "description": "Shared logical audit event contract owned by Azure/GPT-RAG. Readers of major version 1 must ignore unknown optional fields. Logical root events use a null parent_event_id; the Azure Monitor wire sentinel is not a logical event ID.", + "type": "object", + "additionalProperties": true, + "maxProperties": 64, + "required": [ + "schema_version", + "event_id", + "event_type", + "event_time_utc", + "correlation_id", + "trace_id", + "span_id", + "parent_event_id", + "service_name", + "service_version", + "environment", + "operation", + "status", + "reason_code", + "capture_mode", + "redaction_applied", + "omitted_fields", + "truncated_fields" + ], + "properties": { + "schema_version": { "const": 1 }, + "event_id": { + "type": "string", + "pattern": "^evt_[0-9a-f]{32}$", + "not": { "const": "evt_00000000000000000000000000000000" }, + "description": "Logical event ID. The all-zero Azure Monitor wire sentinel is reserved and cannot identify an event." + }, + "event_type": { + "enum": [ + "request.started", + "request.completed", + "request.failed", + "request.cancelled", + "route.selected", + "grounding.source.selected", + "grounding.source.rejected", + "tool.invocation.started", + "tool.invocation.completed", + "tool.invocation.failed", + "tool.invocation.cancelled", + "outcome.produced", + "outcome.rejected", + "audit.emission.failed", + "ingestion.run.started", + "ingestion.run.completed", + "ingestion.run.failed", + "ingestion.run.cancelled", + "ingestion.document.indexed", + "ingestion.document.rejected", + "ingestion.document.deleted" + ] + }, + "event_time_utc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$" + }, + "correlation_id": { "type": "string", "pattern": "^req_[0-9a-f]{32}$" }, + "trace_id": { "type": "string", "pattern": "^[0-9a-f]{32}$" }, + "span_id": { "type": "string", "pattern": "^[0-9a-f]{16}$" }, + "parent_event_id": { + "type": ["string", "null"], + "pattern": "^evt_[0-9a-f]{32}$", + "not": { "const": "evt_00000000000000000000000000000000" }, + "description": "Logical parent event ID. Null identifies a root event. Exporters encode null as the reserved all-zero event ID only in the Azure Monitor wire shape." + }, + "service_name": { "type": "string", "maxLength": 512 }, + "service_version": { "type": "string", "maxLength": 512 }, + "environment": { "type": "string", "maxLength": 64 }, + "operation": { "type": "string", "maxLength": 512 }, + "status": { + "enum": [ + "started", + "completed", + "failed", + "cancelled", + "selected", + "rejected", + "produced" + ] + }, + "reason_code": { + "enum": [ + "none", + "request_received", + "request_completed", + "request_failed", + "request_cancelled", + "client_disconnected", + "partial_output", + "strategy_configured", + "direct_model_selected", + "agent_selected", + "source_selected", + "source_rejected", + "source_empty", + "source_limit_reached", + "tool_invoked", + "tool_completed", + "tool_failed", + "tool_cancelled", + "timeout", + "outcome_produced", + "outcome_rejected", + "validation_failed", + "redaction_failure", + "serialization_failure", + "event_too_large", + "attribute_limit_exceeded", + "export_failure", + "unknown" + ] + }, + "capture_mode": { "enum": ["metadata_only", "sensitive_allowlist"] }, + "redaction_applied": { "type": "boolean" }, + "omitted_fields": { + "type": "array", + "maxItems": 32, + "items": { "type": "string", "maxLength": 512 } + }, + "truncated_fields": { + "type": "array", + "maxItems": 32, + "items": { "type": "string", "maxLength": 512 } + }, + "started_at_utc": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6}Z$" + }, + "duration_ms": { "type": "number", "minimum": 0, "maximum": 86400000 }, + "decision_type": { "type": "string", "maxLength": 512 }, + "decision_value": { "type": "string", "maxLength": 512 }, + "source_id": { "type": ["string", "null"], "maxLength": 512 }, + "source_type": { "type": "string", "maxLength": 512 }, + "source_rank": { "type": "integer", "minimum": 0 }, + "tool_name": { "type": "string", "maxLength": 512 }, + "tool_id": { "type": ["string", "null"], "maxLength": 512 }, + "tool_invocation_id": { "type": ["string", "null"], "maxLength": 512 }, + "outcome_type": { "type": "string", "maxLength": 512 }, + "failure_type": { "type": "string", "maxLength": 512 }, + "input_count": { "type": "integer", "minimum": 0 }, + "output_count": { "type": "integer", "minimum": 0 }, + "source_count": { "type": "integer", "minimum": 0 }, + "partial_output": { "type": "boolean" }, + "http_status_code": { "type": "integer", "minimum": 100, "maximum": 599 }, + "transport": { "type": "string", "maxLength": 512 }, + "actor_id": { "type": ["string", "null"], "maxLength": 512 }, + "conversation_id": { "type": ["string", "null"], "maxLength": 512 }, + "question_id": { "type": ["string", "null"], "maxLength": 512 }, + "thread_id": { "type": ["string", "null"], "maxLength": 512 }, + "hmac_key_id": { "type": "string", "maxLength": 512 }, + "timing_source": { + "enum": ["observed", "reconstructed"], + "description": "How the activity start time was obtained. Reconstructed timestamps are approximate." + }, + "audit_events_omitted": { + "type": "integer", + "minimum": 0, + "description": "Number of audit detail events omitted during this request." + }, + "source_events_omitted": { + "type": "integer", + "minimum": 0, + "description": "Number of grounding-source events omitted during this request." + }, + "tool_invocations_omitted": { + "type": "integer", + "minimum": 0, + "description": "Number of complete tool invocation event pairs omitted during this request." + }, + "prompt": { "type": "string", "maxLength": 2048 }, + "response": { "type": "string", "maxLength": 2048 }, + "source_excerpt": { "type": "string", "maxLength": 2048 }, + "tool_arguments": { "type": "string", "maxLength": 2048 }, + "tool_result": { "type": "string", "maxLength": 2048 } + }, + "allOf": [ + { + "if": { + "properties": { + "event_type": { + "enum": [ + "request.completed", + "request.failed", + "request.cancelled", + "tool.invocation.completed", + "tool.invocation.failed", + "tool.invocation.cancelled", + "ingestion.run.completed", + "ingestion.run.failed", + "ingestion.run.cancelled" + ] + } + }, + "required": ["event_type"] + }, + "then": { "required": ["started_at_utc", "duration_ms"] } + } + ] +} diff --git a/contracts/audit-event-v1.sha256 b/contracts/audit-event-v1.sha256 new file mode 100644 index 00000000..a7a40b2c --- /dev/null +++ b/contracts/audit-event-v1.sha256 @@ -0,0 +1,2 @@ +825db8ef40a81e2c19e5d80d37c565b6b47fc9a6540e9881d35cc12b8fde5aab audit-event-v1.schema.json +066c8f5408610ab839d5121d06ca5bc59e8797e551d5c47c875c5ba52f7e0588 audit-event-v1.application-insights.schema.json diff --git a/manifest.json b/manifest.json index f908592b..975f3b4c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "tag": "v3.6.0", + "tag": "v3.7.0", "repo": "https://github.com/azure/gpt-rag.git", "ailz_tag": "v2.3.0", "components": [ @@ -11,12 +11,12 @@ { "name": "gpt-rag-orchestrator", "repo": "https://github.com/azure/gpt-rag-orchestrator.git", - "tag": "v3.7.0" + "tag": "v3.8.0" }, { "name": "gpt-rag-ingestion", "repo": "https://github.com/azure/gpt-rag-ingestion.git", - "tag": "v2.4.14" + "tag": "v2.5.0" } ] } \ No newline at end of file diff --git a/scripts/postProvision.ps1 b/scripts/postProvision.ps1 index cf512bde..fdc2397a 100755 --- a/scripts/postProvision.ps1 +++ b/scripts/postProvision.ps1 @@ -654,7 +654,20 @@ Invoke-NativeCommand { & python -m pip install -r config/requirements.txt } if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } #------------------------------------------------------------------------------- -# 1) AI Foundry Setup +# 1) Governance and audit configuration +#------------------------------------------------------------------------------- +if (-not $missing.Contains('APP_CONFIG_ENDPOINT')) { + Write-Host "`n🔐 Governance and audit configuration..." + Write-Host "🚀 Running config.governance.setup..." + Invoke-PythonModule -ModuleName 'config.governance.setup' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host "✅ Governance and audit configuration finished." +} else { + Write-Host "⏭️ Skipping governance setup (missing APP_CONFIG_ENDPOINT)." +} + +#------------------------------------------------------------------------------- +# 2) AI Foundry Setup #------------------------------------------------------------------------------- if (-not $missing.Contains('APP_CONFIG_ENDPOINT')) { Write-Host "`n📑 AI Foundry Setup..." @@ -667,7 +680,7 @@ if (-not $missing.Contains('APP_CONFIG_ENDPOINT')) { } #------------------------------------------------------------------------------- -# 2) Container Apps Setup +# 3) Container Apps Setup #------------------------------------------------------------------------------- if (-not $missing.Contains('APP_CONFIG_ENDPOINT')) { Write-Host "`n🔍 ContainerApp setup..." @@ -680,7 +693,7 @@ if (-not $missing.Contains('APP_CONFIG_ENDPOINT')) { } #------------------------------------------------------------------------------- -# 3) AI Search Setup +# 4) AI Search Setup #------------------------------------------------------------------------------- if (-not $missing.Contains('APP_CONFIG_ENDPOINT')) { Write-Host "🔍 AI Search setup..." diff --git a/scripts/postProvision.sh b/scripts/postProvision.sh index 34ea39da..6dc51be0 100755 --- a/scripts/postProvision.sh +++ b/scripts/postProvision.sh @@ -110,7 +110,15 @@ pip install --upgrade pip pip install -r config/requirements.txt ############################################################################### -# 1) AI Foundry Setup +# 1) Governance and audit configuration +############################################################################### +echo +echo "🔐 Governance and audit configuration…" +python -m config.governance.setup +echo "✅ Governance and audit configuration finished." + +############################################################################### +# 2) AI Foundry Setup ############################################################################### echo echo "📑 AI Foundry Setup…" @@ -123,7 +131,7 @@ echo "📑 AI Foundry Setup…" } ############################################################################### -# 2) Container Apps Setup +# 3) Container Apps Setup ############################################################################### echo echo "🔍 ContainerApp setup…" @@ -136,7 +144,7 @@ echo "🔍 ContainerApp setup…" } ############################################################################### -# 3) AI Search Setup +# 4) AI Search Setup ############################################################################### echo echo "🔍 AI Search setup…" From fa4d270ff8b424207d328c114d8fb8e0d8b9e833 Mon Sep 17 00:00:00 2001 From: Paulo Lacerda Date: Tue, 21 Jul 2026 10:29:15 -0300 Subject: [PATCH 6/6] chore(release): prepare v3.7.0 Finalize the governance release notes and record the completed static validation and blocked runtime evidence without overstating deployment coverage.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ceb17c..82ca0bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -## [Unreleased] +## [v3.7.0] - 2026-07-21 ### User and operator impact -Prepares the next minor GPT-RAG release to deploy [orchestrator `v3.8.0`](https://github.com/Azure/gpt-rag-orchestrator/releases/tag/v3.8.0) and [ingestion `v2.5.0`](https://github.com/Azure/gpt-rag-ingestion/releases/tag/v2.5.0) as one validated governance baseline. Metadata audit events and ingestion provenance remain disabled by default, sensitive-content capture remains independently disabled, and existing deployments do not change behavior until an operator opts in. +GPT-RAG `v3.7.0` delivers the governance baseline tracked in [issue #571](https://github.com/Azure/GPT-RAG/issues/571) through [implementation PR #573](https://github.com/Azure/GPT-RAG/pull/573) and [documentation PR #574](https://github.com/Azure/GPT-RAG/pull/574), with [orchestrator `v3.8.0`](https://github.com/Azure/gpt-rag-orchestrator/releases/tag/v3.8.0) and [ingestion `v2.5.0`](https://github.com/Azure/gpt-rag-ingestion/releases/tag/v2.5.0) deployed as one pinned combination. Metadata audit events and ingestion provenance remain disabled by default, sensitive-content capture remains independently disabled, and existing deployments do not change behavior until an operator opts in. The shared `audit-event-v1` contract is pinned to logical hash `825db8ef40a81e2c19e5d80d37c565b6b47fc9a6540e9881d35cc12b8fde5aab` and Application Insights wire hash `066c8f5408610ab839d5121d06ca5bc59e8797e551d5c47c875c5ba52f7e0588`. These audit events provide technical evidence for adopter governance and incident response; they do not establish legal or regulatory compliance. @@ -17,7 +17,7 @@ The shared `audit-event-v1` contract is pinned to logical hash `825db8ef40a81e2c ### Changed -- **Future minor release manifest.** `manifest.json` identifies the planned umbrella `v3.7.0` combination and pins orchestrator `v3.8.0` plus ingestion `v2.5.0`; UI `v2.3.13` and infra `v2.3.0` remain unchanged. No release is published by this change. +- **Release manifest.** `manifest.json` identifies the umbrella `v3.7.0` combination and pins orchestrator `v3.8.0` plus ingestion `v2.5.0`; UI `v2.3.13` and infra `v2.3.0` remain unchanged. - **Non-destructive existing-index migration.** Search post-provisioning no longer deletes an index before updating it. Missing fields are merged into the current definition and submitted with an in-place `PUT`; operator-owned fields and existing documents are preserved. An incompatible same-name field fails before any update, and there is no delete/recreate fallback. ### Security and operational guidance @@ -43,7 +43,9 @@ Rollback by setting both feature gates to `false` and redeploying prior componen | gpt-rag-ingestion | v2.5.0 | | infra / AI Landing Zone | v2.3.0 | -Validation evidence will be recorded on the implementation pull request before merge. No Azure deployment or release publication is part of this change. +- GPT-RAG governance and Search suites: 131 tests passed. +- Bicep compilation, template rendering, static RBAC checks, manifest and component-release checks, and create-only Azure previews passed. +- Runtime provisioning could not complete. Azure platform capacity blocked Azure AI Search in `eastus2` and the Container Apps managed environment in `swedencentral`; the Sweden Central attempt also exposed an ARM nested deployment name over the 64-character limit. No runtime endpoints, application health checks, live governance defaults, Search migration result, or Application Insights/KQL evidence were obtained. ## [v3.6.0] - 2026-07-19