diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index 7ceeefbf9..6e914869b 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -4599,6 +4599,13 @@ "unavailable while it is off." ), "default": False, + # Shown beneath the switch with each one's current state and a link to it, + # because who may use what this switch makes available is decided under + # Governance, two categories away. + "related_settings": [ + {"key": "governance_global_agents_usage", "label": "Govern Global Agents"}, + {"key": "governance_global_actions_usage", "label": "Govern Global Actions"}, + ], }, { "key": "per_user_semantic_kernel", @@ -4690,6 +4697,9 @@ "personal agent governance policy when only some of them should." ), "default": False, + "related_settings": [ + {"key": "governance_user_agents", "label": "Govern Personal Agents"}, + ], }, { "key": "allow_group_agents", @@ -4700,6 +4710,9 @@ "also be enabled under Workspaces, or the agents stay invisible." ), "default": False, + "related_settings": [ + {"key": "governance_group_agents", "label": "Govern Group Agents"}, + ], }, { "key": "allow_user_custom_endpoints", @@ -4711,6 +4724,9 @@ "This lets model traffic leave the endpoints you administer." ), "default": False, + "related_settings": [ + {"key": "governance_user_endpoints", "label": "Govern Personal Endpoints"}, + ], }, { "key": "allow_group_custom_endpoints", @@ -4721,6 +4737,9 @@ "enabled under Workspaces." ), "default": False, + "related_settings": [ + {"key": "governance_group_endpoints", "label": "Govern Group Endpoints"}, + ], }, { "key": "enable_agent_template_gallery", @@ -5100,6 +5119,9 @@ "longer limited to destinations you configured." ), "default": False, + "related_settings": [ + {"key": "governance_user_actions", "label": "Govern Personal Actions"}, + ], }, { "key": "allow_group_plugins", @@ -5110,6 +5132,9 @@ "be enabled under Workspaces." ), "default": False, + "related_settings": [ + {"key": "governance_group_actions", "label": "Govern Group Actions"}, + ], }, ], "gpt-config": [ @@ -5302,6 +5327,19 @@ "label": "Inbound MCP preview", "depends_on": {"flag": "mcp_ui_enabled", "equals": False}, }, + { + # Ungrouped so it leads the section: a request that passes every check + # below still gets no tools until a source governance policy allows the + # person, and that is the step administrators miss. + "type": "component", + "component": "inbound-mcp-governance-shortcut", + "label": "Who can use inbound MCP", + "help": ( + "Passing every check below still returns no tools until an inbound MCP " + "source policy under Governance allows the person." + ), + "depends_on": {"flag": "mcp_ui_enabled", "equals": True}, + }, { "key": "enable_inbound_mcp_server", "type": "switch", @@ -6119,6 +6157,295 @@ "depends_on": {"key": "enable_custom_rate_limit_message", "equals": True}, }, ], + # Governance. Each switch turns on enforcement for one class of capability, and the + # matching feature policy then decides who passes. A switch keeps its value while + # the feature it governs is off, so turning a feature off and on again cannot + # silently drop the governance an administrator configured for it. That is why each + # prerequisite below warns rather than blocks: the switch is saved now and applies + # as soon as the feature is turned on. + # + # Rows pair the personal and group switch for one resource, so on a wide card the + # left column reads as personal scope and the right as group scope. + "governance-feature-toggles-section": [ + { + "type": "component", + "component": "governance-overview", + "label": "How governance works", + "help": ( + "Feature policies decide who may use a kind of capability; delegated " + "item policies decide who may use one specific resource. Block lists " + "always win." + ), + }, + { + "key": "governance_user_endpoints", + "type": "switch", + "label": "Govern Personal Endpoints", + "help": ( + "Checks the Personal Endpoints feature policy before someone adds, " + "tests, or chats through a model endpoint in their own workspace." + ), + "default": False, + "requires": { + "key": "allow_user_custom_endpoints", + "label": "Allow Personal Custom Endpoints", + "mode": "warn", + "target_section": "agent-toggles-card", + "description": ( + "Govern Personal Endpoints is kept, and applies as soon as people " + "may add their own endpoints under Agents & Actions > Workspace " + "Agent Permissions." + ), + }, + }, + { + "key": "governance_group_endpoints", + "type": "switch", + "label": "Govern Group Endpoints", + "help": ( + "Checks the Group Endpoints feature policy before someone adds, tests, " + "or chats through a model endpoint in a group workspace." + ), + "default": False, + "requires": { + "key": "allow_group_custom_endpoints", + "label": "Allow Group Custom Endpoints", + "mode": "warn", + "target_section": "agent-toggles-card", + "description": ( + "Govern Group Endpoints is kept, and applies as soon as groups may " + "add their own endpoints under Agents & Actions > Workspace Agent " + "Permissions." + ), + }, + }, + { + "key": "governance_user_agents", + "type": "switch", + "label": "Govern Personal Agents", + "help": ( + "Checks the Personal Agents feature policy before someone creates, " + "edits, or chats with an agent in their own workspace." + ), + "default": False, + "requires": { + "key": "allow_user_agents", + "label": "Allow Personal Agents", + "mode": "warn", + "target_section": "agent-toggles-card", + "description": ( + "Govern Personal Agents is kept, and applies as soon as personal " + "agents are allowed under Agents & Actions > Workspace Agent " + "Permissions." + ), + }, + }, + { + "key": "governance_group_agents", + "type": "switch", + "label": "Govern Group Agents", + "help": ( + "Checks the Group Agents feature policy before someone uses or manages " + "the agents a group workspace shares." + ), + "default": False, + "requires": { + "key": "allow_group_agents", + "label": "Allow Group Agents", + "mode": "warn", + "target_section": "agent-toggles-card", + "description": ( + "Govern Group Agents is kept, and applies as soon as group agents " + "are allowed under Agents & Actions > Workspace Agent Permissions." + ), + }, + }, + { + "key": "governance_user_actions", + "type": "switch", + "label": "Govern Personal Actions", + "help": ( + "Checks the Personal Actions feature policy, and any personal " + "action-type policy, before someone creates or runs an action in their " + "own workspace." + ), + "default": False, + "requires": { + "key": "allow_user_plugins", + "label": "Allow Personal Actions", + "mode": "warn", + "target_section": "plugin-feature-toggles", + "description": ( + "Govern Personal Actions is kept, and applies as soon as personal " + "actions are allowed under Agents & Actions > Workspace Action " + "Permissions." + ), + }, + }, + { + "key": "governance_group_actions", + "type": "switch", + "label": "Govern Group Actions", + "help": ( + "The same check for actions in group workspaces, using the Group " + "Actions feature policy and group action-type policies." + ), + "default": False, + "requires": { + "key": "allow_group_plugins", + "label": "Allow Group Actions", + "mode": "warn", + "target_section": "plugin-feature-toggles", + "description": ( + "Govern Group Actions is kept, and applies as soon as group actions " + "are allowed under Agents & Actions > Workspace Action Permissions." + ), + }, + }, + { + # Always on: the server-rendered save writes True on every save, and the + # runtime filters the shared AI connections through this check. A switch + # here could only ever be turned on, so it is reported, not offered. + "key": "governance_global_endpoints", + "type": "switch", + "label": "Govern Global Endpoints", + "help": ( + "Always enforced. People who pass the Global Endpoints feature policy " + "can use the shared AI connections, and a delegated item policy can " + "narrow one connection to particular people or groups." + ), + "default": True, + "readonly": True, + "managed_by": "SimpleChat, which always enforces it", + }, + { + "key": "governance_global_agents_usage", + "type": "switch", + "label": "Govern Global Agents", + "help": ( + "Checks the Global Agents feature policy before someone selects or " + "chats with an agent the organization publishes, then any delegated " + "item policy on that agent." + ), + "default": False, + "requires": { + "key": "enable_semantic_kernel", + "label": "Agents", + "mode": "warn", + "target_section": "agents-config", + "description": ( + "Global agent and action governance is kept, and applies as soon " + "as Enable Agents is turned on under Agents & Actions > Agent Runtime." + ), + }, + }, + { + "key": "governance_global_actions_usage", + "type": "switch", + "label": "Govern Global Actions", + "help": ( + "Checks the Global Actions feature policy and global action-type " + "policies before an agent uses an action the organization publishes, " + "then any delegated item policy on that action." + ), + "default": False, + "requires": { + "key": "enable_semantic_kernel", + "label": "Agents", + "mode": "warn", + "target_section": "agents-config", + "description": ( + "Global agent and action governance is kept, and applies as soon " + "as Enable Agents is turned on under Agents & Actions > Agent Runtime." + ), + }, + }, + ], + # Feature and item policies live in their own Cosmos containers rather than the + # settings document, so each is a component that saves through the governance API + # as soon as a policy is applied, separately from the page's Save bar. + "governance-feature-policies-section": [ + { + "type": "component", + "component": "governance-feature-policies", + "label": "Feature policies", + "help": ( + "Who passes each governance switch. Block lists are checked first and " + "always win; then Allow all, or the people and groups you list, decide. " + "With Allow all off and nobody listed, nobody passes." + ), + }, + ], + "governance-item-policies-section": [ + { + "type": "component", + "component": "governance-item-policies", + "label": "Delegated item policies", + "help": ( + "Who may use one specific global endpoint, agent, or action, one " + "action type, one MCP destination pattern, or one inbound MCP source. " + "When a resource has several policies, passing any one of them is " + "enough, and a block list in any of them denies." + ), + }, + ], + "governance-mcp-destination-section": [ + { + "key": "enable_mcp_destination_governance", + "type": "switch", + "role": "capability", + "label": "Enforce MCP Destination Allowlist", + "help": ( + "Remote MCP actions may only connect to destinations a policy below " + "allows for the action's scope and the person running it, checked on " + "save, discovery, connection tests, and every tool call. Create policies " + "for the servers people already use before turning this on." + ), + "default": False, + }, + { + # Deliberately not gated on the allowlist: refusing unsafe addresses is + # worth having on its own, before any allowlist exists. + "key": "mcp_block_unsafe_destinations", + "type": "switch", + "label": "Block Private and Local IP Destinations", + "help": ( + "Refuses endpoints written as a loopback, private, link-local, or cloud " + "metadata IP address, or a localhost name. Works with or without the " + "allowlist." + ), + "default": False, + }, + { + "type": "status", + "status_source": "mcp_destination_environment_policy", + "label": "Deployment Restrictions", + "help": ( + "App Service settings can require enforcement or blocking for the " + "whole deployment, and nothing on this page can turn those off." + ), + }, + { + "type": "component", + "component": "governance-mcp-destination-policies", + "label": "Destination policies", + "help": ( + "Which remote MCP servers each scope may reach, and for whom. A policy " + "names a destination pattern and the people or groups it applies to." + ), + }, + ], + "governance-inbound-mcp-section": [ + { + "type": "component", + "component": "governance-inbound-mcp-policies", + "label": "Inbound MCP source policies", + "help": ( + "Who may use SimpleChat as an inbound MCP server, and from which " + "accepted source. Requests are denied until a policy allows the person." + ), + }, + ], # ------------------------------------------------------------------ # Data Lifecycle # diff --git a/application/single_app/config.py b/application/single_app/config.py index adc62f128..2a7c46d2e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.272" +VERSION = "0.261.273" 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_governance.py b/application/single_app/functions_governance.py index 0b47bb919..84ae7640a 100644 --- a/application/single_app/functions_governance.py +++ b/application/single_app/functions_governance.py @@ -50,6 +50,24 @@ INBOUND_MCP_SYSTEM_SOURCE_POLICY_ID = "system-allow-all-sources" +# Settings whose changes belong in the governance audit log. The server-rendered save +# path records the same keys in its own `governance_feature_toggles_updated` entry; the +# V2 settings PATCH reads this list so a change made there leaves the same record. +GOVERNANCE_AUDITED_SETTING_KEYS = ( + "governance_user_endpoints", + "governance_group_endpoints", + "governance_global_endpoints", + "governance_user_agents", + "governance_group_agents", + "governance_global_agents_usage", + "governance_user_actions", + "governance_group_actions", + "governance_global_actions_usage", + "enable_mcp_destination_governance", + "mcp_block_unsafe_destinations", +) + + ACTION_TYPE_POLICY_ENTITY_TYPES = { "personal": "personal_action_type", "user": "personal_action_type", @@ -972,16 +990,67 @@ def list_item_policies(entity_type: Optional[str] = None) -> List[Dict[str, Any] normalized_rows_by_key[row_key] = normalized_row normalized_rows = list(normalized_rows_by_key.values()) - return sorted( - normalized_rows, - key=lambda item: ( - str(item.get("entity_type") or ""), - str(item.get("resource_label") or item.get("item_id") or ""), - str(item.get("policy_name") or ""), - ), + return sorted(normalized_rows, key=_item_policy_list_sort_key) + + +def _item_policy_list_sort_key(item: Dict[str, Any]) -> Tuple[str, str, str]: + return ( + str(item.get("entity_type") or ""), + str(item.get("resource_label") or item.get("item_id") or ""), + str(item.get("policy_name") or ""), ) +def normalize_item_policy_entity_type_filter(raw_value: Any) -> Tuple[List[str], List[str]]: + """Split a comma-separated entity type filter into ``(known, unknown)`` types. + + The review endpoint takes one type or several, so a list embedded in a section can + show every MCP destination scope at once. Each value goes through the legacy alias + table first, so ``endpoint`` still selects ``global_endpoint``. Unknown values are + returned rather than dropped, because silently ignoring a misspelled filter would + answer with every policy instead of the ones that were asked for. + """ + known: List[str] = [] + unknown: List[str] = [] + for raw_part in str(raw_value or "").split(","): + candidate = raw_part.strip().lower() + if not candidate: + continue + normalized = _normalize_item_policy_entity_type(candidate) + if normalized not in DEFAULT_ITEM_POLICY_ENTITY_TYPES: + if candidate not in unknown: + unknown.append(candidate) + continue + if normalized not in known: + known.append(normalized) + return known, unknown + + +def list_item_policies_for_entity_types(entity_types: Optional[List[str]] = None) -> List[Dict[str, Any]]: + """Return item policies for any of several entity types, ordered like ``list_item_policies``.""" + normalized_entity_types: List[str] = [] + for entity_type in entity_types or []: + normalized = _normalize_item_policy_entity_type(entity_type) + if normalized and normalized not in normalized_entity_types: + normalized_entity_types.append(normalized) + + if not normalized_entity_types: + return list_item_policies() + if len(normalized_entity_types) == 1: + return list_item_policies(entity_type=normalized_entity_types[0]) + + rows_by_key: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + for entity_type in normalized_entity_types: + for policy in list_item_policies(entity_type=entity_type): + row_key = ( + str(policy.get("entity_type") or ""), + str(policy.get("item_id") or ""), + str(policy.get("policy_id") or ""), + ) + rows_by_key[row_key] = policy + return sorted(rows_by_key.values(), key=_item_policy_list_sort_key) + + def list_item_policies_by_policy_id(policy_id: str) -> List[Dict[str, Any]]: normalized_policy_id = str(policy_id or "").strip() if not normalized_policy_id: diff --git a/application/single_app/functions_mcp_destinations.py b/application/single_app/functions_mcp_destinations.py index 751a9c08e..643a25739 100644 --- a/application/single_app/functions_mcp_destinations.py +++ b/application/single_app/functions_mcp_destinations.py @@ -394,6 +394,27 @@ def environment_value(key, default=None): } +def describe_mcp_destination_environment_policy(): + """Summarize the deployment's non-overridable destination floor for administrators. + + The admin settings page shows whether the environment already forces enforcement or + unsafe-address blocking, because a switch that reads "off" while the deployment + enforces anyway is misleading. Only booleans and counts are returned: an + administrator needs to know a floor exists, not to read the deployment's + configuration back out of a settings page. + """ + environment_policy = _get_environment_destination_policy_config() + scope_patterns = environment_policy.get("scope_patterns") or {} + allowed_pattern_count = len(environment_policy.get("common_patterns") or []) + sum( + len(patterns or []) for patterns in scope_patterns.values() + ) + return { + "enforcement_required": bool(environment_policy.get("enabled")), + "unsafe_blocking_required": bool(environment_policy.get("block_unsafe_destinations")), + "allowed_pattern_count": allowed_pattern_count, + } + + def get_mcp_destination_policy_config(settings=None, user_id=""): """Combine supplied current settings with a non-overridable deployment floor.""" environment_policy = _get_environment_destination_policy_config() diff --git a/application/single_app/functions_mcp_preconfigurations.py b/application/single_app/functions_mcp_preconfigurations.py index c0f95ab7a..6c16ca49f 100644 --- a/application/single_app/functions_mcp_preconfigurations.py +++ b/application/single_app/functions_mcp_preconfigurations.py @@ -553,6 +553,44 @@ def build_mcp_server_preconfigurations_response( } +def build_mcp_preconfiguration_policy_catalog(): + """Describe each enabled preconfiguration for the admin destination policy builder. + + Destination policies name a template by id (``preconfiguration:github``). The admin + settings page offers these as choices so an administrator does not have to know the + ids, and marks the templates that only surface when a policy names them explicitly: + broad patterns such as ``*`` are deliberately not enough for those. Endpoints are + left out, because an endpoint-reviewed template needs the organization's own host + rather than the placeholder shipped in its definition. + """ + catalog = [] + for preconfiguration in load_mcp_server_preconfigurations(): + preconfiguration_id = normalize_mcp_preconfiguration_id(preconfiguration.get("id")) + if not preconfiguration_id: + continue + + scopes = [] + scope_eligibility = preconfiguration.get("scopeEligibility") + for scope in scope_eligibility if isinstance(scope_eligibility, list) else []: + try: + normalized_scope = normalize_mcp_destination_scope(scope) + except McpDestinationPolicyError: + continue + if normalized_scope in MCP_VALID_PRECONFIGURATION_SCOPES and normalized_scope not in scopes: + scopes.append(normalized_scope) + + catalog.append({ + "id": preconfiguration_id, + "label": str(preconfiguration.get("displayName") or preconfiguration_id), + "catalog_tier": str(preconfiguration.get("catalogTier") or ""), + # Empty means every scope may use the template. + "scopes": scopes, + "requires_explicit_policy": bool(_requires_explicit_preconfiguration_policy(preconfiguration)), + "requires_endpoint_review": _coerce_bool(preconfiguration.get("requiresEndpointReview")), + }) + return catalog + + def evaluate_mcp_preconfiguration_manifest_policy( manifest, scope_type=None, diff --git a/application/single_app/functions_public_workspaces.py b/application/single_app/functions_public_workspaces.py index 7e3302215..84cf62d32 100644 --- a/application/single_app/functions_public_workspaces.py +++ b/application/single_app/functions_public_workspaces.py @@ -214,6 +214,85 @@ def search_all_public_workspaces(search_query: str) -> list: )) +PUBLIC_WORKSPACE_DIRECTORY_MAX_LIMIT = 200 +PUBLIC_WORKSPACE_DIRECTORY_DEFAULT_LIMIT = 50 + + +def _to_public_workspace_directory_row(workspace_doc): + """Project a public workspace down to the fields an administrator's picker needs.""" + return { + "id": str(workspace_doc.get("id") or ""), + "name": workspace_doc.get("name") or "", + "description": workspace_doc.get("description") or "", + } + + +def find_public_workspaces_by_ids(workspace_ids): + """Return directory rows for specific public workspace ids, skipping ids that no longer exist. + + The counterpart of ``find_groups_by_ids``. Governance policies store public + workspace ids beside group ids, so labelling a saved policy has to resolve both. + An id that resolves to nothing is absent from the result, which is how the caller + tells a live entry apart from one pointing at a deleted workspace. + """ + wanted_ids = [str(workspace_id or "").strip() for workspace_id in (workspace_ids or [])] + wanted_ids = [workspace_id for workspace_id in wanted_ids if workspace_id] + if not wanted_ids: + return [] + + results = list(cosmos_public_workspaces_container.query_items( + query="SELECT c.id, c.name, c.description FROM c WHERE ARRAY_CONTAINS(@ids, c.id)", + parameters=[{"name": "@ids", "value": wanted_ids}], + enable_cross_partition_query=True + )) + + rows_by_id = {row["id"]: row for row in (_to_public_workspace_directory_row(doc) for doc in results)} + return [rows_by_id[workspace_id] for workspace_id in wanted_ids if workspace_id in rows_by_id] + + +def list_public_workspaces_for_admin_directory(search_query="", limit=PUBLIC_WORKSPACE_DIRECTORY_DEFAULT_LIMIT): + """Return ``(rows, truncated)`` for an administrator's public workspace picker. + + Mirrors ``list_groups_for_admin_directory``: an empty search browses, and one + row more than ``limit`` is requested so a truncated result is reported rather + than silently hiding workspaces beyond the cap. + """ + normalized_query = str(search_query or "").strip().lower() + capped_limit = max(1, min(int(limit or PUBLIC_WORKSPACE_DIRECTORY_DEFAULT_LIMIT), PUBLIC_WORKSPACE_DIRECTORY_MAX_LIMIT)) + + filters = [] + parameters = [] + if normalized_query: + filters.append( + "(CONTAINS(LOWER(c.name), @search)" + " OR (IS_DEFINED(c.description) AND CONTAINS(LOWER(c.description), @search))" + " OR LOWER(c.id) = @search)" + ) + parameters.append({"name": "@search", "value": normalized_query}) + + where_clause = f"WHERE {' AND '.join(filters)}" if filters else "" + # No ORDER BY, for the same reason as the group directory: Cosmos drops + # documents that lack the sorted property. The page is sorted below. + query = f""" + SELECT c.id, c.name, c.description + FROM c + {where_clause} + OFFSET 0 LIMIT @limit + """ + parameters.append({"name": "@limit", "value": capped_limit + 1}) + + results = list(cosmos_public_workspaces_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True + )) + + truncated = len(results) > capped_limit + rows = [_to_public_workspace_directory_row(doc) for doc in results[:capped_limit]] + rows.sort(key=lambda row: (row["name"].lower(), row["id"])) + return rows, truncated + + def delete_public_workspace(ws_id: str) -> None: """ Deletes a public workspace from Cosmos DB. Typically only the owner may call this. diff --git a/application/single_app/route_backend_governance.py b/application/single_app/route_backend_governance.py index fc524409a..6029e9093 100644 --- a/application/single_app/route_backend_governance.py +++ b/application/single_app/route_backend_governance.py @@ -2,26 +2,47 @@ """Admin API routes for governance policy management.""" +import logging + from flask import jsonify, request, session +from functions_appinsights import log_event from functions_authentication import admin_required, get_current_user_id, login_required from functions_governance import ( DEFAULT_FEATURE_POLICIES, bootstrap_default_feature_policies, delete_item_policy, list_item_policies_by_policy_id, + list_item_policies_for_entity_types, list_feature_policies, list_item_policies, + normalize_item_policy_entity_type_filter, retarget_item_policy, upsert_feature_policy, upsert_item_policy, ) +from functions_group import find_groups_by_ids, list_groups_for_admin_directory +from functions_mcp_operations import MCP_REMOTE_TRANSPORTS +from functions_mcp_preconfigurations import build_mcp_preconfiguration_policy_catalog +from functions_mcp_presets import load_mcp_server_presets +from functions_public_workspaces import ( + find_public_workspaces_by_ids, + list_public_workspaces_for_admin_directory, +) from swagger_wrapper import get_auth_security, swagger_route DEFAULT_GOVERNANCE_REVIEW_PAGE_SIZE = 25 MAX_GOVERNANCE_REVIEW_PAGE_SIZE = 100 +# Governance group principals are group workspaces and public workspaces, because a +# user's governance groups are resolved from both memberships. A search returns up to +# this many of each kind; resolving saved ids is capped so one request stays bounded. +GOVERNANCE_PRINCIPAL_GROUP_SEARCH_LIMIT = 25 +GOVERNANCE_PRINCIPAL_GROUP_RESOLVE_LIMIT = 100 +GOVERNANCE_PRINCIPAL_GROUP_KIND_GROUP = "group" +GOVERNANCE_PRINCIPAL_GROUP_KIND_PUBLIC_WORKSPACE = "public_workspace" + def _normalize_actor_email() -> str: user = session.get("user") if isinstance(session.get("user"), dict) else {} @@ -164,6 +185,28 @@ def _item_policy_target_changed( ) +def _principal_group_row(row, kind): + row = row if isinstance(row, dict) else {} + member_count = row.get("member_count") + return { + "id": str(row.get("id") or ""), + "name": str(row.get("name") or ""), + "description": str(row.get("description") or ""), + "kind": kind, + "member_count": member_count if isinstance(member_count, int) else None, + } + + +def _parse_principal_group_ids(raw_ids): + """Split a comma-separated id list, keeping order and dropping blanks and repeats.""" + requested_ids = [] + for raw_id in str(raw_ids or "").split(","): + candidate = raw_id.strip() + if candidate and candidate not in requested_ids: + requested_ids.append(candidate) + return requested_ids + + def register_route_backend_governance(bp): @bp.route('/api/admin/governance/policies', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -248,11 +291,26 @@ def get_governance_item_policies_route(): @login_required @admin_required def review_governance_item_policies_route(): - entity_type = str(request.args.get('entity_type') or '').strip().lower() or None + # One type or a comma-separated list, so a section can embed every MCP + # destination scope in one list. A misspelled type is refused rather than + # ignored, because ignoring it would answer with every policy instead. + entity_types, unknown_entity_types = normalize_item_policy_entity_type_filter( + request.args.get('entity_type') + ) + if unknown_entity_types: + return jsonify({'error': 'Unknown delegated item entity type.'}), 400 + item_id = str(request.args.get('item_id') or '').strip() search = str(request.args.get('search') or '').strip().lower() page, per_page = _normalize_review_pagination(request.args) - policies = list_item_policies(entity_type=entity_type) + policies = list_item_policies_for_entity_types(entity_types) + if item_id: + # An exact match on the stored item id, for listing the policies that + # govern one resource from that resource's own settings. + policies = [ + policy for policy in policies + if str(policy.get("item_id") or "").strip() == item_id + ] if search: policies = [ policy for policy in policies @@ -276,7 +334,9 @@ def review_governance_item_policies_route(): 'has_next': page < total_pages, }, 'search': search, - 'entity_type': entity_type, + 'entity_type': ','.join(entity_types) or None, + 'entity_types': entity_types, + 'item_id': item_id or None, }), 200 @bp.route('/api/admin/governance/item-policies//', methods=['PUT']) @@ -409,3 +469,109 @@ def delete_governance_item_policy_json_route(): return jsonify({'error': 'entity_type and item_id are required.'}), 400 return _delete_governance_item_policy(normalized_entity_type, normalized_item_id, normalized_policy_id) + + @bp.route('/api/admin/governance/principal-groups', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_governance_principal_groups_route(): + """Search or resolve the groups a governance policy can name. + + A user's governance groups come from both group workspace and public workspace + membership, so a policy may hold either kind of id. ``ids`` labels a saved + policy: ids that resolve to nothing are absent from the answer, which is how + the caller tells a deleted workspace apart from a live one. Otherwise + ``search`` matches name, description or id across both kinds. + """ + raw_ids = str(request.args.get('ids') or '').strip() + try: + if raw_ids: + requested_ids = _parse_principal_group_ids(raw_ids) + if len(requested_ids) > GOVERNANCE_PRINCIPAL_GROUP_RESOLVE_LIMIT: + return jsonify({ + 'error': ( + 'Too many group ids requested. Limit is ' + f'{GOVERNANCE_PRINCIPAL_GROUP_RESOLVE_LIMIT}.' + ), + }), 400 + + rows = [ + _principal_group_row(row, GOVERNANCE_PRINCIPAL_GROUP_KIND_GROUP) + for row in find_groups_by_ids(requested_ids) + ] + found_ids = {row['id'] for row in rows} + remaining_ids = [group_id for group_id in requested_ids if group_id not in found_ids] + if remaining_ids: + rows.extend( + _principal_group_row(row, GOVERNANCE_PRINCIPAL_GROUP_KIND_PUBLIC_WORKSPACE) + for row in find_public_workspaces_by_ids(remaining_ids) + ) + rows_by_id = {row['id']: row for row in rows} + return jsonify({ + 'groups': [rows_by_id[group_id] for group_id in requested_ids if group_id in rows_by_id], + 'truncated': False, + }), 200 + + search = str(request.args.get('search') or '').strip() + groups, groups_truncated = list_groups_for_admin_directory( + search_query=search, + limit=GOVERNANCE_PRINCIPAL_GROUP_SEARCH_LIMIT, + ) + workspaces, workspaces_truncated = list_public_workspaces_for_admin_directory( + search_query=search, + limit=GOVERNANCE_PRINCIPAL_GROUP_SEARCH_LIMIT, + ) + rows = [ + _principal_group_row(row, GOVERNANCE_PRINCIPAL_GROUP_KIND_GROUP) for row in groups + ] + [ + _principal_group_row(row, GOVERNANCE_PRINCIPAL_GROUP_KIND_PUBLIC_WORKSPACE) for row in workspaces + ] + rows.sort(key=lambda row: (row['name'].lower(), row['kind'], row['id'])) + return jsonify({ + 'groups': rows, + 'truncated': bool(groups_truncated or workspaces_truncated), + }), 200 + except Exception as exc: + log_event( + "[GOVERNANCE_ADMIN] Failed to load governance principal groups.", + extra={ + 'error_type': type(exc).__name__, + 'mode': 'resolve' if raw_ids else 'search', + }, + level=logging.ERROR, + ) + return jsonify({'error': 'Failed to load groups.'}), 500 + + @bp.route('/api/admin/governance/mcp-destination-catalog', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_governance_mcp_destination_catalog_route(): + """Return the ids an MCP destination policy can name. + + A destination policy names a template as ``preconfiguration:`` or + ``preset:``, and a transport as ``transport:``. Offering these as + choices means an administrator does not have to know the ids, and a value + that could never match is visible before it is saved. + """ + try: + presets = [ + { + 'id': str(preset.get('id') or '').strip(), + 'label': str(preset.get('displayName') or preset.get('id') or '').strip(), + } + for preset in load_mcp_server_presets() + if str(preset.get('id') or '').strip() + ] + return jsonify({ + 'preconfigurations': build_mcp_preconfiguration_policy_catalog(), + 'presets': presets, + 'transports': sorted(MCP_REMOTE_TRANSPORTS), + }), 200 + except Exception as exc: + log_event( + "[GOVERNANCE_ADMIN] Failed to load the MCP destination catalog.", + extra={'error_type': type(exc).__name__}, + level=logging.ERROR, + ) + return jsonify({'error': 'Failed to load the MCP destination catalog.'}), 500 diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index 5f754cb75..56f0adbfe 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -47,12 +47,14 @@ from admin_settings_nav import ADMIN_NAV from content_screening.contracts import ScreeningError from functions_mcp_server_config import is_mcp_ui_enabled +from functions_mcp_destinations import describe_mcp_destination_environment_policy from config import ( CLIENTS, ensure_custom_favicon_file_exists, ensure_custom_logo_file_exists, get_allowed_extension_categories, ) +from functions_activity_logging import log_governance_change from functions_appinsights import get_appinsights_runtime_state, log_event from functions_branding_images import ( ALLOWED_FAVICON_EXTENSIONS, @@ -82,6 +84,7 @@ ) from functions_conversation_contents import is_conversation_contents_drawer_enabled from functions_custom_pages import get_custom_pages_nav +from functions_governance import GOVERNANCE_AUDITED_SETTING_KEYS from functions_group import ( GROUP_DIRECTORY_DEFAULT_LIMIT, GROUP_DIRECTORY_MAX_LIMIT, @@ -1188,6 +1191,59 @@ def _seed_connections_on_first_enable(updates, current_settings): ) +def _log_governance_setting_changes(current_settings, normalized): + """Record governance switch changes in the governance audit log. + + The server-rendered save writes one ``governance_feature_toggles_updated`` entry, + with the before and after state of every governance switch, whenever a save + changes one of them. The V2 page saves through the settings PATCH instead, so + without this a governance change made there would leave no audit trail at all. + + Nothing is coerced here. A governance switch keeps its value while the feature it + governs is off, so turning a feature off and on again cannot silently drop the + governance an administrator configured for it. + """ + changed_toggles = {} + for key in GOVERNANCE_AUDITED_SETTING_KEYS: + if key not in normalized: + continue + before_value = bool(current_settings.get(key, False)) + after_value = bool(normalized[key]) + if before_value != after_value: + changed_toggles[key] = {"before": before_value, "after": after_value} + + if not changed_toggles: + return + + before_state = {key: bool(current_settings.get(key, False)) for key in GOVERNANCE_AUDITED_SETTING_KEYS} + after_state = dict(before_state) + after_state.update({key: change["after"] for key, change in changed_toggles.items()}) + + try: + user_info = get_current_user_info() or {} + log_governance_change( + admin_user_id=str(get_current_user_id() or "").strip(), + admin_email=str(user_info.get("email") or "").strip(), + action="governance_feature_toggles_updated", + scope="feature_policy", + target_id="governance_feature_toggles", + before_state=before_state, + after_state=after_state, + change_details={"changed_toggles": changed_toggles}, + ) + except Exception as exc: + # The settings are already saved; a failed audit write is reported rather + # than turned into a failed save the administrator would retry. + log_event( + "[V2_ADMIN_SETTINGS] Failed to record a governance setting change.", + extra={ + "error_type": type(exc).__name__, + "changed_keys": sorted(changed_toggles), + }, + level=logging.ERROR, + ) + + def register_route_backend_v2_admin(bp): def _build_model_catalog(settings): """Return the models an administrator can pick, with resolved capabilities. @@ -1297,6 +1353,49 @@ def _build_status_readouts(): "message": "Audio runtime support could not be checked.", } + try: + floor = describe_mcp_destination_environment_policy() + forced = [] + if floor.get("enforcement_required"): + forced.append("requires the destination allowlist") + if floor.get("unsafe_blocking_required"): + forced.append("blocks private and local IP destinations") + pattern_count = int(floor.get("allowed_pattern_count") or 0) + pattern_note = ( + f" It contributes {pattern_count} allowed destination pattern" + f"{'' if pattern_count == 1 else 's'}." + if pattern_count + else "" + ) + if forced: + # Tinted as a warning because a switch above can read "off" while the + # deployment enforces anyway, and that is worth noticing. + readouts["mcp_destination_environment_policy"] = { + "ok": False, + "message": ( + f"The deployment environment {' and '.join(forced)}. Settings " + f"here can add restrictions but cannot turn these off.{pattern_note}" + ), + } + else: + readouts["mcp_destination_environment_policy"] = { + "ok": True, + "message": ( + "The deployment environment adds no destination restrictions, " + f"so the switches above decide enforcement.{pattern_note}" + ), + } + except Exception as exc: + log_event( + "[V2_ADMIN_SETTINGS] MCP destination environment policy could not be read.", + extra={"error_type": type(exc).__name__}, + level=logging.WARNING, + ) + readouts["mcp_destination_environment_policy"] = { + "ok": False, + "message": "Deployment destination restrictions could not be checked.", + } + readouts["appinsights_connection"] = _build_appinsights_connection_readout() return readouts @@ -1648,6 +1747,7 @@ def v2_admin_patch_settings(): f"{', '.join(sorted(normalized.keys()))}", level=logging.INFO, ) + _log_governance_setting_changes(current_settings, normalized) # Logo scale and title changes are read from the settings document on the # next request, but the favicon and logo static files are written from it, so diff --git a/application/v2_ui/src/components/admin/ModelConnectionsManager.tsx b/application/v2_ui/src/components/admin/ModelConnectionsManager.tsx index d33007790..ee50c9614 100644 --- a/application/v2_ui/src/components/admin/ModelConnectionsManager.tsx +++ b/application/v2_ui/src/components/admin/ModelConnectionsManager.tsx @@ -23,6 +23,7 @@ import { RefreshCw, Search, Server, + ShieldCheck, Trash2, Zap, } from 'lucide-react'; @@ -1420,6 +1421,7 @@ export function ModelConnectionsManager({ help, adapter = ADMIN_MODEL_CONNECTIONS_ADAPTER, landmark = true, + onManageAccess, }: { help?: string; adapter?: ModelConnectionsAdapter; @@ -1429,6 +1431,11 @@ export function ModelConnectionsManager({ * nested inside it would only repeat the landmark for screen reader users. */ landmark?: boolean; + /** + * Opens the governance policies for one connection. Only Admin Settings passes it: + * delegated item policies govern global connections, not a group's own endpoints. + */ + onManageAccess?: (connection: ModelConnection) => void; }) { const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); @@ -1732,6 +1739,17 @@ export function ModelConnectionsManager({
+ {onManageAccess ? ( + + ) : null} {adapter.allows('enable', connection) ? ( + ) : targetSection ? ( Configure {requirement.label} @@ -329,6 +347,7 @@ export function SettingsSection({ appearance, guide, runtimeFlags, + onNavigate, children, }: SettingsSectionProps) { const capability = useMemo(() => findCapabilityField(fields), [fields]); @@ -552,6 +571,7 @@ export function SettingsSection({ key={requirement.key} requirement={requirement} satisfied={asBoolean(readSectionValue(settings, draft, requirement.key, fieldsByKey))} + onNavigate={onNavigate} /> ))} diff --git a/application/v2_ui/src/components/admin/agentSectionAppearance.ts b/application/v2_ui/src/components/admin/agentSectionAppearance.ts index fd3b79df6..7ab6588ea 100644 --- a/application/v2_ui/src/components/admin/agentSectionAppearance.ts +++ b/application/v2_ui/src/components/admin/agentSectionAppearance.ts @@ -1,6 +1,6 @@ // agentSectionAppearance.ts -import { UserRound, UsersRound } from 'lucide-react'; +import { Globe, UserRound, UsersRound } from 'lucide-react'; import type { SettingsSectionAppearance } from './SettingsSection'; import { RETENTION_SCOPE_ICONS } from './retentionScopeIcons'; @@ -8,9 +8,9 @@ import { RETENTION_SCOPE_ICONS } from './retentionScopeIcons'; // // Section icons now come from the navigation definition, and which switch leads which // settings is derived from `depends_on`, so the Agent Runtime emphasis no longer needs -// declaring here. What remains are cues the schema cannot express: whether a workspace -// permission applies to a person or to a group, and which workspace type each retention -// switch governs. +// declaring here. What remains are cues the schema cannot express: whether a setting +// applies to a person, to a group, or to everyone, and which workspace type each +// retention switch governs. export const agentSectionAppearances: Readonly< Partial> > = { @@ -23,6 +23,21 @@ export const agentSectionAppearances: Readonly< allow_group_custom_endpoints: { Icon: UsersRound }, }, }, + // The schema pairs each personal switch with its group counterpart, so on a wide card + // the person icon leads the left column and the group icon the right. + 'governance-feature-toggles-section': { + fields: { + governance_user_endpoints: { Icon: UserRound }, + governance_user_agents: { Icon: UserRound }, + governance_user_actions: { Icon: UserRound }, + governance_group_endpoints: { Icon: UsersRound }, + governance_group_agents: { Icon: UsersRound }, + governance_group_actions: { Icon: UsersRound }, + governance_global_endpoints: { Icon: Globe }, + governance_global_agents_usage: { Icon: Globe }, + governance_global_actions_usage: { Icon: Globe }, + }, + }, // The three workspace types are peers, so the first is not promoted to the switch the // card is about, which is what the derived hierarchy would otherwise do with it. 'retention-policy-section': { diff --git a/application/v2_ui/src/components/admin/governance/GovernanceDialogHost.tsx b/application/v2_ui/src/components/admin/governance/GovernanceDialogHost.tsx new file mode 100644 index 000000000..120c08324 --- /dev/null +++ b/application/v2_ui/src/components/admin/governance/GovernanceDialogHost.tsx @@ -0,0 +1,96 @@ +// GovernanceDialogHost.tsx +// Renders whichever governance dialog the store asks for. +// +// Mounted once by the Admin Settings page, so any section can open the item policy editor +// or a resource's access list without owning a dialog of its own. + +import { entityTypeLabel, newItemPolicyDraft } from '../../../lib/governance'; +import type { Json } from '../../../lib/types'; +import { + governancePoliciesChanged, + useGovernanceStore, + type GovernanceResourceTarget, +} from '../../../stores/governanceStore'; +import { AdminModal } from '../AdminModal'; +import { GlassButton } from '../../ui/primitives'; +import { GovernanceItemPolicyEditor } from './GovernanceItemPolicyEditor'; +import { GovernanceItemPolicyManager } from './GovernanceItemPolicyManager'; + +/** What a resource's access list says when no policy narrows it, by resource type. */ +const ACCESS_EXPLANATIONS: Partial> = { + global_endpoint: { + intro: 'Global endpoint governance is always on. People must pass the Global Endpoints feature policy first, then any one of the policies listed here.', + empty: 'No policy narrows this connection, so everyone who passes the Global Endpoints feature policy can use it.', + }, +}; + +function GovernanceResourceAccess({ + target, + onClose, +}: { + target: GovernanceResourceTarget; + onClose: () => void; +}) { + const explanation = ACCESS_EXPLANATIONS[target.entityType]; + const typeLabel = entityTypeLabel(target.entityType).toLowerCase(); + return ( + + Close + + )} + > +
+ {explanation ?

{explanation.intro}

: null} + newItemPolicyDraft({ + entity_type: target.entityType, + item_id: target.itemId, + resource_label: target.label, + })} + newPolicyLabel="New access policy" + returnTo={target} + emptyText={explanation?.empty ?? 'No policy narrows this item yet.'} + label={`Access policies for ${target.label}`} + /> +
+
+ ); +} + +export function GovernanceDialogHost({ settings }: { settings: Json }) { + const dialog = useGovernanceStore((state) => state.dialog); + const close = useGovernanceStore((state) => state.close); + + if (!dialog) { + return null; + } + + if (dialog.kind === 'editor') { + const { id } = dialog; + return ( + { + if (saved) { + governancePoliciesChanged(); + } + close(id); + }} + /> + ); + } + + const { id } = dialog; + return close(id)} />; +} diff --git a/application/v2_ui/src/components/admin/governance/GovernanceFeaturePolicies.tsx b/application/v2_ui/src/components/admin/governance/GovernanceFeaturePolicies.tsx new file mode 100644 index 000000000..08fe0332b --- /dev/null +++ b/application/v2_ui/src/components/admin/governance/GovernanceFeaturePolicies.tsx @@ -0,0 +1,408 @@ +// GovernanceFeaturePolicies.tsx +// Who passes each governance switch, edited in place. +// +// The classic page showed only the policies whose switch was on, as a table of hidden +// comma-separated inputs saved together by one button. Here every feature is listed, so a +// policy can be prepared before its switch is turned on, and each row says plainly whether +// it is being enforced right now: on, off, or waiting for the feature it governs. A row opens +// in place to edit, and saves on its own through the governance API rather than through the +// page's Save bar, because policies live in their own store. + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { clsx } from 'clsx'; +import { AlertCircle, AlertTriangle, ArrowRight, Pencil, RotateCw } from 'lucide-react'; +import { + GOVERNANCE_FEATURES, + GOVERNANCE_SCOPE_LABELS, + allowsNobody, + describeFeatureEnforcement, + fetchFeaturePolicies, + governanceErrorMessage, + normalizePolicyState, + principalsForSave, + saveFeaturePolicy, + summarizeAllowed, + summarizeBlocked, + EMPTY_PRINCIPALS, + type FeatureEnforcement, + type FeatureEnforcementState, + type GovernanceFeature, + type GovernanceFeaturePolicy, + type GovernancePrincipals, + type GovernanceScope, +} from '../../../lib/governance'; +import type { Json } from '../../../lib/types'; +import { toast } from '../../../stores/toastStore'; +import { GlassButton, Skeleton, Toggle } from '../../ui/primitives'; +import { PrincipalListEditor } from './PrincipalListEditor'; + +const TOGGLES_SECTION_ID = 'governance-feature-toggles-section'; + +const STATE_PRESENTATION: Record = { + enforced: { label: 'Enforced', className: 'border-ok/40 bg-ok/5 text-ok' }, + off: { label: 'Not enforced', className: 'border-edge-strong text-text-3' }, + waiting: { label: 'Waiting for its feature', className: 'border-warn/40 bg-warn/5 text-warn' }, +}; + +function samePrincipals(left: GovernancePrincipals, right: GovernancePrincipals): boolean { + const a = principalsForSave(left); + const b = principalsForSave(right); + return a.allow_all === b.allow_all + && a.allowed_users.join('\n') === b.allowed_users.join('\n') + && a.allowed_groups.join('\n') === b.allowed_groups.join('\n') + && a.denied_users.join('\n') === b.denied_users.join('\n') + && a.denied_groups.join('\n') === b.denied_groups.join('\n'); +} + +function EnforcementBadge({ + feature, + enforcement, + onNavigate, +}: { + feature: GovernanceFeature; + enforcement: FeatureEnforcement; + onNavigate: (sectionId: string) => void; +}) { + const live = STATE_PRESENTATION[enforcement.state]; + const pending = enforcement.pending ? STATE_PRESENTATION[enforcement.pending] : null; + const waitingOn = feature.primary?.label; + + // Where the administrator goes to change what the badge reports. + const target = enforcement.state === 'waiting' && feature.primary + ? feature.primary.section + : enforcement.state === 'off' + ? TOGGLES_SECTION_ID + : null; + + return ( +
+ + {enforcement.state === 'waiting' && waitingOn ? `Waiting for ${waitingOn}` : live.label} + + {pending ? ( + + + ) : null} + {target && !feature.alwaysEnforced ? ( + + ) : null} +
+ ); +} + +function FeaturePolicyEditor({ + feature, + saved, + onCancel, + onSaved, + onDirtyChange, +}: { + feature: GovernanceFeature; + saved: GovernancePrincipals; + onCancel: () => void; + onSaved: (policy: GovernanceFeaturePolicy | null) => void; + /** Must be stable; called with false when the editor closes. */ + onDirtyChange: (dirty: boolean) => void; +}) { + const [draft, setDraft] = useState(() => ({ ...saved })); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const dirty = !samePrincipals(draft, saved); + const prefix = `governance-feature-${feature.key}`; + + useEffect(() => { + onDirtyChange(dirty); + }, [dirty, onDirtyChange]); + useEffect(() => () => onDirtyChange(false), [onDirtyChange]); + + useEffect(() => { + if (!dirty) { + return; + } + const beforeUnload = (event: BeforeUnloadEvent) => event.preventDefault(); + window.addEventListener('beforeunload', beforeUnload); + return () => window.removeEventListener('beforeunload', beforeUnload); + }, [dirty]); + + const update = (changes: Partial) => setDraft((current) => ({ ...current, ...changes })); + + const save = async () => { + setSaving(true); + setError(null); + try { + const policy = await saveFeaturePolicy(feature.key, draft); + toast.success(`${feature.label} policy saved.`); + onSaved(policy); + } catch (saveError) { + setError(governanceErrorMessage(saveError, 'The policy could not be saved.')); + } finally { + setSaving(false); + } + }; + + return ( +
+ update({ allow_all: next })} + /> + + {!draft.allow_all ? ( +
+ update({ allowed_users: next })} + emptyText="No people listed." + idPrefix={`${prefix}-allowed-users`} + disabled={saving} + /> + update({ allowed_groups: next })} + emptyText="No groups listed. A group or public workspace here stands for its members." + idPrefix={`${prefix}-allowed-groups`} + disabled={saving} + /> +
+ ) : null} + + {allowsNobody(draft) ? ( +

+

+ ) : null} + +
+ update({ denied_users: next })} + emptyText="Nobody blocked." + idPrefix={`${prefix}-denied-users`} + disabled={saving} + /> + update({ denied_groups: next })} + emptyText="No groups blocked." + idPrefix={`${prefix}-denied-groups`} + disabled={saving} + /> +
+

+ A blocked person, or a member of a blocked group, is refused even when Allow everyone or an + allowed group would let them through. +

+ + {error ? ( +

+

+ ) : null} + +
+
+

Saves now, separately from the Save bar.

+ {dirty ? ( +

+ Settings categories and search are locked until you save or discard this policy. +

+ ) : null} +
+ + {dirty ? 'Discard changes' : 'Close'} + + void save()}> + {saving ? 'Saving…' : 'Save policy'} + +
+
+ ); +} + +export function GovernanceFeaturePolicies({ + help, + settings, + draft, + onNavigate, + onDirtyChange, +}: { + help?: string; + settings: Json; + draft: Json; + onNavigate: (sectionId: string) => void; + /** + * Whether a policy has unsaved edits. Leaving the category or searching would unmount + * this section and discard them, so the page locks both until the edit is settled. + */ + onDirtyChange?: (dirty: boolean) => void; +}) { + const [policies, setPolicies] = useState | null>(null); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(null); + const [editorDirty, setEditorDirty] = useState(false); + const [reload, setReload] = useState(0); + + useEffect(() => { + onDirtyChange?.(editorDirty); + }, [editorDirty, onDirtyChange]); + // Released however the section goes away, so the page can never stay locked. + useEffect(() => () => onDirtyChange?.(false), [onDirtyChange]); + + const load = useCallback(async (signal: AbortSignal) => { + try { + const loaded = await fetchFeaturePolicies(signal); + if (!signal.aborted) { + setPolicies(Object.fromEntries(loaded.map((policy) => [policy.feature_key, policy]))); + setError(null); + } + } catch (loadError) { + if (!signal.aborted) { + setError(governanceErrorMessage(loadError, 'Feature policies could not be loaded.')); + } + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load, reload]); + + const grouped = useMemo(() => { + const scopes: GovernanceScope[] = ['personal', 'group', 'global']; + return scopes.map((scope) => ({ + scope, + features: GOVERNANCE_FEATURES.filter((feature) => feature.scope === scope), + })); + }, []); + + return ( +
+ {help ?

{help}

: null} + + {error ? ( +
+
+ ) : null} + +
+ {grouped.map(({ scope, features }) => ( +
+

+ {GOVERNANCE_SCOPE_LABELS[scope]} +

+
    + {features.map((feature) => { + const policy = policies?.[feature.key]; + const principals = policy ?? EMPTY_PRINCIPALS; + const blocked = summarizeBlocked(principals); + const enforcement = describeFeatureEnforcement(feature, settings, draft); + const isEditing = editing === feature.key; + // The Edit button collapses an open editor, which would discard unsaved edits. + const holdingEdits = isEditing && editorDirty; + return ( +
  • +
    +
    +

    {feature.label}

    +

    {feature.summary}

    +
    + +
    + {policies === null && !error ? ( + + ) : ( + <> +

    + Allows + {summarizeAllowed(principals)} +

    + {blocked ? ( +

    + Blocks + {blocked} +

    + ) : null} + + )} +
    +
    + setEditing(isEditing ? null : feature.key)} + > + +
    +
    + + {isEditing ? ( + setEditing(null)} + onDirtyChange={setEditorDirty} + onSaved={(saved) => { + if (saved) { + setPolicies((current) => ({ ...(current ?? {}), [saved.feature_key]: saved })); + } else { + setReload((value) => value + 1); + } + setEditing(null); + }} + /> + ) : null} +
  • + ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/application/v2_ui/src/components/admin/governance/GovernanceItemPolicyEditor.tsx b/application/v2_ui/src/components/admin/governance/GovernanceItemPolicyEditor.tsx new file mode 100644 index 000000000..2542fad2a --- /dev/null +++ b/application/v2_ui/src/components/admin/governance/GovernanceItemPolicyEditor.tsx @@ -0,0 +1,541 @@ +// GovernanceItemPolicyEditor.tsx +// Create, edit, or move one delegated item policy. +// +// The editor is a dialog because it is opened from several places on the page -- the +// Delegated Item Policies list, the MCP destination and Inbound MCP cards, the Inbound MCP +// shortcut, and an AI connection's access list -- and because it saves on its own the +// moment it is applied, unlike the settings around it, which wait for the Save bar. +// +// Editing a saved policy may change what it applies to. The policy keeps its ID and moves, +// rather than leaving a copy on the old target, which is what the governance API does when +// it is told the original target. + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { AlertCircle, AlertTriangle, Info, Loader2, RotateCw } from 'lucide-react'; +import { + GOVERNANCE_ENTITY_TYPES, + allowsNobody, + buildMcpDestinationItemId, + checkMcpDestinationPattern, + defaultItemPolicyName, + entityTypeDefinition, + fetchActionTypeOptions, + fetchGlobalActionOptions, + fetchGlobalAgentOptions, + fetchMcpDestinationCatalog, + governanceErrorMessage, + inboundSourceOptions, + isMcpDestinationEntityType, + parseMcpDestinationItemId, + principalsForSave, + saveItemPolicy, + validateItemPolicyDraft, + type GovernanceEntityType, + type GovernanceItemSource, + type ItemOption, + type ItemPolicyDraft, + type McpDestinationCatalog, + type McpPatternCheck, + type McpPatternParts, +} from '../../../lib/governance'; +import { fetchModelConnections, providerLabel } from '../../../lib/modelConnections'; +import type { Json } from '../../../lib/types'; +import { toast } from '../../../stores/toastStore'; +import { AdminModal } from '../AdminModal'; +import { GlassButton, Toggle } from '../../ui/primitives'; +import { McpDestinationPatternField } from './McpDestinationPatternField'; +import { PrincipalListEditor } from './PrincipalListEditor'; + +const inputClass = + 'w-full rounded-lg border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 placeholder:text-text-3 focus:border-accent focus:outline-none disabled:opacity-60'; + +/** Item lists change rarely, so one load serves every editor opened on the page. */ +const optionCache = new Map(); +let catalogCache: McpDestinationCatalog | null = null; + +async function loadItemOptions(source: GovernanceItemSource, signal: AbortSignal): Promise { + switch (source) { + case 'connections': { + const response = await fetchModelConnections(signal); + return (response.endpoints ?? []).map((connection) => ({ + value: connection.id, + label: connection.name || connection.id, + detail: `${providerLabel(connection.provider)}${connection.enabled === false ? ' · disabled' : ''}`, + })); + } + case 'global_agents': + return fetchGlobalAgentOptions(signal); + case 'global_actions': + return fetchGlobalActionOptions(signal); + case 'action_types': + return fetchActionTypeOptions(signal); + default: + return []; + } +} + +function sameDraft(left: ItemPolicyDraft, right: ItemPolicyDraft): boolean { + return JSON.stringify({ ...left, ...principalsForSave(left), original: undefined }) + === JSON.stringify({ ...right, ...principalsForSave(right), original: undefined }); +} + +function ItemPicker({ + source, + value, + settings, + disabled, + idPrefix, + onChange, +}: { + source: GovernanceItemSource; + value: string; + settings: Json; + disabled?: boolean; + idPrefix: string; + onChange: (value: string, label: string) => void; +}) { + // Inbound sources come from the settings on the page, so they are derived, not fetched. + const inboundOptions = useMemo( + () => (source === 'inbound_sources' ? inboundSourceOptions(settings) : null), + [source, settings], + ); + const [loaded, setLoaded] = useState(() => optionCache.get(source) ?? null); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(''); + const [reload, setReload] = useState(0); + + useEffect(() => { + if (source === 'inbound_sources') { + return; + } + if (!reload && optionCache.has(source)) { + setLoaded(optionCache.get(source) ?? []); + return; + } + const controller = new AbortController(); + setLoaded(null); + setError(null); + void loadItemOptions(source, controller.signal) + .then((options) => { + if (!controller.signal.aborted) { + optionCache.set(source, options); + setLoaded(options); + } + }) + .catch((loadError) => { + if (!controller.signal.aborted) { + setLoaded([]); + setError(governanceErrorMessage(loadError, 'The list could not be loaded.')); + } + }); + return () => controller.abort(); + }, [source, reload]); + + const options = inboundOptions ?? loaded; + + const visible = useMemo(() => { + const needle = filter.trim().toLowerCase(); + if (!needle || !options) { + return options ?? []; + } + return options.filter((option) => `${option.value} ${option.label} ${option.detail ?? ''}`.toLowerCase().includes(needle)); + }, [options, filter]); + + if (options === null) { + return ( +

+

+ ); + } + + const known = options.some((option) => option.value === value); + + return ( +
+ {options.length > 12 ? ( + setFilter(event.target.value)} + placeholder="Filter by name or ID" + aria-label="Filter the items" + className={inputClass} + /> + ) : null} +
+ + {source !== 'inbound_sources' ? ( + setReload((current) => current + 1)} + > + + ) : null} +
+ {error ? ( +

+

+ ) : null} +
+ ); +} + +export function GovernanceItemPolicyEditor({ + initial, + entityTypes, + settings, + onClose, +}: { + initial: ItemPolicyDraft; + entityTypes?: readonly GovernanceEntityType[]; + /** Settings with unsaved edits applied, for the inbound source list. */ + settings: Json; + onClose: (saved: boolean) => void; +}) { + const [draft, setDraft] = useState(initial); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + // Set by the first save attempt, so a blank destination is reported at the field + // only once someone has tried to save it. + const [attempted, setAttempted] = useState(false); + const [confirmDiscard, setConfirmDiscard] = useState(false); + // Whether this dialog is still on screen when its save settles. + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [catalog, setCatalog] = useState(catalogCache); + const [catalogState, setCatalogState] = useState<'loading' | 'ready' | 'failed'>(catalogCache ? 'ready' : 'loading'); + const [patternParts, setPatternParts] = useState(() => + initial.item_id + ? parseMcpDestinationItemId(initial.item_id, initial.entity_type) + : { kind: 'preconfiguration', value: '' }, + ); + + const editing = Boolean(initial.original); + const dirty = !sameDraft(draft, initial); + const definition = entityTypeDefinition(draft.entity_type); + const isMcp = isMcpDestinationEntityType(draft.entity_type); + const allowedTypes = GOVERNANCE_ENTITY_TYPES.filter( + (type) => !entityTypes?.length || entityTypes.includes(type.value), + ); + const moved = editing && initial.original + && (initial.original.entity_type !== draft.entity_type || initial.original.item_id !== draft.item_id); + const prefix = 'governance-item-editor'; + + useEffect(() => { + if (!isMcp || catalogCache) { + return; + } + const controller = new AbortController(); + void fetchMcpDestinationCatalog(controller.signal) + .then((loaded) => { + if (!controller.signal.aborted) { + catalogCache = loaded; + setCatalog(loaded); + setCatalogState('ready'); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setCatalogState('failed'); + } + }); + return () => controller.abort(); + }, [isMcp]); + + useEffect(() => { + if (!dirty) { + return; + } + const beforeUnload = (event: BeforeUnloadEvent) => event.preventDefault(); + window.addEventListener('beforeunload', beforeUnload); + return () => window.removeEventListener('beforeunload', beforeUnload); + }, [dirty]); + + const update = (changes: Partial) => { + setError(null); + setDraft((current) => ({ ...current, ...changes })); + }; + + const setEntityType = (entityType: GovernanceEntityType) => { + const nextParts: McpPatternParts = { kind: 'preconfiguration', value: '' }; + setPatternParts(nextParts); + update({ entity_type: entityType, item_id: '', resource_label: '' }); + }; + + const setPattern = (parts: McpPatternParts) => { + setPatternParts(parts); + const itemId = buildMcpDestinationItemId(parts, draft.entity_type); + update({ item_id: itemId, resource_label: itemId }); + }; + + const patternCheck: McpPatternCheck = isMcp + ? checkMcpDestinationPattern( + patternParts, + catalog?.transports, + draft.entity_type === 'mcp_group_destination' && patternParts.groupId !== undefined, + ) + : {}; + const validation = patternCheck.error ?? validateItemPolicyDraft(draft); + + // Escape, the backdrop, and the close button all land here. A save in flight keeps the + // dialog open, as the disabled Cancel does, so its outcome is never lost. + const requestClose = () => { + if (saving) { + return; + } + if (dirty) { + setConfirmDiscard(true); + return; + } + onClose(false); + }; + + const save = async () => { + if (validation) { + setAttempted(true); + // A pattern problem is shown beside the pattern, so it is not repeated here. + setError(patternCheck.error ? null : validation); + return; + } + setSaving(true); + setError(null); + try { + await saveItemPolicy(draft); + toast.success(editing ? 'Policy saved.' : 'Policy created.'); + onClose(true); + } catch (saveError) { + const message = governanceErrorMessage(saveError, 'The policy could not be saved.'); + if (mounted.current) { + setError(message); + } else { + // Another dialog replaced this one while it saved, so there is no field left + // to show the error beside. + toast.error(message); + } + } finally { + setSaving(false); + } + }; + + const footer = confirmDiscard ? ( + <> + Discard your changes to this policy? + setConfirmDiscard(false)}> + Keep editing + + onClose(false)}> + Discard + + + ) : ( + <> + Saves now, separately from the Save bar. + + Cancel + + void save()}> + {saving ? 'Saving…' : editing ? 'Save policy' : 'Create policy'} + + + ); + + return ( + +
+
+
+ + +
+
+ + update({ policy_name: event.target.value })} + placeholder={defaultItemPolicyName(draft.entity_type, draft.item_id, draft.resource_label)} + className={inputClass} + /> +
+
+ +
+

+ {isMcp ? 'Destination' : draft.entity_type === 'inbound_mcp_source' ? 'Source' : 'Item'} +

+ {isMcp ? ( + + ) : definition ? ( + update({ item_id: itemId, resource_label: itemId ? label : '' })} + /> + ) : null} + {moved ? ( +

+

+ ) : null} + {draft.entity_type === 'inbound_mcp_source' ? ( +

+ The source comes from the configured request header. It identifies a client rather + than proving who sent the request, unless a trusted gateway sets it. +

+ ) : null} +
+ +
+

Who may use it

+ update({ allow_all: next })} + /> + {!draft.allow_all ? ( +
+ update({ allowed_users: next })} + emptyText="No people listed." + idPrefix={`${prefix}-allowed-users`} + disabled={saving} + /> + update({ allowed_groups: next })} + emptyText="No groups listed. A group or public workspace here stands for its members." + idPrefix={`${prefix}-allowed-groups`} + disabled={saving} + /> +
+ ) : null} + {allowsNobody(draft) ? ( +

+

+ ) : null} +
+ +
+

Who is blocked

+
+ update({ denied_users: next })} + emptyText="Nobody blocked." + idPrefix={`${prefix}-denied-users`} + disabled={saving} + /> + update({ denied_groups: next })} + emptyText="No groups blocked." + idPrefix={`${prefix}-denied-groups`} + disabled={saving} + /> +
+

+ A block here refuses the person even when this policy, or another policy on the same + item, would let them through. +

+
+ + {error ? ( +

+

+ ) : null} +
+
+ ); +} diff --git a/application/v2_ui/src/components/admin/governance/GovernanceItemPolicyManager.tsx b/application/v2_ui/src/components/admin/governance/GovernanceItemPolicyManager.tsx new file mode 100644 index 000000000..1c56da232 --- /dev/null +++ b/application/v2_ui/src/components/admin/governance/GovernanceItemPolicyManager.tsx @@ -0,0 +1,442 @@ +// GovernanceItemPolicyManager.tsx +// A searchable, paged list of delegated item policies with their row actions. +// +// One list serves every place item policies appear: the Delegated Item Policies section +// shows every type; the MCP destination and Inbound MCP cards show only their own types; an +// AI connection's access dialog shows only the policies on that one connection. The editor +// is shared through the governance store, and every list refetches when a policy is saved +// anywhere on the page. + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { clsx } from 'clsx'; +import { + AlertCircle, + ArrowLeftRight, + ChevronDown, + Copy, + Lock, + Pencil, + Plus, + RotateCw, + Search, + Trash2, +} from 'lucide-react'; +import { + GOVERNANCE_ENTITY_TYPES, + ITEM_POLICY_PAGE_SIZES, + actionTypeLabel, + allowsNobody, + deleteItemPolicy, + draftFromItemPolicy, + duplicateItemPolicy, + entityTypeLabel, + fetchItemPolicyPage, + governanceErrorMessage, + inverseItemPolicy, + summarizeAllowed, + summarizeBlocked, + type GovernanceEntityType, + type GovernanceItemPolicy, + type ItemPolicyDraft, + type ItemPolicyPage, + type PrincipalKind, +} from '../../../lib/governance'; +import { + governancePoliciesChanged, + openGovernanceEditor, + useGovernanceStore, + type GovernanceResourceTarget, +} from '../../../stores/governanceStore'; +import { toast } from '../../../stores/toastStore'; +import { GlassButton, Skeleton } from '../../ui/primitives'; +import { ConfirmAction, RowAction } from '../../workspace/primitives'; +import { usePrincipalLabels } from './usePrincipalLabels'; + +const SEARCH_DEBOUNCE_MS = 300; + +const ACTION_TYPE_ENTITY_TYPES = new Set(['personal_action_type', 'group_action_type', 'global_action_type']); + +function itemLabel(policy: GovernanceItemPolicy): string { + if (ACTION_TYPE_ENTITY_TYPES.has(policy.entity_type)) { + return actionTypeLabel(policy.item_id, policy.resource_label); + } + return policy.resource_label || policy.item_id; +} + +function PrincipalChips({ kind, ids, label }: { kind: PrincipalKind; ids: string[]; label: string }) { + const labels = usePrincipalLabels(kind, ids); + return ( +
+

{label}

+ {ids.length ? ( +
    + {ids.map((id) => { + const lookup = labels.lookup(id); + const entry = lookup?.status === 'found' ? lookup.entry : undefined; + return ( +
  • + {entry?.name || id} + {entry?.kind === 'public_workspace' ? (public) : null} +
  • + ); + })} +
+ ) : ( +

None

+ )} +
+ ); +} + +function PolicyPrincipals({ policy }: { policy: GovernanceItemPolicy }) { + return ( +
+ {policy.allow_all ? ( +

+ Allows everyone who passes the matching feature policy. +

+ ) : ( + <> + + + + )} + + + {policy.policy_id ? ( +

+ Policy ID {policy.policy_id} +

+ ) : null} +
+ ); +} + +export interface GovernanceItemPolicyManagerProps { + /** Types this list covers. Absent means every type. */ + entityTypes?: readonly GovernanceEntityType[]; + /** Only the policies on this one item, matched exactly. */ + itemId?: string; + /** The draft New policy starts from. */ + newPolicy: () => ItemPolicyDraft; + newPolicyLabel?: string; + /** Set when the list sits inside a resource's access dialog. */ + returnTo?: GovernanceResourceTarget; + /** Taught when nothing is listed and no filter is narrowing the list. */ + emptyText: string; + /** Accessible name for the list. */ + label: string; + onTotalChange?: (total: number) => void; + /** Extra controls beside New policy, such as quick-create shortcuts. */ + actions?: React.ReactNode; + /** Leave New policy to the shortcuts in `actions`. */ + hideNewButton?: boolean; +} + +export function GovernanceItemPolicyManager({ + entityTypes, + itemId, + newPolicy, + newPolicyLabel = 'New policy', + returnTo, + emptyText, + label, + onTotalChange, + actions, + hideNewButton = false, +}: GovernanceItemPolicyManagerProps) { + const revision = useGovernanceStore((state) => state.revision); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(25); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [reload, setReload] = useState(0); + + const typeOptions = useMemo( + () => GOVERNANCE_ENTITY_TYPES.filter((type) => !entityTypes?.length || entityTypes.includes(type.value)), + [entityTypes], + ); + const entityTypesKey = (entityTypes ?? []).join(','); + const queryTypes = useMemo( + () => (typeFilter ? [typeFilter] : entityTypesKey ? entityTypesKey.split(',') : []), + [typeFilter, entityTypesKey], + ); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [search]); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + void fetchItemPolicyPage( + { entityTypes: queryTypes, itemId, search: debouncedSearch, page, perPage }, + controller.signal, + ) + .then((result) => { + if (!controller.signal.aborted) { + setData(result); + setError(null); + // The server clamps a page past the end, so follow it rather than + // asking again for a page that no longer exists after a delete. + if (result.pagination.page !== page) { + setPage(result.pagination.page); + } + } + }) + .catch((loadError) => { + if (!controller.signal.aborted) { + setError(governanceErrorMessage(loadError, 'Policies could not be loaded.')); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoading(false); + } + }); + return () => controller.abort(); + }, [queryTypes, itemId, debouncedSearch, page, perPage, revision, reload]); + + const total = data?.pagination.total_items ?? 0; + const filtered = Boolean(debouncedSearch.trim() || typeFilter); + useEffect(() => { + if (data && !filtered) { + onTotalChange?.(data.pagination.total_items); + } + }, [data, filtered, onTotalChange]); + + const openEditor = useCallback((draft: ItemPolicyDraft) => { + openGovernanceEditor({ draft, entityTypes, returnTo }); + }, [entityTypes, returnTo]); + + const remove = async (policy: GovernanceItemPolicy) => { + const key = `${policy.entity_type}:${policy.item_id}:${policy.policy_id}`; + setBusyKey(key); + try { + await deleteItemPolicy(policy); + toast.success(`Deleted ${policy.policy_name}.`); + governancePoliciesChanged(); + } catch (deleteError) { + toast.error(governanceErrorMessage(deleteError, 'The policy could not be deleted.')); + } finally { + setBusyKey(null); + } + }; + + const policies = data?.policies ?? []; + const pagination = data?.pagination; + const firstShown = pagination && total ? (pagination.page - 1) * pagination.per_page + 1 : 0; + const lastShown = pagination ? Math.min(total, pagination.page * pagination.per_page) : 0; + + return ( +
+
+
+
+ {typeOptions.length > 1 ? ( + + ) : null} +
+ {actions} + {hideNewButton ? null : ( + openEditor(newPolicy())}> + + )} +
+
+ + {error ? ( +
+
+ ) : null} + + {loading && !data ? ( + + ) : policies.length === 0 && !error ? ( +

+ {filtered ? 'No policies match this search.' : emptyText} +

+ ) : policies.length ? ( +
    + {policies.map((policy) => { + const key = `${policy.entity_type}:${policy.item_id}:${policy.policy_id}`; + const blocked = summarizeBlocked(policy); + const isExpanded = expanded === key; + const locked = policy.system_managed; + return ( +
  • +
    +
    +

    + {policy.policy_name} + {locked ? ( + + + ) : null} +

    +

    + {entityTypeLabel(policy.entity_type)} + + {itemLabel(policy)} +

    + {locked && policy.managed_reason ? ( +

    {policy.managed_reason}

    + ) : null} +
    +
    +

    + Allows + {summarizeAllowed(policy)} +

    + {blocked ? ( +

    + Blocks + {blocked} +

    + ) : null} +
    +
    + + } + label={`Duplicate ${policy.policy_name}`} + onClick={() => openEditor(duplicateItemPolicy(policy))} + /> + } + label={`Inverse of ${policy.policy_name}: swap allowed and blocked`} + onClick={() => openEditor(inverseItemPolicy(policy))} + /> + } + label={locked ? 'System-managed policies cannot be edited' : `Edit ${policy.policy_name}`} + disabled={locked} + onClick={() => openEditor(draftFromItemPolicy(policy))} + /> + } + label={locked ? 'System-managed policies cannot be deleted' : `Delete ${policy.policy_name}`} + confirmLabel="Confirm delete" + busy={busyKey === key} + disabled={locked} + onConfirm={() => void remove(policy)} + /> +
    +
    + {isExpanded ? : null} +
  • + ); + })} +
+ ) : null} + + {pagination && total > 0 ? ( +
+ + Showing {firstShown}–{lastShown} of {total} + +
+ + setPage((current) => Math.max(1, current - 1))} + > + Previous + + setPage((current) => current + 1)} + > + Next + +
+
+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/admin/governance/GovernanceMcpSections.tsx b/application/v2_ui/src/components/admin/governance/GovernanceMcpSections.tsx new file mode 100644 index 000000000..fbbe4131e --- /dev/null +++ b/application/v2_ui/src/components/admin/governance/GovernanceMcpSections.tsx @@ -0,0 +1,249 @@ +// GovernanceMcpSections.tsx +// The policy halves of the two MCP governance cards. +// +// MCP Action Destination Governance: the switches above decide whether destinations are +// enforced; this lists the destination policies that decide which servers each scope may +// reach, and for whom. It says plainly when policies are saved but not enforced, and when +// enforcement is on with nothing allowed, which is the state that silently breaks every +// remote MCP action. +// +// Inbound MCP Source Governance: inbound MCP is deny-by-default, so a request that passes +// every check in the Inbound MCP settings still gets no tools until a source policy allows +// the person. New policies start restricted to named people, as the classic page did. + +import { useState } from 'react'; +import { clsx } from 'clsx'; +import { AlertTriangle, ChevronRight, Globe, Info, UserRound, UsersRound } from 'lucide-react'; +import { + INBOUND_MCP_SOURCE_ENTITY_TYPE, + MCP_DESTINATION_ENTITY_TYPES, + newItemPolicyDraft, + readDraftAware, + type GovernanceEntityType, +} from '../../../lib/governance'; +import { asBoolean } from '../../../lib/adminFields'; +import type { Json } from '../../../lib/types'; +import { openGovernanceEditor } from '../../../stores/governanceStore'; +import { GlassButton } from '../../ui/primitives'; +import { GovernanceItemPolicyManager } from './GovernanceItemPolicyManager'; + +function Notice({ tone, children }: { tone: 'info' | 'warn'; children: React.ReactNode }) { + const Icon = tone === 'warn' ? AlertTriangle : Info; + return ( +

+

+ ); +} + +const PATTERNS: { pattern: string; meaning: string }[] = [ + { pattern: 'preconfiguration:github', meaning: 'One template from the MCP catalog. Enterprise templates only appear in the action editor when a policy names them this way.' }, + { pattern: '*.contoso.com', meaning: 'Every endpoint on a matching host.' }, + { pattern: 'https://mcp.contoso.com/mcp*', meaning: 'One endpoint, or every path under it when the URL ends in *.' }, + { pattern: 'preset:generic', meaning: 'Every server built from one preset.' }, + { pattern: 'transport:streamable_http', meaning: 'Every server on one transport. Use the underscore: streamable-http never matches.' }, + { pattern: '*', meaning: 'Any remote server in the scope, after identity and authentication checks.' }, + { pattern: 'group:::', meaning: 'Group scope only: the pattern applies to one group instead of every group.' }, +]; + +function PatternReference() { + const [open, setOpen] = useState(false); + return ( +
+ + {open ? ( +
+ {PATTERNS.map((entry) => ( +
+
+ {entry.pattern} +
+
{entry.meaning}
+
+ ))} +
+ ) : null} +
+ ); +} + +const SCOPE_SHORTCUTS: { entityType: GovernanceEntityType; label: string; Icon: typeof UserRound }[] = [ + { entityType: 'mcp_personal_destination', label: 'Personal', Icon: UserRound }, + { entityType: 'mcp_group_destination', label: 'Group', Icon: UsersRound }, + { entityType: 'mcp_global_destination', label: 'Global', Icon: Globe }, +]; + +export function GovernanceMcpDestinationPolicies({ + help, + settings, + draft, +}: { + help?: string; + settings: Json; + draft: Json; +}) { + const [total, setTotal] = useState(null); + const enforced = asBoolean(readDraftAware(settings, draft, 'enable_mcp_destination_governance')); + + const create = (entityType: GovernanceEntityType) => + openGovernanceEditor({ + draft: newItemPolicyDraft({ entity_type: entityType }), + entityTypes: MCP_DESTINATION_ENTITY_TYPES, + }); + + return ( +
+ {help ?

{help}

: null} + + {!enforced ? ( + + These policies are saved but not enforced while the allowlist is off. Prepare them for the + servers people already use, then turn on Enforce MCP Destination Allowlist. + + ) : total === 0 ? ( + + The allowlist is on and no destination policy exists, so no remote MCP server is reachable + from any scope unless the deployment environment allows it. + + ) : null} + + + + newItemPolicyDraft({ entity_type: 'mcp_personal_destination' })} + hideNewButton + actions={( +
+ New policy for + {SCOPE_SHORTCUTS.map(({ entityType, label, Icon }) => ( + create(entityType)} + > + + ))} +
+ )} + emptyText="No destination policies yet. Start with a specific preconfigured server or host for each scope people build MCP actions in, rather than *." + label="MCP destination policies" + onTotalChange={setTotal} + /> +
+ ); +} + +export function GovernanceInboundMcpPolicies({ + help, + settings, + draft, + mcpUiEnabled, +}: { + help?: string; + settings: Json; + draft: Json; + mcpUiEnabled: boolean; +}) { + const [total, setTotal] = useState(null); + const serverOn = asBoolean(readDraftAware(settings, draft, 'enable_inbound_mcp_server')); + const allowAllSourcesValue = readDraftAware(settings, draft, 'inbound_mcp_allow_all_source_ids'); + const allowAllSources = allowAllSourcesValue === undefined ? true : asBoolean(allowAllSourcesValue); + + const create = (anySource: boolean) => + openGovernanceEditor({ + draft: newItemPolicyDraft({ + entity_type: INBOUND_MCP_SOURCE_ENTITY_TYPE, + item_id: anySource ? '*' : '', + resource_label: anySource ? 'All accepted source IDs (*)' : '', + policy_name: anySource ? 'Inbound MCP all source access' : 'Inbound MCP source access', + // Restricted by default, as on the classic page: allowing everyone with the + // Entra role and delegated scope is a deliberate choice, not a default. + allow_all: false, + }), + entityTypes: [INBOUND_MCP_SOURCE_ENTITY_TYPE], + }); + + return ( +
+ {help ?

{help}

: null} + +
+
+
Inbound MCP
+
{mcpUiEnabled ? 'Available' : 'Not enabled for this deployment'}
+
+
+
Server
+
{serverOn ? 'On' : 'Off'}
+
+
+
Accepted sources
+
{allowAllSources ? 'Any source ID' : 'Listed source IDs only'}
+
+
+ + {!mcpUiEnabled ? ( + + The App Service setting that enables inbound MCP is off, so these policies have no effect yet. + They apply as soon as it is turned on. + + ) : serverOn && total === 0 ? ( + + The inbound MCP server is on, but no source policy allows anyone, so every request is refused. + + ) : null} + +

+

+ + newItemPolicyDraft({ entity_type: INBOUND_MCP_SOURCE_ENTITY_TYPE, allow_all: false })} + hideNewButton + actions={( +
+ create(true)}> + Allow from any source + + {/* With every source ID accepted, * is the only source a policy can name. */} + {!allowAllSources ? ( + create(false)}> + Allow from one source + + ) : null} +
+ )} + emptyText="No source policy yet, so inbound MCP returns no tools to anyone. Start with a policy for * that names the people or groups who should connect." + label="Inbound MCP source policies" + onTotalChange={setTotal} + /> +
+ ); +} diff --git a/application/v2_ui/src/components/admin/governance/GovernanceOverview.tsx b/application/v2_ui/src/components/admin/governance/GovernanceOverview.tsx new file mode 100644 index 000000000..41caf9191 --- /dev/null +++ b/application/v2_ui/src/components/admin/governance/GovernanceOverview.tsx @@ -0,0 +1,79 @@ +// GovernanceOverview.tsx +// How governance decides, stated once at the top of the Governance group. +// +// The classic page explained this in a six-tab modal, and one of its statements was wrong: +// it said an empty allow list was permissive, when the server refuses everyone. This is the +// short, correct version. The two-layer summary stays visible; how a request is checked +// opens on demand, so the switches below remain the first thing on the card. + +import { useState } from 'react'; +import { clsx } from 'clsx'; +import { ChevronRight, ExternalLink } from 'lucide-react'; + +const GOVERNANCE_GUIDE_URL = 'https://microsoft.github.io/simplechat/admin/governance/'; + +export function GovernanceOverview() { + const [open, setOpen] = useState(false); + + return ( +
+

+ Governance decides who may use what is configured inside SimpleChat; Entra app roles still decide + who may sign in. A feature policy decides who may + use a kind of capability, such as personal agents. A{' '} + delegated item policy decides who may use one + specific resource. Block lists always win. +

+ +
+ + {open ? ( +
+
    +
  1. A governance switch that is off checks nothing.
  2. +
  3. A block list that names the person, or one of their groups, refuses them.
  4. +
  5. + The feature policy must allow them: everyone, or a list that names them or one of their + groups. With Allow everyone off and nobody listed, nobody passes. +
  6. +
  7. + For a specific resource, passing any one of its item policies is enough, and with none + the feature policy decides alone. An item policy cannot open a feature the feature + policy refuses, with one exception: an action type with its own policies requires one of + them, and can be opened to people the feature policy leaves out. +
  8. +
+

+ MCP destinations and inbound MCP sources work the other way round: with no policy, nothing + is allowed. +

+

+ A group or public workspace in a policy stands for its members, so it works as a reusable + cohort, such as the people piloting personal agents. It grants nothing inside that workspace. +

+
+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/admin/governance/InboundMcpGovernanceShortcut.tsx b/application/v2_ui/src/components/admin/governance/InboundMcpGovernanceShortcut.tsx new file mode 100644 index 000000000..0ba984ed5 --- /dev/null +++ b/application/v2_ui/src/components/admin/governance/InboundMcpGovernanceShortcut.tsx @@ -0,0 +1,103 @@ +// InboundMcpGovernanceShortcut.tsx +// The governance step of Inbound MCP, shown in the Inbound MCP settings themselves. +// +// A request that passes every check on that card still gets no tools until an inbound MCP +// source policy allows the person, and that policy lives two categories away under +// Governance. This says how many source policies exist and offers to create one, matching +// the source mode the card is set to. + +import { useEffect, useState } from 'react'; +import { AlertTriangle, ArrowRight } from 'lucide-react'; +import { asBoolean } from '../../../lib/adminFields'; +import { + INBOUND_MCP_SOURCE_ENTITY_TYPE, + fetchItemPolicyPage, + newItemPolicyDraft, + readDraftAware, +} from '../../../lib/governance'; +import type { Json } from '../../../lib/types'; +import { openGovernanceEditor, useGovernanceStore } from '../../../stores/governanceStore'; +import { GlassButton } from '../../ui/primitives'; + +const INBOUND_GOVERNANCE_SECTION_ID = 'governance-inbound-mcp-section'; + +export function InboundMcpGovernanceShortcut({ + label, + help, + settings, + draft, + onNavigate, +}: { + label: string; + help?: string; + settings: Json; + draft: Json; + onNavigate: (sectionId: string) => void; +}) { + const revision = useGovernanceStore((state) => state.revision); + const [count, setCount] = useState(null); + const [failed, setFailed] = useState(false); + const allowAllValue = readDraftAware(settings, draft, 'inbound_mcp_allow_all_source_ids'); + const allowAllSources = allowAllValue === undefined ? true : asBoolean(allowAllValue); + + useEffect(() => { + const controller = new AbortController(); + void fetchItemPolicyPage({ entityTypes: [INBOUND_MCP_SOURCE_ENTITY_TYPE], perPage: 100 }, controller.signal) + .then((page) => { + if (!controller.signal.aborted) { + // Runtime evaluation ignores system-managed source policies, so they are + // not counted as letting anyone in. + const listed = page.policies.filter((policy) => !policy.system_managed).length; + setCount(listed + Math.max(0, page.pagination.total_items - page.policies.length)); + setFailed(false); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setFailed(true); + } + }); + return () => controller.abort(); + }, [revision]); + + const create = () => openGovernanceEditor({ + draft: newItemPolicyDraft({ + entity_type: INBOUND_MCP_SOURCE_ENTITY_TYPE, + item_id: allowAllSources ? '*' : '', + resource_label: allowAllSources ? 'All accepted source IDs (*)' : '', + policy_name: allowAllSources ? 'Inbound MCP all source access' : 'Inbound MCP source access', + allow_all: false, + }), + entityTypes: [INBOUND_MCP_SOURCE_ENTITY_TYPE], + }); + + const summary = failed + ? 'Source policies could not be checked.' + : count === null + ? 'Checking source policies…' + : count === 0 + ? 'No source policy yet, so inbound MCP returns no tools to anyone.' + : `${count} source ${count === 1 ? 'policy decides' : 'policies decide'} who receives tools.`; + + return ( +
+
+

{label}

+

+ {count === 0 ?

+ {help ?

{help}

: null} +
+
+ + {allowAllSources ? 'Create a policy for any source' : 'Create a source policy'} + + onNavigate(INBOUND_GOVERNANCE_SECTION_ID)}> + Review in Governance + +
+
+ ); +} diff --git a/application/v2_ui/src/components/admin/governance/McpDestinationPatternField.tsx b/application/v2_ui/src/components/admin/governance/McpDestinationPatternField.tsx new file mode 100644 index 000000000..2404a3a2c --- /dev/null +++ b/application/v2_ui/src/components/admin/governance/McpDestinationPatternField.tsx @@ -0,0 +1,371 @@ +// McpDestinationPatternField.tsx +// Builds the destination pattern an MCP destination policy stores as its item. +// +// The classic editor took the pattern as free text with examples beside it, so a typo saved +// without complaint and then quietly allowed nothing: `transport:streamable-http`, which the +// classic examples suggested, never matches, because transports are stored with an +// underscore. Here the kind of pattern is chosen first, ids and transports are picked from +// what the server actually loads, and the stored value is shown before it is saved. + +import { useEffect, useMemo, useState } from 'react'; +import { clsx } from 'clsx'; +import { AlertCircle, AlertTriangle, Info, Loader2, Search } from 'lucide-react'; +import { + buildMcpDestinationItemId, + checkMcpDestinationPattern, + mcpScopeOf, + searchPrincipals, + type GovernanceEntityType, + type McpDestinationCatalog, + type McpPatternKind, + type McpPatternParts, + type PrincipalEntry, +} from '../../../lib/governance'; +import { usePrincipalLabels } from './usePrincipalLabels'; + +const KIND_OPTIONS: { value: McpPatternKind; label: string; hint: string }[] = [ + { + value: 'preconfiguration', + label: 'A preconfigured server', + hint: 'One template from the MCP catalog. Enterprise templates only appear when a policy names them this way.', + }, + { value: 'host', label: 'A host name', hint: 'Every endpoint on a host. * matches any run of characters.' }, + { value: 'url', label: 'A URL prefix', hint: 'One endpoint, or every path under it when the URL ends in *.' }, + { value: 'preset', label: 'A server preset', hint: 'Every server built from one preset, such as the generic preset.' }, + { value: 'transport', label: 'A transport', hint: 'Every server reached over one transport.' }, + { value: 'any', label: 'Any remote server (*)', hint: 'Everything in this scope. Use it sparingly.' }, +]; + +const inputClass = + 'w-full rounded-lg border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 placeholder:text-text-3 focus:border-accent focus:outline-none disabled:opacity-60'; + +function GroupTargetPicker({ + groupId, + onChange, + disabled, +}: { + groupId: string; + onChange: (next: string) => void; + disabled?: boolean; +}) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [error, setError] = useState(null); + const [searching, setSearching] = useState(false); + const labels = usePrincipalLabels('groups', groupId ? [groupId] : []); + const lookup = groupId ? labels.lookup(groupId) : undefined; + + useEffect(() => { + const controller = new AbortController(); + const timer = window.setTimeout(() => { + setSearching(true); + void searchPrincipals('groups', query, controller.signal) + .then((page) => { + if (!controller.signal.aborted) { + // Group destinations govern group workspace actions, so a public + // workspace is not a meaningful target here. + setResults(page.entries.filter((entry) => entry.kind !== 'public_workspace')); + setError(null); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setResults([]); + setError('Groups could not be searched.'); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setSearching(false); + } + }); + }, 300); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [query]); + + return ( +
+

+ {groupId ? ( + <> + Applies to{' '} + + {lookup?.status === 'found' ? lookup.entry.name || groupId : groupId} + + {lookup?.status === 'missing' ? (not found) : null} + + ) : ( + 'Choose the group this policy applies to.' + )} +

+
+
+ {error ?

{error}

: null} + {searching && results === null ? ( +

+

+ ) : null} + {results && results.length ? ( +
    + {results.map((entry) => ( +
  • + +
  • + ))} +
+ ) : null} + {results && !results.length && !error ? ( +

{query.trim() ? `No groups match “${query.trim()}”.` : 'No groups exist yet.'}

+ ) : null} +
+ ); +} + +export function McpDestinationPatternField({ + entityType, + parts, + onChange, + catalog, + catalogState, + disabled, + showIncomplete = false, + idPrefix, +}: { + entityType: GovernanceEntityType; + parts: McpPatternParts; + onChange: (next: McpPatternParts) => void; + catalog: McpDestinationCatalog | null; + catalogState: 'loading' | 'ready' | 'failed'; + disabled?: boolean; + /** Report a blank value or unpicked group too, once a save has been attempted. */ + showIncomplete?: boolean; + idPrefix: string; +}) { + const isGroupScope = entityType === 'mcp_group_destination'; + // `groupId` undefined means every group; an empty string means "one group, not picked yet", + // which the check reports so the policy cannot be saved for every group by accident. + const groupSpecific = isGroupScope && parts.groupId !== undefined; + const scope = mcpScopeOf(entityType); + const transports = catalog?.transports ?? []; + const check = checkMcpDestinationPattern(parts, transports.length ? transports : undefined, groupSpecific); + const stored = buildMcpDestinationItemId(parts, entityType); + const kindOption = KIND_OPTIONS.find((option) => option.value === parts.kind) ?? KIND_OPTIONS[0]; + // A blank value or an unpicked group is the starting state, not a mistake: the inputs already + // ask for them, and they are reported here once a save has been attempted. Something entered + // that cannot match is flagged as it is typed. + const incomplete = (parts.kind !== 'any' && !parts.value.trim()) || (groupSpecific && !parts.groupId?.trim()); + const fieldError = incomplete && !showIncomplete ? undefined : check.error; + + const preconfigurations = useMemo( + () => (catalog?.preconfigurations ?? []).filter( + (entry) => !scope || !entry.scopes.length || entry.scopes.includes(scope), + ), + [catalog, scope], + ); + const selectedPreconfiguration = catalog?.preconfigurations.find((entry) => entry.id === parts.value); + + const setKind = (kind: McpPatternKind) => onChange({ ...parts, kind, value: '' }); + const setValue = (value: string) => onChange({ ...parts, value }); + + const catalogSelect = ( + options: { id: string; label: string }[], + placeholder: string, + ariaLabel: string, + ) => { + if (catalogState === 'loading') { + return ( +

+

+ ); + } + if (catalogState === 'failed' || !options.length) { + // The id can still be typed when the catalog is unavailable. + return ( + setValue(event.target.value)} + placeholder={placeholder} + aria-label={ariaLabel} + className={clsx(inputClass, 'font-mono')} + /> + ); + } + const known = options.some((option) => option.id === parts.value); + return ( + + ); + }; + + return ( +
+
+ + +

{kindOption.hint}

+
+ + {parts.kind === 'preconfiguration' ? ( +
+ {catalogSelect( + preconfigurations.map((entry) => ({ id: entry.id, label: entry.label })), + 'microsoft_learn', + 'Preconfigured server', + )} + {selectedPreconfiguration?.requires_endpoint_review ? ( +

+

+ ) : null} +
+ ) : null} + + {parts.kind === 'preset' ? catalogSelect(catalog?.presets ?? [], 'generic', 'Server preset') : null} + + {parts.kind === 'transport' ? ( + + ) : null} + + {parts.kind === 'host' ? ( + setValue(event.target.value)} + placeholder="*.contoso.com" + aria-label="Host pattern" + className={clsx(inputClass, 'font-mono')} + /> + ) : null} + + {parts.kind === 'url' ? ( + setValue(event.target.value)} + placeholder="https://mcp.contoso.com/mcp*" + aria-label="URL pattern" + className={clsx(inputClass, 'font-mono')} + /> + ) : null} + + {isGroupScope ? ( +
+ + {groupSpecific ? ( +
+ onChange({ ...parts, groupId })} + /> +
+ ) : null} +
+ ) : null} + + {stored && !incomplete ? ( +
+ Stored as + + {stored} + +
+ ) : null} + + {fieldError ? ( +

+

+ ) : check.warning && !incomplete ? ( +

+

+ ) : null} +
+ ); +} diff --git a/application/v2_ui/src/components/admin/governance/PrincipalListEditor.tsx b/application/v2_ui/src/components/admin/governance/PrincipalListEditor.tsx new file mode 100644 index 000000000..94b78045a --- /dev/null +++ b/application/v2_ui/src/components/admin/governance/PrincipalListEditor.tsx @@ -0,0 +1,365 @@ +// PrincipalListEditor.tsx +// Edits one list of governance principals: the people or the groups a policy allows or blocks. +// +// The classic page kept these lists in hidden comma-separated inputs behind a modal with its +// own paging, and named an entry only after an extra lookup per id. Here the list is visible +// where it is decided: each entry is a chip with its resolved name, search is inline, and +// pasting ids (the classic CSV import) merges into or replaces the list in place. +// +// Groups cover both group workspaces and public workspaces, because a person's governance +// groups are resolved from both memberships. The list is a cohort for governance only; being +// in one grants nothing inside that workspace. + +import { useEffect, useMemo, useState } from 'react'; +import { clsx } from 'clsx'; +import { AlertCircle, ClipboardPaste, Loader2, Search, X } from 'lucide-react'; +import { + applyPrincipalImport, + searchPrincipals, + type PrincipalEntry, + type PrincipalKind, +} from '../../../lib/governance'; +import { GlassButton } from '../../ui/primitives'; +import { usePrincipalLabels } from './usePrincipalLabels'; + +const SEARCH_DEBOUNCE_MS = 300; +/** Chips shown before the list collapses behind "Show all". */ +const COLLAPSED_CHIP_LIMIT = 24; + +const NOUNS: Record = { + users: { one: 'person', many: 'people', search: 'Search people by name or email' }, + groups: { one: 'group', many: 'groups', search: 'Search group or public workspaces by name or ID' }, +}; + +function countText(kind: PrincipalKind, count: number): string { + const nouns = NOUNS[kind]; + return `${count} ${count === 1 ? nouns.one : nouns.many}`; +} + +export interface PrincipalListEditorProps { + kind: PrincipalKind; + /** Heading for the list, such as "Allowed people". */ + title: string; + ids: string[]; + onChange: (next: string[]) => void; + tone: 'allow' | 'block'; + emptyText: string; + /** Unique prefix for element ids, since several lists share one dialog. */ + idPrefix: string; + disabled?: boolean; +} + +export function PrincipalListEditor({ + kind, + title, + ids, + onChange, + tone, + emptyText, + idPrefix, + disabled = false, +}: PrincipalListEditorProps) { + const nouns = NOUNS[kind]; + const [showAll, setShowAll] = useState(false); + const [filter, setFilter] = useState(''); + const [panel, setPanel] = useState<'search' | 'paste' | null>(null); + + const [query, setQuery] = useState(''); + const [results, setResults] = useState(null); + const [truncated, setTruncated] = useState(false); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + const [pasteText, setPasteText] = useState(''); + + const filtered = useMemo(() => { + const needle = filter.trim().toLowerCase(); + return needle ? ids.filter((id) => id.toLowerCase().includes(needle)) : ids; + }, [ids, filter]); + const visible = showAll || filter ? filtered : filtered.slice(0, COLLAPSED_CHIP_LIMIT); + const labels = usePrincipalLabels(kind, visible); + + // A filter that also matches names needs the names, so it reads them from the cache + // once they are known rather than only matching the raw id. + const matchesFilter = (id: string) => { + const needle = filter.trim().toLowerCase(); + if (!needle) { + return true; + } + const lookup = labels.lookup(id); + const name = lookup?.status === 'found' ? `${lookup.entry.name} ${lookup.entry.detail ?? ''}` : ''; + return `${id} ${name}`.toLowerCase().includes(needle); + }; + const shown = filter ? ids.filter(matchesFilter) : visible; + + useEffect(() => { + if (panel !== 'search') { + return; + } + // People search needs a term; groups can be browsed before typing. + if (kind === 'users' && !query.trim()) { + setResults(null); + setSearchError(null); + return; + } + const controller = new AbortController(); + const timer = window.setTimeout(() => { + setSearching(true); + void searchPrincipals(kind, query, controller.signal) + .then((page) => { + if (!controller.signal.aborted) { + setResults(page.entries); + setTruncated(page.truncated); + setSearchError(null); + } + }) + .catch((error: unknown) => { + if (!controller.signal.aborted) { + setResults([]); + setTruncated(false); + setSearchError( + error instanceof Error && error.message + ? error.message + : `${nouns.many[0].toUpperCase()}${nouns.many.slice(1)} could not be searched.`, + ); + } + }) + .finally(() => { + if (!controller.signal.aborted) { + setSearching(false); + } + }); + }, SEARCH_DEBOUNCE_MS); + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [panel, query, kind, nouns.many]); + + const add = (id: string) => { + if (!ids.includes(id)) { + onChange([...ids, id]); + } + }; + const remove = (id: string) => onChange(ids.filter((entry) => entry !== id)); + + const applyPaste = (mode: 'merge' | 'replace') => { + onChange(applyPrincipalImport(ids, pasteText, mode)); + setPasteText(''); + setPanel(null); + }; + + const chipTone = tone === 'block' + ? 'border-danger/30 bg-danger-soft text-text-1' + : 'border-edge bg-surface-2 text-text-1'; + + return ( +
+
+ {title} + {countText(kind, ids.length)} +
+ + {ids.length > COLLAPSED_CHIP_LIMIT ? ( +
+
+ ) : null} + + {ids.length === 0 ? ( +

{emptyText}

+ ) : ( +
    + {shown.map((id) => { + const lookup = labels.lookup(id); + const entry = lookup?.status === 'found' ? lookup.entry : undefined; + const missing = lookup?.status === 'missing'; + const name = entry?.name || id; + return ( +
  • + + {missing ? +
  • + ); + })} +
+ )} + + {!filter && !showAll && ids.length > COLLAPSED_CHIP_LIMIT ? ( + + ) : null} + {labels.failed ? ( +

+ Some names could not be loaded. Entries are shown by ID and still save correctly. +

+ ) : null} + {labels.resolving ? ( +

+

+ ) : null} + + {panel === 'search' ? ( +
+
+
+ {searchError ? ( +

+

+ ) : null} + {searching && results === null ? ( +

+

+ ) : null} + {kind === 'users' && !query.trim() ? ( +

Type a name or email address to search the directory.

+ ) : null} + {results !== null && results.length === 0 && !searchError && query.trim() ? ( +

No {nouns.many} match “{query.trim()}”.

+ ) : null} + {results !== null && results.length > 0 ? ( +
    + {results.map((entry) => { + const added = ids.includes(entry.id); + return ( +
  • +
    +

    + {entry.name || entry.id} + {entry.kind === 'public_workspace' ? ( + Public workspace + ) : null} +

    +

    {entry.detail || entry.id}

    +
    + (added ? remove(entry.id) : add(entry.id))} + > + {added ? 'Remove' : 'Add'} + +
  • + ); + })} +
+ ) : null} + {truncated ? ( +

More matched than are shown. Narrow the search to reach the rest.

+ ) : null} +
+ ) : null} + + {panel === 'paste' ? ( +
+ +