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
48 changes: 48 additions & 0 deletions application/single_app/admin_settings_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -4596,6 +4596,25 @@
"depends_on": {"key": "enable_semantic_kernel", "equals": True},
},
],
# The organisation's agents themselves, rather than settings about them. The

Check warning on line 4599 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
# component reads and writes through the global editor routes
# (/api/v2/admin/agents) and the classic enable and default-agent routes, never

Check warning on line 4601 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
# the settings draft, so each change saves on its own.
"organization-agents-section": [

Check warning on line 4603 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
{
"type": "component",
"component": "global-agents-manager",

Check warning on line 4606 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"label": "Global Agents",

Check warning on line 4607 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"help": (
"Agents the organisation provides. Everyone uses the default agent while "

Check warning on line 4609 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"Workspace Mode is off; with it on, these appear beside people's own agents "

Check warning on line 4610 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"only when global agents are added to workspaces. Create one from scratch "

Check warning on line 4611 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"or from an approved template, and disable one to keep it without offering "

Check warning on line 4612 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
"it."
),
"depends_on": {"key": "enable_semantic_kernel", "equals": True},

Check warning on line 4615 in application/single_app/admin_settings_fields.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
},
],
# Rendered by V1 only while Workspace Mode is on, which ``ADMIN_NAV`` now
# states as a section condition, so V2 hides the section on the same terms.
"agent-toggles-card": [
Expand Down Expand Up @@ -4824,6 +4843,17 @@
"default": True,
"depends_on": {"key": "agent_templates_allow_user_submission", "equals": True},
},
{
# Reviewing submissions happens in the shared approvals queue, which
# the classic Approvals page serves; this only points there.
"type": "component",
"component": "agent-template-approvals-link",
"label": "Approvals queue",
"help": (
"Review, approve and remove submitted templates in the approvals queue, "
"beside the other requests an administrator handles."
),
},
],
# --- Actions ----------------------------------------------------------
#
Expand Down Expand Up @@ -5177,6 +5207,24 @@
"group": "Managed elsewhere",
},
],
# The organisation's actions themselves. The built-in toggles that used to sit
# here live in core-plugin-toggles, so this section holds no setting at all:
# the component reads and writes through the global editor routes
# (/api/v2/admin/actions) and the classic enable route, never the settings draft.
"actions-config": [
{
"type": "component",
"component": "global-actions-manager",
"label": "Global Actions",
"help": (
"Connections to APIs, databases, MCP servers and other services that "
"global agents can call. Workspaces see them too when global agents and "
"actions are added to workspaces. Disable one to keep its configuration "
"without loading it."
),
"depends_on": {"key": "enable_semantic_kernel", "equals": True},
},
],
# --- Inbound MCP ------------------------------------------------------
#
# The whole card is gated by ENABLE_MCP_UI, an App Service application
Expand Down
4 changes: 4 additions & 0 deletions application/single_app/admin_settings_nav.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@
"icon": "bi-robot",
"sections": [
{"id": "agents-config", "label": "Agent Runtime", "icon": "bi-robot"},
# The organisation's own agents: created, edited and chosen as the
# default here. V1 draws this as the Global Agents table inside the
# Agent Runtime card; V2 gives it a section of its own.
{"id": "organization-agents-section", "label": "Global Agents", "icon": "bi-stars"},
{"id": "agent-toggles-card", "label": "Workspace Agent Permissions", "icon": "bi-person-gear", "condition": "per_user_semantic_kernel"},
{"id": "agents-page-customization-card", "label": "Agents Page", "icon": "bi-palette"},
{"id": "agent-template-approvals-section", "label": "Agent Template Approvals", "icon": "bi-layers", "condition": "enable_agent_template_gallery"},
Expand Down
2 changes: 2 additions & 0 deletions application/single_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
from route_backend_group_prompts_scoped import *
from route_backend_group_actions_scoped import *
from route_backend_group_agents_scoped import *
from route_backend_v2_admin_agents_actions import register_route_backend_v2_admin_agents_actions
from route_backend_group_identities_scoped import *
from route_backend_group_endpoints_scoped import *
from route_backend_group_file_sources_scoped import *
Expand Down Expand Up @@ -1411,6 +1412,7 @@ def list_semantic_kernel_plugins():
# ------------------- API V2 UI Routes -------------------
register_route_blueprint('backend_v2', register_route_backend_v2, user_required_blueprint)
register_route_blueprint('backend_v2_admin', register_route_backend_v2_admin, admin_required_blueprint)
register_route_blueprint('backend_v2_admin_agents_actions', register_route_backend_v2_admin_agents_actions, admin_required_blueprint)

# ------------------- API Orchestration Routes ----------
register_route_blueprint('backend_orchestration', register_route_backend_orchestration, user_required_blueprint)
Expand Down
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.270"
VERSION = "0.261.271"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
7 changes: 6 additions & 1 deletion application/single_app/functions_global_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,12 @@ def delete_global_agent(agent_id):
try:
user_id = get_current_user_id()
print(f"Deleting global agent: {agent_id}")
agent_dict = get_global_agent(agent_id)
# Read the stored references, not the UI placeholders: a masked read rebuilds the
# classic secret name, which misses the fresh names the V2 editor saves under.
agent_dict = cosmos_global_agents_container.read_item(
item=agent_id,
partition_key=agent_id
)
keyvault_agent_delete_helper(agent_dict, agent_id, scope="global")
cosmos_global_agents_container.delete_item(
item=agent_id,
Expand Down
228 changes: 228 additions & 0 deletions application/single_app/functions_global_editor_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# functions_global_editor_access.py
"""Orchestration for the V2 Admin Settings global agent and action editors.

The classic ``/api/admin/agents`` and ``/api/admin/plugins`` routes save whole
documents addressed by name, with no revision. These routes serve the V2 agent and
action editors instead, so global records get the contract personal and group
records already have: reads answer with ``{record, revision, secret_paths,
read_only}``, writes accept ``{updates, expected_revision, clear_secret_paths,
removed_paths}``, and a write made against a stale revision is refused with nothing
written. Persistence, secret handling and the conditional writes are the shared
engine in ``functions_workspace_authoring``; the routes enforce the Admin role.

Enabling, disabling and choosing the default agent stay on the classic routes, which
already update those fields without rewriting stored secret references.
"""

import json
import logging
import re
from importlib import import_module

from flask import jsonify, request
from werkzeug.exceptions import HTTPException

from functions_appinsights import log_event
from functions_settings import get_settings, update_settings
from functions_workspace_authoring import (
apply_global_action_write,
apply_global_agent_write,
build_global_agent_editor_options,
build_secret_reminder_defaults,
delete_global_editor_record,
editor_error_response,
editor_resource,
list_global_editor_records,
log_committed_global_editor_change,
project_editor_record,
read_global_editor_record,
)


INVALID_GLOBAL_RESOURCE_ID = re.compile(r"[/\\?#,\x00-\x1f\x7f]")


class GlobalEditorError(HTTPException):
"""A stable, non-sensitive failure at the global editor boundary."""

def __init__(self, message, status_code):
super().__init__(description=message)
self.code = status_code


def _validate_identifier(value, label):
if (
not isinstance(value, str) or not value or len(value) > 512
or value != value.strip() or value in (".", "..")
or INVALID_GLOBAL_RESOURCE_ID.search(value)
):
raise GlobalEditorError(f"Invalid {label}.", 400)


def global_editor_error_response(exc):
"""Map boundary errors and shared editor-engine errors to HTTP responses."""
if isinstance(exc, GlobalEditorError):
response = jsonify({"error": exc.description})
response.headers["Cache-Control"] = "no-store"
return response, exc.code
return editor_error_response(exc)


def reject_query_parameters():
"""These routes take no query parameters; a stray one is refused, not ignored."""
if request.args:
raise GlobalEditorError("This request does not accept query parameters.", 400)


def reject_request_body():
if request.get_data():
raise GlobalEditorError("This read does not accept a request body.", 400)


def read_json_body():
"""Parse a JSON object body, rejecting duplicate keys so a smuggled second value
cannot shadow a validated one."""
def unique_fields(pairs):
payload = {}
for key, value in pairs:
if key in payload:
raise GlobalEditorError("Duplicate fields are not supported.", 400)
payload[key] = value
return payload

if not request.is_json:
raise GlobalEditorError("A JSON object is required for this action.", 400)
try:
body = json.loads(request.get_data(), object_pairs_hook=unique_fields)
except (ValueError, UnicodeDecodeError) as error:
raise GlobalEditorError("Provide valid JSON with no duplicate fields.", 400) from error
if not isinstance(body, dict):
raise GlobalEditorError("A JSON object is required for this action.", 400)
return body


def _resource(record, kind):
# Global records are read-only everywhere else; here they are the administrator's own.
return editor_resource(record, kind, global_scope=True, read_only=False)


def selected_agent_name(settings):
"""The default global agent, which settings store by name."""
selected = settings.get("global_selected_agent")
if isinstance(selected, dict):
return str(selected.get("name") or "")
return selected if isinstance(selected, str) else ""


def _follow_default_agent_rename(existing, saved, settings):
"""Keep the default agent selected when it is renamed.

The selection is stored by name, so a rename would otherwise leave it naming an
agent that no longer exists. The agent is already saved when this runs; a failure
here is logged rather than reported as a failed save.
"""
old_name = existing.get("name")
new_name = saved.get("name")
selected = settings.get("global_selected_agent")
if old_name == new_name or not isinstance(selected, dict) or selected.get("name") != old_name:
return
try:
if not update_settings({"global_selected_agent": {**selected, "name": new_name}}):
raise RuntimeError("The settings update was not saved.")
except Exception as exc:
log_event(
"[GLOBAL_AGENTS] The renamed default agent could not be reselected.",
level=logging.WARNING,
extra={"agent_id": saved.get("id", ""), "error_type": type(exc).__name__},
)


def list_global_agents():
settings = get_settings()
agents = [
project_editor_record(record, "agents", global_scope=True)
for record in list_global_editor_records("agents")
]
return {"agents": agents, "selected_agent_name": selected_agent_name(settings)}, 200


def get_global_agent(agent_id):
_validate_identifier(agent_id, "agent identifier")
return _resource(read_global_editor_record("agents", agent_id), "agents"), 200


def get_global_agent_options():
settings = get_settings()
# Bound lazily so this module does not import the agent route blueprint at load.
route_agents = import_module("route_backend_agents")
endpoints = route_agents.build_combined_model_endpoints(settings)
return build_global_agent_editor_options(settings, endpoints), 200


def create_global_agent(user_id, body, prepare):
settings = get_settings()
saved = apply_global_agent_write(user_id, None, body, prepare, settings)
log_committed_global_editor_change("agents", user_id, saved, "creation")
return _resource(saved, "agents"), 201


def update_global_agent(user_id, agent_id, body, prepare):
_validate_identifier(agent_id, "agent identifier")
settings = get_settings()
existing = read_global_editor_record("agents", agent_id)
saved = apply_global_agent_write(user_id, existing, body, prepare, settings)
_follow_default_agent_rename(existing, saved, settings)
log_committed_global_editor_change("agents", user_id, saved, "update")
return _resource(saved, "agents"), 200


def delete_global_agent_record(user_id, agent_id):
_validate_identifier(agent_id, "agent identifier")
settings = get_settings()
existing = read_global_editor_record("agents", agent_id)
delete_global_editor_record("agents", user_id, existing, settings)
log_committed_global_editor_change("agents", user_id, existing, "deletion")
return {"success": True}, 200


def list_global_actions():
actions = [
project_editor_record(record, "actions", global_scope=True)
for record in list_global_editor_records("actions")
]
return {"actions": actions}, 200


def get_global_action(action_id):
_validate_identifier(action_id, "action identifier")
return _resource(read_global_editor_record("actions", action_id), "actions"), 200


def get_global_action_options():
"""The tenant-level Key Vault reminder defaults the action editor shows."""
return {"secret_reminders": build_secret_reminder_defaults(get_settings())}, 200


def create_global_action(user_id, body, prepare):
settings = get_settings()
saved = apply_global_action_write(user_id, None, body, prepare, settings)
log_committed_global_editor_change("actions", user_id, saved, "creation")
return _resource(saved, "actions"), 201


def update_global_action(user_id, action_id, body, prepare):
_validate_identifier(action_id, "action identifier")
settings = get_settings()
existing = read_global_editor_record("actions", action_id)
saved = apply_global_action_write(user_id, existing, body, prepare, settings)
log_committed_global_editor_change("actions", user_id, saved, "update")
return _resource(saved, "actions"), 200


def delete_global_action_record(user_id, action_id):
_validate_identifier(action_id, "action identifier")
settings = get_settings()
existing = read_global_editor_record("actions", action_id)
delete_global_editor_record("actions", user_id, existing, settings)
log_committed_global_editor_change("actions", user_id, existing, "deletion")
return {"success": True}, 200
Loading
Loading