Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
327 changes: 327 additions & 0 deletions application/single_app/admin_settings_fields.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 76 additions & 7 deletions application/single_app/functions_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions application/single_app/functions_mcp_destinations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
38 changes: 38 additions & 0 deletions application/single_app/functions_mcp_preconfigurations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions application/single_app/functions_public_workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading