From 11a6dcb1b90c6c74b40eba1834133852537f5d6e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 29 Sep 2026 21:51:58 -0400 Subject: [PATCH 1/4] Explain missing Entra directory access in orchestration Orchestrated web search, linked-page, deep research, agent and action steps reread the user's app roles from Microsoft Graph with the application's own identity. Without the Directory.Read.All application permission Graph answers 403, which surfaced as "A required retained result is unavailable or changed." - Report application-side directory refusals with their own reasons and the directory_access_unavailable step code, telling users which source could not be verified and to contact their administrator. - Warn administrators in Admin Settings (Chat Orchestration and Web Search) and with a once-per-day admin notification, with an admin-only check route. - Request and consent to Directory.Read.All in the Bicep/azd, Azure CLI and Terraform deployers (deployer version 1.0.33). - Add tests, fix documentation, admin docs and release notes (0.261.204). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_notifications.py | 8 +- .../functions_orchestration_bootstrap.py | 58 +- .../functions_orchestration_composition.py | 9 +- ...unctions_orchestration_directory_access.py | 50 + ...tions_orchestration_directory_readiness.py | 351 ++++ .../functions_orchestration_executor.py | 37 +- ...nctions_orchestration_external_identity.py | 25 +- ...ctions_orchestration_invocation_capture.py | 27 +- .../functions_orchestration_schema.py | 21 + .../route_frontend_admin_settings.py | 25 + .../admin_orchestration_directory_access.js | 179 ++ .../admin/_panes/chat-orchestration.html | 41 + .../templates/admin/_panes/web-research.html | 8 + .../single_app/templates/admin_settings.html | 1 + application/v2_ui/src/lib/notifications.ts | 3 + deployers/Initialize-EntraApplication.ps1 | 134 +- deployers/azurecli/README.md | 2 +- deployers/azurecli/deploy-simplechat.ps1 | 28 + deployers/bicep/README.md | 1 + deployers/terraform/ReadMe.md | 8 + deployers/terraform/main.tf | 19 +- deployers/version.txt | 2 +- docs/admin/orchestration.md | 37 +- .../application-specific-configuration.md | 4 +- .../ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md | 10 + .../ORCHESTRATION_DIRECTORY_ACCESS_FIX.md | 194 ++ docs/explanation/release_notes.md | 18 + docs/reference/logging-tags.md | 49 +- .../test_orchestration_dependency_runtime.py | 86 +- .../test_orchestration_directory_access.py | 1636 +++++++++++++++++ ...rchestration_external_preflight_adapter.py | 26 +- ..._orchestration_source_authority_runtime.py | 25 +- .../test_support/orchestration_research.py | 7 +- 34 files changed, 3051 insertions(+), 80 deletions(-) create mode 100644 application/single_app/functions_orchestration_directory_access.py create mode 100644 application/single_app/functions_orchestration_directory_readiness.py create mode 100644 application/single_app/static/js/admin/admin_orchestration_directory_access.js create mode 100644 docs/explanation/fixes/ORCHESTRATION_DIRECTORY_ACCESS_FIX.md create mode 100644 functional_tests/test_orchestration_directory_access.py diff --git a/application/single_app/config.py b/application/single_app/config.py index bcb6a9dd1..459b487f3 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -100,7 +100,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.203" +VERSION = "0.261.204" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py index a39a90bb8..49dc1febe 100644 --- a/application/single_app/functions_notifications.py +++ b/application/single_app/functions_notifications.py @@ -7,8 +7,9 @@ notifications container. Supports personal, group, and public workspace scoped notifications with per-user read/dismiss tracking. -Version: 0.234.032 +Version: 0.261.204 Implemented in: 0.234.032 +Orchestration directory access notification type added in: 0.261.204 """ # Imports (grouped after docstring) @@ -34,6 +35,7 @@ KEY_VAULT_SECRET_REMINDER_NOTIFICATION_TYPE = 'key_vault_secret_expiring' M365_APPROVAL_PENDING_NOTIFICATION_TYPE = 'm365_approval_pending' M365_APPROVAL_UPDATED_NOTIFICATION_TYPE = 'm365_approval_updated' +ORCHESTRATION_DIRECTORY_ACCESS_NOTIFICATION_TYPE = 'orchestration_directory_access_unavailable' MAX_NOTIFICATION_IDEMPOTENCY_KEY_LENGTH = 512 WORKFLOW_ALERT_PRIORITY_CONFIG = { 'info': { @@ -212,6 +214,10 @@ KEY_VAULT_SECRET_REMINDER_NOTIFICATION_TYPE: { 'icon': 'bi-safe', 'color': 'warning' + }, + ORCHESTRATION_DIRECTORY_ACCESS_NOTIFICATION_TYPE: { + 'icon': 'bi-shield-exclamation', + 'color': 'warning' } } diff --git a/application/single_app/functions_orchestration_bootstrap.py b/application/single_app/functions_orchestration_bootstrap.py index 6ddf461bd..6276367eb 100644 --- a/application/single_app/functions_orchestration_bootstrap.py +++ b/application/single_app/functions_orchestration_bootstrap.py @@ -1,7 +1,7 @@ # functions_orchestration_bootstrap.py """Application-owned factories shared by web requests and scheduler continuations. -Version: 0.261.140 +Version: 0.261.204 Unlike the result/rendering services, this is an application composition root. Import it only after config has initialized the existing clients. Registering the @@ -11,12 +11,10 @@ import hashlib import hmac from copy import deepcopy -from urllib.parse import urlsplit import requests import config -import functions_authentication as authentication from agent_execution_context import capture_execution_identity from functions_generated_export_contracts import GeneratedFileExportRequest from functions_generated_export_registry import resolve_generated_file_export_format @@ -25,6 +23,11 @@ OrchestrationOutputCleanupService, configure_orchestration_artifact_service, ) +from functions_orchestration_directory_access import directory_access_reason +from functions_orchestration_directory_readiness import ( + graph_directory_token_provider, + report_directory_access_failure, +) from functions_orchestration_external_configuration import ( OrchestrationExternalConfigurationAttestor, _read_metadata, ) @@ -119,43 +122,11 @@ def read_identity(*, user_id, conversation_id): raise ResultUnavailableError("external_identity_access_denied") if reader is None: # Optional directory authorization is initialized only when external data is accessed. - from functions_orchestration_external_identity import ( - ExternalIdentityServiceError, - GraphExternalIdentityReader, - ) - - graph_base = authentication.get_graph_base_url() - graph_origin = urlsplit(graph_base) - graph_scope = f"{graph_origin.scheme}://{graph_origin.netloc}/.default" - app_client_id = authentication.CLIENT_ID - application = None - - def get_access_token(scope): - nonlocal application - if scope != graph_scope or authentication.CLIENT_ID != app_client_id: - raise ResultUnavailableError("external_identity_access_denied") - if application is None: - application = authentication._build_msal_app( - authority_override=authentication.get_graph_authority(), timeout=timeout, - ) - result = application.acquire_token_for_client(scopes=[scope]) - if type(result) is not dict: - raise ExternalIdentityServiceError("external_identity_response_invalid") - token = result.get("access_token") - if type(token) is str and token: - return token - error = result.get("error") - if error in ("server_error", "temporarily_unavailable"): - raise ExternalIdentityServiceError() - if error == "too_many_requests": - raise ExternalIdentityServiceError("external_identity_throttled") - if error in ( - "invalid_client", "invalid_grant", "unauthorized_client", "invalid_scope", - "access_denied", "consent_required", "interaction_required", - ): - raise ResultUnavailableError("external_identity_access_denied") - raise ExternalIdentityServiceError("external_identity_response_invalid") + from functions_orchestration_external_identity import GraphExternalIdentityReader + graph_base, graph_scope, app_client_id, get_access_token = graph_directory_token_provider( + timeout=timeout, + ) reader = GraphExternalIdentityReader( user_id=actor_user_id, conversation_id=actor_conversation_id, app_client_id=app_client_id, graph_base_url=graph_base, graph_scope=graph_scope, @@ -163,7 +134,14 @@ def get_access_token(scope): authorize_conversation=authorize_conversation, read_user_settings=read_user_settings, execution_check=execution_check, request_timeout=timeout, ) - return reader(user_id=user_id, conversation_id=conversation_id) + try: + return reader(user_id=user_id, conversation_id=conversation_id) + except ResultUnavailableError as error: + reason = directory_access_reason(error) + if reason: + # The application, not this user, was refused; administrators have to act. + report_directory_access_failure(reason) + raise return read_identity diff --git a/application/single_app/functions_orchestration_composition.py b/application/single_app/functions_orchestration_composition.py index cb4a562dc..ef79781bc 100644 --- a/application/single_app/functions_orchestration_composition.py +++ b/application/single_app/functions_orchestration_composition.py @@ -1,7 +1,8 @@ # functions_orchestration_composition.py """Explicit one-call content preparation from named authorized result readers. -Version: 0.261.141 +Version: 0.261.204 +Directory access refusals reported as their own failure in: 0.261.204 No retrieval, file-format inference, upload, publication, or implicit sibling inputs. Answer-writing steps receive saved memory, the resolved conversation references, the @@ -26,6 +27,7 @@ from functions_orchestration_deliverables import ( compose_deliverable_guidance, place_deck_images, place_image_tokens, ) +from functions_orchestration_directory_access import DIRECTORY_ACCESS_FAILURE_CODE, directory_access_reason from functions_orchestration_memory import OrchestrationMemoryError from functions_orchestration_registry import ( CAPABILITY_GENERATE_IMAGE, KNOWLEDGE_BASIS_GENERAL, KNOWLEDGE_BASIS_MIXED, KNOWLEDGE_BASIS_SOURCES, @@ -508,10 +510,14 @@ def prepared_values(response): except Exception as exc: raise_source_service_failure(exc) code = getattr(exc, 'code', '') + authority_reason = directory_access_reason(exc) if code == 'result_requires_streaming' or isinstance(exc, WorkflowContextBudgetError): failure = build_failure('result_input_too_large') elif isinstance(exc, OrchestrationMemoryError): failure = build_failure('context_unavailable') + elif authority_reason: + # A retained external input was rechecked, but the application could not read the directory. + failure = build_failure(DIRECTORY_ACCESS_FAILURE_CODE) elif isinstance(exc, (ResultUnavailableError, PermissionError, ScreeningError)): failure = build_failure('result_unavailable') elif isinstance(exc, (ResultContractError, ValueError)): @@ -529,6 +535,7 @@ def prepared_values(response): 'capability_id': step['capability_id'], 'error_type': type(exc).__name__, 'failure_code': failure['code'], **({'execution_code': code} if type(code) is str and code else {}), + **({'authority_reason': authority_reason} if authority_reason else {}), }, ) return build_step_result( diff --git a/application/single_app/functions_orchestration_directory_access.py b/application/single_app/functions_orchestration_directory_access.py new file mode 100644 index 000000000..b9f7d23db --- /dev/null +++ b/application/single_app/functions_orchestration_directory_access.py @@ -0,0 +1,50 @@ +# functions_orchestration_directory_access.py +"""Stable codes for Microsoft Entra directory reads that the application itself cannot make. + +Version: 0.261.204 +Implemented in: 0.261.204 + +Before orchestration uses or reuses web, linked-page, deep-research, agent or action +results, it rereads the requesting user's current account and app-role assignments with +the application's own Microsoft Graph identity. When Microsoft Graph or Microsoft Entra ID +refuses the application, no decision about the user was made: the deployment is missing +the Microsoft Graph application permission, or its client credentials cannot sign in. +Those refusals carry one of the reasons below, so users see an actionable message and +administrators are told which fix applies. Denials about the user keep their own codes. + +This module has no imports. The directory reader, invocation capture, executor and +failure schema all read it without widening their dependency graphs. +""" + +GRAPH_RESOURCE_APP_ID = "00000003-0000-0000-c000-000000000000" +GRAPH_DIRECTORY_PERMISSION = "Directory.Read.All" +GRAPH_DIRECTORY_PERMISSION_ID = "7ab1d382-f21e-4acd-a863-ba3e13f7da61" + +# Microsoft Graph refused the application's token (HTTP 403): the application +# permission is missing or has not been granted administrator consent. +DIRECTORY_PERMISSION_MISSING = "external_identity_directory_permission_missing" +# Microsoft Entra ID refused the client-credential sign-in, or Microsoft Graph +# rejected the resulting token (HTTP 401). +DIRECTORY_SIGN_IN_FAILED = "external_identity_directory_sign_in_failed" +DIRECTORY_ACCESS_REASONS = frozenset({DIRECTORY_PERMISSION_MISSING, DIRECTORY_SIGN_IN_FAILED}) + +# The step failure code shown to users when a directory reason stopped their work. +DIRECTORY_ACCESS_FAILURE_CODE = "directory_access_unavailable" + +# Capabilities whose steps reread the requesting user's directory state, in registry order. +DIRECTORY_GATED_CAPABILITIES = ("web_search", "url_fetch", "deep_research", "agent_invoke", "action_invoke") + + +def directory_access_reason(error): + """Return the directory-access reason a refused identity read carries, or None. + + Invocation capture reports the reason as ``authority_reason``; the directory reader + raises it as the refusal ``code``. Any other denial returns None. + """ + if not isinstance(error, PermissionError): + return None + for name in ("authority_reason", "code"): + value = getattr(error, name, None) + if type(value) is str and value in DIRECTORY_ACCESS_REASONS: + return value + return None diff --git a/application/single_app/functions_orchestration_directory_readiness.py b/application/single_app/functions_orchestration_directory_readiness.py new file mode 100644 index 000000000..e3cf16f2f --- /dev/null +++ b/application/single_app/functions_orchestration_directory_readiness.py @@ -0,0 +1,351 @@ +# functions_orchestration_directory_readiness.py +"""Whether the application can read Microsoft Entra ID for orchestration's external sources. + +Version: 0.261.204 +Implemented in: 0.261.204 + +Before Chat Orchestration uses or reuses web, linked-page, deep-research, agent or action +results, it rereads the requesting user's account and app-role assignments from Microsoft +Graph with the application's own identity. That read needs the Microsoft Graph +Directory.Read.All application permission with administrator consent. Without it, every +such step fails for every user, however their roles are assigned. + +This module owns the application's Graph token source for those reads, the check the admin +settings page runs for the signed-in administrator, and the notifier that tells +administrators when live requests are refused. No token, directory response or exception +text leaves it. +""" + +import logging +import re +from datetime import datetime, timezone +from threading import Lock +from time import monotonic +from urllib.parse import urlsplit + +import requests + +import functions_authentication as authentication +from functions_appinsights import log_event +from functions_orchestration_directory_access import ( + DIRECTORY_GATED_CAPABILITIES, + DIRECTORY_PERMISSION_MISSING, + DIRECTORY_SIGN_IN_FAILED, + GRAPH_DIRECTORY_PERMISSION, + GRAPH_DIRECTORY_PERMISSION_ID, + GRAPH_RESOURCE_APP_ID, +) +from functions_orchestration_registry import ( + CAPABILITY_WEB_SEARCH, + CapabilityResolutionError, + get_capability, + resolve_available_capability_ids, +) +from functions_orchestration_result_contracts import ResultContractError +from functions_orchestration_results import ResultUnavailableError + + +LOG_TAG = '[ORCHESTRATION_DIRECTORY]' +ADMIN_SETTINGS_LINK = '/admin/settings#chat-orchestration' + +STATUS_NOT_REQUIRED = 'not_required' +STATUS_READY = 'ready' +STATUS_PERMISSION_MISSING = 'permission_missing' +STATUS_SIGN_IN_FAILED = 'sign_in_failed' +STATUS_UNVERIFIED = 'unverified' + +# Microsoft Entra ID refusing the client-credential sign-in itself. None of these can be +# fixed by retrying or by an interactive prompt; an administrator has to act. +_SIGN_IN_REFUSALS = frozenset({ + 'invalid_client', 'invalid_grant', 'unauthorized_client', 'invalid_scope', + 'access_denied', 'consent_required', 'interaction_required', +}) +# Decisions about the signed-in administrator. Reaching one proves the directory was read. +_USER_DECISIONS = frozenset({ + 'external_identity_role_required', 'external_identity_role_unavailable', + 'external_identity_account_unavailable', 'external_identity_access_restricted', +}) +_DIRECTORY_STATUSES = { + DIRECTORY_PERMISSION_MISSING: STATUS_PERMISSION_MISSING, + DIRECTORY_SIGN_IN_FAILED: STATUS_SIGN_IN_FAILED, +} +_SAFE_CODE = re.compile(r'[a-z][a-z0-9_]{0,79}\Z') +_GUID = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z', re.IGNORECASE) +_PROBE_CONVERSATION_ID = 'orchestration-directory-access-check' +_PROBE_TIMEOUT_SECONDS = 10.0 +_PROBE_MAX_ELAPSED_SECONDS = 20.0 +# A successful check is reused briefly so reopening admin settings does not reread Graph. +_READY_CACHE_SECONDS = 300.0 + +_ready_cache = {} +_ready_cache_lock = Lock() +_reported = set() +_report_lock = Lock() + + +def _safe_code(value): + return value if type(value) is str and _SAFE_CODE.fullmatch(value) is not None else None + + +def _utc_now(): + return datetime.now(timezone.utc) + + +def graph_directory_token_provider(*, timeout): + """The application's own Microsoft Graph token source for directory reads. + + Returns ``(graph_base_url, graph_scope, app_client_id, get_access_token)`` for the + configured cloud. The MSAL confidential client is built on first use, against the + Graph authority, and never falls back to an interactive or delegated sign-in. + A refused client-credential sign-in is reported as ``DIRECTORY_SIGN_IN_FAILED``; + outages and throttling stay operational so they are never mistaken for a refusal. + """ + # Optional directory authorization is initialized only when external data is accessed. + from functions_orchestration_external_identity import ExternalIdentityServiceError + + graph_base = authentication.get_graph_base_url() + graph_origin = urlsplit(graph_base) + graph_scope = f"{graph_origin.scheme}://{graph_origin.netloc}/.default" + app_client_id = authentication.CLIENT_ID + application = None + + def get_access_token(scope): + nonlocal application + if scope != graph_scope or authentication.CLIENT_ID != app_client_id: + raise ResultUnavailableError('external_identity_access_denied') + if application is None: + application = authentication._build_msal_app( + authority_override=authentication.get_graph_authority(), timeout=timeout, + ) + result = application.acquire_token_for_client(scopes=[scope]) + if type(result) is not dict: + raise ExternalIdentityServiceError('external_identity_response_invalid') + token = result.get('access_token') + if type(token) is str and token: + return token + error = result.get('error') + if error in ('server_error', 'temporarily_unavailable'): + raise ExternalIdentityServiceError() + if error == 'too_many_requests': + raise ExternalIdentityServiceError('external_identity_throttled') + if error in _SIGN_IN_REFUSALS: + raise ResultUnavailableError(DIRECTORY_SIGN_IN_FAILED) + raise ExternalIdentityServiceError('external_identity_response_invalid') + + return graph_base, graph_scope, app_client_id, get_access_token + + +def identity_gated_capabilities(settings): + """Enabled orchestration capabilities whose steps reread the user's directory state.""" + if not isinstance(settings, dict) or not settings.get('enable_chat_orchestration'): + return [] + try: + available = resolve_available_capability_ids( + settings, candidate_ids=DIRECTORY_GATED_CAPABILITIES, include_runtime_bindings=False, + ) + except CapabilityResolutionError: + # A malformed capability allowlist already stops every orchestration capability. + return [] + return [capability_id for capability_id in DIRECTORY_GATED_CAPABILITIES if capability_id in available] + + +def _probe_result(status, reason=None): + return {'status': status, 'reason': reason, 'checked_at': _utc_now().isoformat()} + + +def probe_directory_access(user_id, *, refresh=False): + """Read the signed-in administrator's directory state exactly as orchestration does. + + Uses the real directory reader and token source, with only the conversation and user + settings reads replaced, so the result reflects the permission and credentials a live + request would use. Returns ``{status, reason, checked_at}``. + """ + # Optional directory authorization is initialized only when it is checked. + from functions_orchestration_external_identity import ( + ExternalIdentityCancelledError, + ExternalIdentityServiceError, + GraphExternalIdentityReader, + ) + + try: + graph_base, graph_scope, app_client_id, get_access_token = graph_directory_token_provider( + timeout=_PROBE_TIMEOUT_SECONDS, + ) + except Exception as error: + log_event( + f'{LOG_TAG} The directory access check could not prepare its token source.', + level=logging.WARNING, extra={'error_type': type(error).__name__}, + ) + return _probe_result(STATUS_UNVERIFIED, 'directory_check_failed') + + cache_key = (user_id, app_client_id, graph_base) + if not refresh: + with _ready_cache_lock: + cached = _ready_cache.get(cache_key) + if cached is not None and cached[0] > monotonic(): + return dict(cached[1]) + + def authorize_conversation(*, user_id, conversation_id): + return {'id': conversation_id, 'user_id': user_id} + + def read_user_settings(user_id): + return {'id': user_id, 'settings': {}} + + try: + reader = GraphExternalIdentityReader( + user_id=user_id, conversation_id=_PROBE_CONVERSATION_ID, + app_client_id=app_client_id, graph_base_url=graph_base, graph_scope=graph_scope, + get_access_token=get_access_token, http_get=requests.get, + authorize_conversation=authorize_conversation, read_user_settings=read_user_settings, + request_timeout=_PROBE_TIMEOUT_SECONDS, max_elapsed=_PROBE_MAX_ELAPSED_SECONDS, + ) + reader(user_id=user_id, conversation_id=_PROBE_CONVERSATION_ID) + result = _probe_result(STATUS_READY) + except ResultUnavailableError as error: + code = _safe_code(getattr(error, 'code', None)) + if code in _DIRECTORY_STATUSES: + result = _probe_result(_DIRECTORY_STATUSES[code], code) + elif code in _USER_DECISIONS: + result = _probe_result(STATUS_READY) + else: + result = _probe_result(STATUS_UNVERIFIED, code) + except (ExternalIdentityServiceError, ExternalIdentityCancelledError, ResultContractError) as error: + result = _probe_result(STATUS_UNVERIFIED, _safe_code(getattr(error, 'code', None))) + except Exception as error: + log_event( + f'{LOG_TAG} The directory access check failed unexpectedly.', + level=logging.WARNING, extra={'error_type': type(error).__name__}, + ) + result = _probe_result(STATUS_UNVERIFIED, 'directory_check_failed') + + if result['status'] == STATUS_READY: + with _ready_cache_lock: + _ready_cache[cache_key] = (monotonic() + _READY_CACHE_SECONDS, dict(result)) + else: + with _ready_cache_lock: + _ready_cache.pop(cache_key, None) + return result + + +def _notification_content(reason): + if reason == DIRECTORY_SIGN_IN_FAILED: + return ( + "Chat Orchestration can't sign in to Microsoft Entra ID", + "Microsoft Entra ID refused SimpleChat's application sign-in when Chat Orchestration " + "tried to confirm a user's access to web search and other external sources (linked " + "pages, deep research, agents and actions). Check that the app registration's client " + "secret is current and that the application is enabled. Until then, those steps fail " + "for every user.", + ) + return ( + "Chat Orchestration can't verify access to web search", + "SimpleChat can't read Microsoft Entra ID, so Chat Orchestration can't confirm that " + "users may use web search and other external sources (linked pages, deep research, " + f"agents and actions). Grant the Microsoft Graph {GRAPH_DIRECTORY_PERMISSION} " + "application permission to SimpleChat's app registration and give administrator " + "consent. Until then, those steps fail for every user.", + ) + + +def _notify_administrators(reason, day): + # Notifications load Cosmos-backed workspace modules, needed only once a failure is reported. + from functions_notifications import ( + ORCHESTRATION_DIRECTORY_ACCESS_NOTIFICATION_TYPE, + create_notification, + ) + + title, message = _notification_content(reason) + return create_notification( + notification_type=ORCHESTRATION_DIRECTORY_ACCESS_NOTIFICATION_TYPE, + title=title, + message=message, + link_url=ADMIN_SETTINGS_LINK, + metadata={ + 'reason': reason, + 'graph_permission': GRAPH_DIRECTORY_PERMISSION, + 'graph_permission_id': GRAPH_DIRECTORY_PERMISSION_ID, + 'graph_resource_app_id': GRAPH_RESOURCE_APP_ID, + }, + assignment={'roles': ['Admin']}, + idempotency_key=f'orchestration-directory-access:{reason}:{day}', + ) + + +def report_directory_access_failure(reason): + """Tell administrators, at most once per reason and UTC day, that directory reads fail. + + Called on the failing request's path, so it never raises. Each process remembers what + it reported; the notification's retry key keeps other processes and instances from + adding a duplicate. A notification that could not be saved is retried by a later + failure instead of being remembered as sent. + """ + try: + if type(reason) is not str or reason not in _DIRECTORY_STATUSES: + return None + day = _utc_now().date().isoformat() + key = (reason, day) + with _report_lock: + if key in _reported: + return None + _reported.add(key) + log_event( + f"{LOG_TAG} Microsoft Entra ID refused the application's directory read.", + level=logging.WARNING, + extra={'authority_reason': reason}, + ) + notification = _notify_administrators(reason, day) + if notification is None: + with _report_lock: + _reported.discard(key) + return notification + except Exception as error: + try: + with _report_lock: + _reported.discard((reason, _utc_now().date().isoformat())) + log_event( + f'{LOG_TAG} Administrators could not be notified about directory access.', + level=logging.WARNING, extra={'error_type': type(error).__name__}, + ) + except Exception: + pass + return None + + +def reset_directory_access_state(): + """Forget cached checks and reported failures. For tests and diagnostics only.""" + with _ready_cache_lock: + _ready_cache.clear() + with _report_lock: + _reported.clear() + + +def check_orchestration_directory_access(user_id, settings, *, refresh=False): + """What the admin settings page shows about orchestration's directory access. + + Performs no Graph I/O unless an enabled capability needs it. A missing permission or a + refused sign-in found here is also reported to administrators. + """ + capabilities = identity_gated_capabilities(settings) + client_id = authentication.CLIENT_ID + report = { + 'status': STATUS_NOT_REQUIRED, + 'required': bool(capabilities), + 'reason': None, + 'capabilities': [ + {'id': capability_id, 'label': (get_capability(capability_id) or {}).get('label') or capability_id} + for capability_id in capabilities + ], + 'web_search_enabled': CAPABILITY_WEB_SEARCH in capabilities, + 'application_client_id': client_id if type(client_id) is str and _GUID.fullmatch(client_id) else None, + 'graph_permission': GRAPH_DIRECTORY_PERMISSION, + 'graph_permission_id': GRAPH_DIRECTORY_PERMISSION_ID, + 'graph_resource_app_id': GRAPH_RESOURCE_APP_ID, + 'checked_at': None, + } + if not capabilities: + return report + probe = probe_directory_access(user_id, refresh=refresh) + report.update(status=probe['status'], reason=probe['reason'], checked_at=probe['checked_at']) + if probe['status'] in (STATUS_PERMISSION_MISSING, STATUS_SIGN_IN_FAILED): + report_directory_access_failure(probe['reason']) + return report diff --git a/application/single_app/functions_orchestration_executor.py b/application/single_app/functions_orchestration_executor.py index be72b2f76..0e59bb3e9 100644 --- a/application/single_app/functions_orchestration_executor.py +++ b/application/single_app/functions_orchestration_executor.py @@ -61,6 +61,7 @@ resolve_elicitation_references, ) from functions_orchestration_deliverables import explicit_image_shortfalls +from functions_orchestration_directory_access import DIRECTORY_ACCESS_FAILURE_CODE, directory_access_reason from functions_orchestration_registry import ( CAPABILITY_TABULAR_ANALYZE, DEPENDENCY_PLAN_CONTRACT_VERSION, admitted_export_pairs, get_capability, @@ -154,6 +155,25 @@ def _error_code(error): return code if isinstance(code, str) else None +def _authority_reason(error): + """The stable reason an access refusal carries, for diagnostics only.""" + reason = getattr(error, 'authority_reason', None) + if isinstance(reason, str): + return reason + return _error_code(error) if isinstance(error, ResultUnavailableError) else None + + +def _access_failure(error): + """A refused source, or a directory the application itself could not read. + + Recording a step's failure names that step's source, so the explanation says what the + user was trying to use. + """ + if directory_access_reason(error): + return build_failure(DIRECTORY_ACCESS_FAILURE_CODE) + return build_failure('result_unavailable') + + def _partial_input_log_fields(error): """Which producer returned the partial result a complete-only input refused, as codes and counts.""" if not isinstance(error, PartialInputNotAcceptedError): @@ -850,7 +870,7 @@ def _run_dependency_step( if isinstance(exc, OrchestrationFilePolicyError): failure = build_failure('file_publication_not_allowed') elif isinstance(exc, (ResultUnavailableError, ElicitationContextError, PermissionError, ScreeningError)): - failure = build_failure('result_unavailable') + failure = _access_failure(exc) elif isinstance(exc, PartialInputNotAcceptedError): failure = build_failure('input_partial_not_accepted') elif isinstance(exc, ResultContractError): @@ -863,6 +883,7 @@ def _run_dependency_step( extra={ **_step_log_context(context, step), 'error_type': type(exc).__name__, 'failure_code': failure['code'], 'execution_code': _error_code(exc), + 'authority_reason': _authority_reason(exc), **_partial_input_log_fields(exc), }, ) @@ -1223,15 +1244,17 @@ def save(record): _raise_dependency_service_failure(step, exc) if step['role'] == 'render': raise + failure = _access_failure(exc) log_event( f'{_LOG_PREFIX} A saved wait could not be resumed.', level=logging.WARNING, extra={ **_step_log_context(context, step), 'error_type': type(exc).__name__, - 'failure_code': 'result_unavailable', 'execution_code': _error_code(exc), + 'failure_code': failure['code'], 'execution_code': _error_code(exc), + 'authority_reason': _authority_reason(exc), }, ) - result = build_step_result(status=STEP_STATUS_FAILED, failure=build_failure('result_unavailable')) + result = build_step_result(status=STEP_STATUS_FAILED, failure=failure) task = result.get('task_result') if result['status'] in (STEP_STATUS_COMPLETED, STEP_STATUS_PARTIAL, STEP_STATUS_WAITING): if step['role'] == 'render': @@ -1463,11 +1486,15 @@ def finished_stop(): WorkflowResultIntegrityError, WorkflowResultStorageUnavailableError, ) as exc: raise_source_service_failure(exc) + failure = _access_failure(exc) log_event( f'{_LOG_PREFIX} Retained content could not be reauthorized for finalization.', - level=logging.WARNING, extra={'run_id': context.run_id, 'error_type': type(exc).__name__}, + level=logging.WARNING, extra={ + 'run_id': context.run_id, 'error_type': type(exc).__name__, + 'failure_code': failure['code'], 'authority_reason': _authority_reason(exc), + }, ) - context.failures.append(build_failure('result_unavailable')) + context.failures.append(failure) message, complete, response_reference, citations = '', False, None, [] context.artifacts = [] file_outputs, committed_artifacts = _dependency_file_outcomes( diff --git a/application/single_app/functions_orchestration_external_identity.py b/application/single_app/functions_orchestration_external_identity.py index 1e7f2530b..7a5d4745b 100644 --- a/application/single_app/functions_orchestration_external_identity.py +++ b/application/single_app/functions_orchestration_external_identity.py @@ -1,7 +1,9 @@ # functions_orchestration_external_identity.py """Read current external-result identity through injected Microsoft Graph I/O. -Version: 0.261.127 +Version: 0.261.204 +Implemented in: 0.261.127 +Application directory refusals reported separately from user decisions in: 0.261.204 Only the owner supplies credentials, the trusted cloud endpoint, conversation authorization and uncached, read-only user settings. No directory authority, @@ -22,6 +24,9 @@ from requests import Response from requests.exceptions import RequestException, Timeout +from functions_orchestration_directory_access import ( + DIRECTORY_ACCESS_REASONS, DIRECTORY_PERMISSION_MISSING, DIRECTORY_SIGN_IN_FAILED, +) from functions_orchestration_external_sources import CurrentExternalSourceIdentity from functions_orchestration_invocation_capture import ( OrchestrationInvocationCancelledError, OrchestrationInvocationServiceError, @@ -72,9 +77,14 @@ def __init__(self): super().__init__("Current directory authorization was cancelled.") -def _http_error(status): +def _http_error(status, *, directory=False): if type(status) is not int: return ExternalIdentityServiceError("external_identity_response_invalid") + if directory and status == 403: + # Microsoft Graph refused the application itself; nothing was decided about the user. + return ResultUnavailableError(DIRECTORY_PERMISSION_MISSING) + if directory and status == 401: + return ResultUnavailableError(DIRECTORY_SIGN_IN_FAILED) if status in (401, 403, 404, 410): return ResultUnavailableError("external_identity_access_denied") if status == 429: @@ -96,7 +106,14 @@ def _call_io(callback, *args, **kwargs): raise except ExternalIdentityServiceError as error: failure = ExternalIdentityServiceError(error.code) - except (ResultUnavailableError, ClientAuthenticationError, ResourceNotFoundError, PermissionError): + except ResultUnavailableError as error: + # A directory refusal from the owner's token source keeps its reason; other denials stay generic. + code = getattr(error, "code", None) + failure = ResultUnavailableError( + code if type(code) is str and code in DIRECTORY_ACCESS_REASONS + else "external_identity_access_denied" + ) + except (ClientAuthenticationError, ResourceNotFoundError, PermissionError): failure = ResultUnavailableError("external_identity_access_denied") except HttpResponseError as error: failure = _http_error(error.status_code) @@ -292,7 +309,7 @@ def _read_response(self, response, url_key, path, budget): ): raise ExternalIdentityServiceError("external_identity_response_invalid") if response.status_code != 200: - raise _http_error(response.status_code) + raise _http_error(response.status_code, directory=True) content_type = response.headers.get("Content-Type") if type(content_type) is not str or content_type.split(";", 1)[0].strip().lower() != "application/json": raise ExternalIdentityServiceError("external_identity_response_invalid") diff --git a/application/single_app/functions_orchestration_invocation_capture.py b/application/single_app/functions_orchestration_invocation_capture.py index f2e6981c5..d930cbd92 100644 --- a/application/single_app/functions_orchestration_invocation_capture.py +++ b/application/single_app/functions_orchestration_invocation_capture.py @@ -1,7 +1,9 @@ # functions_orchestration_invocation_capture.py """Private invocation-capture state, independent of application initialization. -Version: 0.261.127 +Version: 0.261.204 +Implemented in: 0.261.127 +Directory access refusal reason carried on denials in: 0.261.204 The server supplies an already producer-bound callback. SDKs and tool loops may catch exceptions, so a refused capture remains sticky until the owning @@ -11,6 +13,7 @@ from copy import deepcopy import inspect +import re from threading import RLock from typing import NoReturn @@ -18,6 +21,14 @@ from functions_orchestration_result_contracts import ResultContractError +# A refusal's stable application code, never exception text or an identifier. +_AUTHORITY_REASON = re.compile(r"[a-z][a-z0-9_]{0,79}\Z") + + +def _authority_reason(value): + return value if type(value) is str and _AUTHORITY_REASON.fullmatch(value) is not None else None + + class OrchestrationInvocationCaptureError(ResultContractError): """The actual invocation configuration could not be attested.""" @@ -38,12 +49,17 @@ class OrchestrationInvocationControlError(RuntimeError): class OrchestrationInvocationDeniedError(PermissionError): - """An acquired source is denied, without retaining private authority details.""" + """An acquired source is denied, without retaining private authority details. + + ``authority_reason`` keeps only the refusal's stable snake_case code, such as a + directory-access reason, so the owner can explain and diagnose the denial. + """ code = "result_unavailable" retryable = False - def __init__(self): + def __init__(self, authority_reason=None): + self.authority_reason = _authority_reason(authority_reason) super().__init__("The acquired source is unavailable under the current access policy.") @@ -122,6 +138,11 @@ def _record_failure(self, error): self._failure_type = OrchestrationInvocationHeldError elif isinstance(error, PermissionError): self._failure_type = OrchestrationInvocationDeniedError + self._failure_code = _authority_reason( + getattr(error, "authority_reason", None) + if isinstance(error, OrchestrationInvocationDeniedError) + else getattr(error, "code", None) + ) def fail(self, error) -> NoReturn: """Record an owned engine failure without retaining its private exception.""" diff --git a/application/single_app/functions_orchestration_schema.py b/application/single_app/functions_orchestration_schema.py index 2f54234e3..fdfc55b54 100644 --- a/application/single_app/functions_orchestration_schema.py +++ b/application/single_app/functions_orchestration_schema.py @@ -68,6 +68,7 @@ StepBindings, TaskResult, canonical_bytes, output_name, validate_input_bindings, ) from functions_orchestration_deliverables import DeliverableError, compile_deliverables +from functions_orchestration_directory_access import DIRECTORY_ACCESS_FAILURE_CODE ORCHESTRATION_ELICITATION_CONTRACT_VERSION = 2 @@ -1230,6 +1231,10 @@ def summarize_plan(plan): 'analysis_result_not_saved': 'The analysis completed, but its final data could not be saved for reuse.', 'analysis_input_too_large': 'The complete saved analysis exceeds the selected model input budget. No data was truncated or re-analyzed. Select a larger model or use a supported complete-record reader.', 'result_unavailable': 'A required retained result is unavailable or changed. No preview was substituted.', + DIRECTORY_ACCESS_FAILURE_CODE: ( + "Unable to verify your permission to use web search and other external sources because " + "this application doesn't have access to Microsoft Entra ID. Please contact your administrator." + ), 'result_invalid': 'The operation did not produce the complete named results declared by the plan.', 'result_input_too_large': 'The complete named inputs exceed the selected model budget. No input was truncated. Use a larger model or revise the plan.', 'result_not_ready': 'Required computation is still pending. Its result is not ready to consume.', @@ -1255,6 +1260,15 @@ def summarize_plan(plan): LEGACY_PLAN_CODE: LEGACY_PLAN_MESSAGE, } +# What a user was trying to do when the application could not read Microsoft Entra ID for them. +DIRECTORY_ACCESS_ACTIVITIES = { + 'web_search': 'use web search', + 'url_fetch': 'read linked web pages', + 'deep_research': 'use deep research', + 'agent_invoke': 'use agents', + 'action_invoke': 'use actions', +} + def build_failure(code='step_failed', *, step_id=None, capability_id=None, provider_status=None): """Only application-owned text may cross a failure boundary.""" @@ -1264,6 +1278,13 @@ def build_failure(code='step_failed', *, step_id=None, capability_id=None, provi result['step_id'] = _text(step_id, 200) if capability_id: result['capability_id'] = _text(capability_id, 100) + activity = DIRECTORY_ACCESS_ACTIVITIES.get(result['capability_id']) + if code == DIRECTORY_ACCESS_FAILURE_CODE and activity: + result['message'] = ( + f"Unable to verify your permission to {activity} " + "because this application doesn't have access to Microsoft Entra ID. " + "Please contact your administrator." + ) if isinstance(provider_status, int) and not isinstance(provider_status, bool) and 400 <= provider_status <= 599: result['provider_status'] = provider_status if code == 'provider_http_error': diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index bb5547cae..864aebd69 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -80,6 +80,7 @@ capabilities_for_contract, effective_capability_ids, ) +from functions_orchestration_directory_readiness import check_orchestration_directory_access from functions_ai_notice import ( normalize_ai_notice_frequency, normalize_ai_notice_message, @@ -559,6 +560,30 @@ def inbound_mcp_easy_auth_check(): status_code = 200 if result.get('success') else 409 return jsonify(result), status_code + @bp.route('/api/admin/settings/orchestration/directory-access-check', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def orchestration_directory_access_check(): + """Report whether Chat Orchestration can read Microsoft Entra ID to verify user access.""" + payload = request.get_json(silent=True) + refresh = isinstance(payload, dict) and payload.get('refresh') is True + try: + report = check_orchestration_directory_access( + get_current_user_id(), get_settings(), refresh=refresh, + ) + except Exception as exc: + log_event( + '[ORCHESTRATION_DIRECTORY] The admin directory access check failed.', + level=logging.WARNING, + extra={'error_type': type(exc).__name__}, + ) + return jsonify({ + 'success': False, + 'error': 'The directory access check could not be completed.', + }), 500 + return jsonify({'success': True, **report}), 200 + @bp.route('/api/admin/settings/file-processing-logs/cleanup', methods=['POST']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/static/js/admin/admin_orchestration_directory_access.js b/application/single_app/static/js/admin/admin_orchestration_directory_access.js new file mode 100644 index 000000000..921d51470 --- /dev/null +++ b/application/single_app/static/js/admin/admin_orchestration_directory_access.js @@ -0,0 +1,179 @@ +// admin_orchestration_directory_access.js +// Warns on the classic Admin Settings page when Chat Orchestration cannot read Microsoft Entra ID. +// +// Before a plan uses web search or another external source, Chat Orchestration rereads the +// user's app roles from Microsoft Graph with the application's own identity. That read needs the +// Microsoft Graph Directory.Read.All application permission with administrator consent. The +// server runs the same read for the signed-in administrator and this module shows the matching +// guidance. Every value from the server is written with textContent. + +const CHECK_URL = "/api/admin/settings/orchestration/directory-access-check"; +const ALERT_ID = "chat-orchestration-directory-access-alert"; +const WEB_SEARCH_ALERT_ID = "web-search-directory-access-alert"; +const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const GRAPH_RESOURCE_APP_ID = "00000003-0000-0000-c000-000000000000"; +const GRAPH_DIRECTORY_PERMISSION_ID = "7ab1d382-f21e-4acd-a863-ba3e13f7da61"; +const CLIENT_ID_PLACEHOLDER = ""; +const DEFAULT_SOURCES = "web search or another external source"; + +export const PROBLEM_STATES = Object.freeze(["permission_missing", "sign_in_failed", "unverified"]); + +// Maps, so a server value such as "constructor" never resolves to an inherited property. +const SOURCE_NAMES = new Map([ + ["web_search", "web search"], + ["url_fetch", "linked pages"], + ["deep_research", "deep research"], + ["agent_invoke", "agents"], + ["action_invoke", "actions"], +]); + +const WEB_SEARCH_SUMMARIES = new Map([ + ["permission_missing", "Chat Orchestration can't verify that users may use web search, because this app registration doesn't have the Microsoft Graph Directory.Read.All permission."], + ["sign_in_failed", "Chat Orchestration can't verify that users may use web search, because Microsoft Entra ID refused SimpleChat's application sign-in."], +]); + +function guidOr(value, fallback) { + return typeof value === "string" && GUID_PATTERN.test(value) ? value : fallback; +} + +/** Azure CLI commands that add the permission and grant administrator consent. */ +export function directoryAccessCommands(report) { + const clientId = guidOr(report?.application_client_id, CLIENT_ID_PLACEHOLDER); + const resourceId = guidOr(report?.graph_resource_app_id, GRAPH_RESOURCE_APP_ID); + const permissionId = guidOr(report?.graph_permission_id, GRAPH_DIRECTORY_PERMISSION_ID); + return [ + `az ad app permission add --id ${clientId} --api ${resourceId} --api-permissions ${permissionId}=Role`, + `az ad app permission admin-consent --id ${clientId}`, + ].join("\n"); +} + +/** The enabled sources that depend on the directory read, as a readable phrase. */ +export function directoryAccessSources(capabilities) { + const names = (Array.isArray(capabilities) ? capabilities : []) + .map((capability) => SOURCE_NAMES.get(capability?.id)) + .filter(Boolean); + if (names.length === 0) { + return DEFAULT_SOURCES; + } + if (names.length === 1) { + return names[0]; + } + return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`; +} + +function formatCheckedAt(value) { + const checkedAt = typeof value === "string" ? new Date(value) : null; + if (!checkedAt || Number.isNaN(checkedAt.getTime())) { + return ""; + } + return `Checked ${checkedAt.toLocaleString()}.`; +} + +function detailText(report) { + const parts = []; + if (report.status === "unverified" && typeof report.reason === "string" && report.reason) { + parts.push(`Reason: ${report.reason}.`); + } + const checked = formatCheckedAt(report.checked_at); + if (checked) { + parts.push(checked); + } + return parts.join(" "); +} + +/** Show the guidance that matches a check result, or hide it when nothing is wrong. */ +export function renderDirectoryAccess(report, root = document) { + const alert = root.getElementById(ALERT_ID); + const webSearchAlert = root.getElementById(WEB_SEARCH_ALERT_ID); + const status = typeof report?.status === "string" ? report.status : ""; + const showAlert = PROBLEM_STATES.includes(status); + + if (alert) { + alert.querySelectorAll("[data-directory-access-state]").forEach((block) => { + block.classList.toggle("d-none", block.getAttribute("data-directory-access-state") !== status); + }); + alert.querySelectorAll("[data-directory-access-capabilities]").forEach((element) => { + element.textContent = directoryAccessSources(report?.capabilities); + }); + alert.querySelectorAll("[data-directory-access-commands]").forEach((element) => { + element.textContent = directoryAccessCommands(report); + }); + const detail = alert.querySelector("[data-directory-access-detail]"); + if (detail) { + detail.textContent = showAlert ? detailText(report) : ""; + } + alert.classList.toggle("d-none", !showAlert); + } + + if (webSearchAlert) { + const summary = WEB_SEARCH_SUMMARIES.get(status); + const showWebSearch = Boolean(summary) && report?.web_search_enabled === true; + const summaryElement = webSearchAlert.querySelector("[data-directory-access-summary]"); + if (summaryElement && summary) { + summaryElement.textContent = summary; + } + webSearchAlert.classList.toggle("d-none", !showWebSearch); + } +} + +async function requestDirectoryAccessCheck(refresh) { + const response = await fetch(CHECK_URL, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + credentials: "same-origin", + body: JSON.stringify({ refresh }), + }); + const payload = await response.json(); + if (!response.ok || payload?.success !== true) { + throw new Error(payload?.error || "The directory access check could not be completed."); + } + return payload; +} + +/** Run the check and update the page. A failed check leaves the page as it was. */ +export async function checkDirectoryAccess({ refresh = false } = {}) { + const alert = document.getElementById(ALERT_ID); + const button = alert?.querySelector("[data-directory-access-recheck]"); + const detail = alert?.querySelector("[data-directory-access-detail]"); + if (button) { + button.disabled = true; + } + if (refresh && detail) { + detail.textContent = "Checking..."; + } + try { + const report = await requestDirectoryAccessCheck(refresh); + renderDirectoryAccess(report); + return report; + } catch (error) { + console.warn("Chat Orchestration directory access check failed.", error); + if (refresh && detail) { + detail.textContent = "The check could not be completed. Try again."; + } + return null; + } finally { + if (button) { + button.disabled = false; + } + } +} + +export function initDirectoryAccessCheck() { + const alert = document.getElementById(ALERT_ID); + if (!alert) { + return; + } + alert.querySelector("[data-directory-access-recheck]")?.addEventListener("click", () => { + checkDirectoryAccess({ refresh: true }); + }); + checkDirectoryAccess(); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initDirectoryAccessCheck); +} else { + initDirectoryAccessCheck(); +} diff --git a/application/single_app/templates/admin/_panes/chat-orchestration.html b/application/single_app/templates/admin/_panes/chat-orchestration.html index 3aa725355..7c231d66b 100644 --- a/application/single_app/templates/admin/_panes/chat-orchestration.html +++ b/application/single_app/templates/admin/_panes/chat-orchestration.html @@ -26,6 +26,47 @@
Available in the V2 interface only. + + +
+
+

+ SimpleChat can't read Microsoft Entra ID +

+

+ Before a plan uses web search or another external source, Chat Orchestration rereads the user's current app roles from Microsoft Graph with this application's own identity. This app registration is missing the Microsoft Graph Directory.Read.All application permission, or it has not been given administrator consent, so those steps fail for every user and they are told to contact an administrator. +

+

+ To fix it, open Microsoft Entra ID > App registrations, select this app, choose API permissions > Add a permission > Microsoft Graph > Application permissions, add Directory.Read.All, then select Grant admin consent. Granting consent needs a Global Administrator or Privileged Role Administrator. Or run: +

+
+
+
+

+ SimpleChat can't sign in to Microsoft Entra ID +

+

+ Before a plan uses web search or another external source, Chat Orchestration rereads the user's current app roles from Microsoft Graph with this application's own identity. Microsoft Entra ID refused that sign-in, so those steps fail for every user and they are told to contact an administrator. +

+

+ Check that the client secret in the MICROSOFT_PROVIDER_AUTHENTICATION_SECRET app setting is current for this app registration, and that its enterprise application is enabled for sign-in. +

+
+
+

+ Microsoft Entra ID access could not be confirmed +

+

+ Chat Orchestration needs to read Microsoft Entra ID before it uses web search or another external source, and this check could not finish. Users may see the same failure. Check again in a few minutes; if it persists, review the [ORCHESTRATION_DIRECTORY] and [ORCHESTRATION_EXECUTOR] entries in Application Insights. +

+
+
+ + +
+
diff --git a/application/single_app/templates/admin/_panes/web-research.html b/application/single_app/templates/admin/_panes/web-research.html index 7ad547075..0be3d8a78 100644 --- a/application/single_app/templates/admin/_panes/web-research.html +++ b/application/single_app/templates/admin/_panes/web-research.html @@ -29,6 +29,14 @@
Web Search (Azure AI Foundry Ag value="{{ 'true' if settings.web_search_consent_accepted else 'false' }}" > + +
+ + Chat Orchestration can't verify that users may use web search, because SimpleChat can't read Microsoft Entra ID. + Web search steps in orchestrated chats fail until this is fixed. + See how to fix it. +
+
diff --git a/application/single_app/templates/admin/_panes/web-research.html b/application/single_app/templates/admin/_panes/web-research.html index 0be3d8a78..7ad547075 100644 --- a/application/single_app/templates/admin/_panes/web-research.html +++ b/application/single_app/templates/admin/_panes/web-research.html @@ -29,14 +29,6 @@
Web Search (Azure AI Foundry Ag value="{{ 'true' if settings.web_search_consent_accepted else 'false' }}" > - -
- - Chat Orchestration can't verify that users may use web search, because SimpleChat can't read Microsoft Entra ID. - Web search steps in orchestrated chats fail until this is fixed. - See how to fix it. -
-