diff --git a/application/single_app/admin_settings_fields.py b/application/single_app/admin_settings_fields.py index a5f19e852..bdd25b97a 100644 --- a/application/single_app/admin_settings_fields.py +++ b/application/single_app/admin_settings_fields.py @@ -4596,6 +4596,25 @@ "depends_on": {"key": "enable_semantic_kernel", "equals": True}, }, ], + # The organisation's agents themselves, rather than settings about them. The + # component reads and writes through the global editor routes + # (/api/v2/admin/agents) and the classic enable and default-agent routes, never + # the settings draft, so each change saves on its own. + "organization-agents-section": [ + { + "type": "component", + "component": "global-agents-manager", + "label": "Global Agents", + "help": ( + "Agents the organisation provides. Everyone uses the default agent while " + "Workspace Mode is off; with it on, these appear beside people's own agents " + "only when global agents are added to workspaces. Create one from scratch " + "or from an approved template, and disable one to keep it without offering " + "it." + ), + "depends_on": {"key": "enable_semantic_kernel", "equals": True}, + }, + ], # 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": [ @@ -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 ---------------------------------------------------------- # @@ -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 diff --git a/application/single_app/admin_settings_nav.py b/application/single_app/admin_settings_nav.py index abb6888db..0b8747e42 100644 --- a/application/single_app/admin_settings_nav.py +++ b/application/single_app/admin_settings_nav.py @@ -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"}, diff --git a/application/single_app/app.py b/application/single_app/app.py index 10e4b599f..80dde76cf 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -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 * @@ -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) diff --git a/application/single_app/config.py b/application/single_app/config.py index 02872ea66..98bde7c3c 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.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 diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 08502c652..be446f08b 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -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, diff --git a/application/single_app/functions_global_editor_access.py b/application/single_app/functions_global_editor_access.py new file mode 100644 index 000000000..c14b12ace --- /dev/null +++ b/application/single_app/functions_global_editor_access.py @@ -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 diff --git a/application/single_app/functions_workspace_authoring.py b/application/single_app/functions_workspace_authoring.py index a16fabfe2..62db0aa74 100644 --- a/application/single_app/functions_workspace_authoring.py +++ b/application/single_app/functions_workspace_authoring.py @@ -54,6 +54,16 @@ ("new_foundry", "New Foundry", "allow_group_new_foundry_agents"), ("foundry_workflow", "Foundry Workflow", "allow_group_new_foundry_agents"), ) +# Global agents may be any type. The classic admin agent editor offers all four, and +# the tenant-wide ``allow_*foundry_agents`` defaults have no administrator control, +# so gating on them would take Foundry agents away from the one surface that has +# always been able to create them. +_GLOBAL_AGENT_TYPES = ( + ("local", "Local agent", None), + ("aifoundry", "Azure AI Foundry", None), + ("new_foundry", "New Foundry", None), + ("foundry_workflow", "Foundry Workflow", None), +) _BUILTIN_ACTIONS = ( ("time", "Time", "enable_time_plugin"), ("fact_memory", "Fact memory", "enable_fact_memory_plugin"), @@ -107,6 +117,23 @@ "enable_url_access": False, **{flag: False for _, _, flag in _BUILTIN_ACTIONS}, } +# The global agent editor options: the same shape again, with no workspace +# permission in it, because a global agent is the organisation's and is authored +# only by an administrator. +_GLOBAL_OPTION_DEFAULTS = { + "enable_semantic_kernel": False, + "per_user_semantic_kernel": False, + "merge_global_semantic_kernel_with_workspace": False, + "enable_multi_model_endpoints": False, + "default_model_selection": {}, + "gpt_model": {}, + "enable_gpt_apim": False, + "azure_apim_gpt_deployment": "", + "enable_agent_template_gallery": False, + "enable_web_search": False, + "enable_url_access": False, + **{flag: False for _, _, flag in _BUILTIN_ACTIONS}, +} class WorkspaceAuthoringConflict(ValueError): @@ -471,7 +498,7 @@ def merge(before, changes, path=(), *, replace_objects=False): return result -def _secret_scope(kind, user_id, record, path, group_id=None): +def _secret_scope(kind, user_id, record, path, group_id=None, *, global_scope=False): """Resolve the Key Vault (scope_value, source, scope) for one credential path. Group resources store their credentials in the group's Key Vault namespace so a @@ -484,7 +511,15 @@ def _secret_scope(kind, user_id, record, path, group_id=None): so ``{agent_id}--agent--group--{name}`` names written by the legacy group route resolve, and so a group agent never mints a ``--user--`` secret the legacy ``scope="group"`` delete would leave behind (M4C §2.1). + + Global agents and actions are keyed by their own id in the ``global`` namespace, + which is how ``save_global_agent`` and ``save_global_action`` name them, so a + record saved by the classic admin editor can be edited here and the reverse. """ + if global_scope: + if kind == "agents": + return (record["id"], "agent", "global") + return (record["id"], "action" if path[0] == "auth" else "action-addset", "global") if kind == "agents": return (record["id"], "agent", "group" if group_id else "user") source = "action" if path[0] == "auth" else "action-addset" @@ -493,7 +528,7 @@ def _secret_scope(kind, user_id, record, path, group_id=None): return (user_id, source, "user") -def _legacy_editor_secret_reference(record, path, kind, user_id, settings, group_id=None): +def _legacy_editor_secret_reference(record, path, kind, user_id, settings, group_id=None, *, global_scope=False): """Recover a legacy stored trigger only from its owned, conventional Key Vault name.""" unavailable = "A stored secret is unavailable. Re-enter its value." if not settings.get("enable_key_vault_secret_storage") or not settings.get("key_vault_name"): @@ -518,7 +553,9 @@ def _legacy_editor_secret_reference(record, path, kind, user_id, settings, group secret_name = keyvault._build_plugin_additional_field_secret_name(name, f"mcp-custom-header-{path[2]}") if not secret_name: raise WorkspaceAuthoringValidation(unavailable) - scope_value, source, kv_scope = _secret_scope(kind, user_id, record, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, record, path, group_id, global_scope=global_scope, + ) try: reference = keyvault.build_full_secret_name(secret_name, scope_value, source, kv_scope) value = keyvault.resolve_secret_reference_for_context( @@ -545,12 +582,14 @@ def _supported_storage_paths(record, kind): } -def _check_stored_references(record, kind, user_id, group_id=None): +def _check_stored_references(record, kind, user_id, group_id=None, *, global_scope=False): keyvault = import_module("functions_keyvault") for path in editor_secret_paths(record, kind): value = _get(record, path) if _is_reference(value): - scope_value, source, kv_scope = _secret_scope(kind, user_id, record, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, record, path, group_id, global_scope=global_scope, + ) if not keyvault.secret_reference_matches_context( value, scope_value=scope_value, scope=kv_scope, allowed_sources={source}, ): @@ -576,10 +615,10 @@ def _cleanup_staged_secrets(references): ) -def _stage_editor_secrets(record, kind, user_id, settings, staged, group_id=None): +def _stage_editor_secrets(record, kind, user_id, settings, staged, group_id=None, *, global_scope=False): """Fresh names isolate failed CAS writes from every credential in a stored manifest.""" keyvault = import_module("functions_keyvault") - _check_stored_references(record, kind, user_id, group_id) + _check_stored_references(record, kind, user_id, group_id, global_scope=global_scope) if not settings.get("enable_key_vault_secret_storage", False): return if not settings.get("key_vault_name"): @@ -590,7 +629,9 @@ def _stage_editor_secrets(record, kind, user_id, settings, staged, group_id=None continue if not isinstance(value, str): raise WorkspaceAuthoringValidation("Credentials must be text values.") - scope_value, source, kv_scope = _secret_scope(kind, user_id, record, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, record, path, group_id, global_scope=global_scope, + ) secret_name = f"editor-{uuid.uuid4().hex}" reference = keyvault.store_secret_in_key_vault( secret_name, value, scope_value, source=source, scope=kv_scope, @@ -603,14 +644,16 @@ def _stage_editor_secrets(record, kind, user_id, settings, staged, group_id=None _set(record, path, reference) -def _cleanup_replaced_secrets(record, kind, user_id, settings, group_id=None): +def _cleanup_replaced_secrets(record, kind, user_id, settings, group_id=None, *, global_scope=False): if not record or not settings.get("enable_key_vault_secret_storage") or not settings.get("key_vault_name"): return keyvault = import_module("functions_keyvault") references = set() for path in _supported_storage_paths(record, kind): value = _get(record, path, None) - scope_value, source, kv_scope = _secret_scope(kind, user_id, record, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, record, path, group_id, global_scope=global_scope, + ) if isinstance(value, str) and keyvault.secret_reference_matches_context( value, scope_value=scope_value, scope=kv_scope, allowed_sources={source}, ): @@ -620,7 +663,11 @@ def _cleanup_replaced_secrets(record, kind, user_id, settings, group_id=None): try: # Legacy names may be shared after a classic rename. Never delete a reference # still used by another owned manifest (including the just-committed one). - if group_id: + if global_scope: + still_used = _container(kind, "global").query_items( + query="SELECT * FROM c", enable_cross_partition_query=True, + ) + elif group_id: still_used = _container(kind, "group").query_items( query="SELECT * FROM c WHERE c.group_id = @group_id", parameters=[{"name": "@group_id", "value": group_id}], partition_key=group_id, @@ -644,7 +691,7 @@ def _cleanup_replaced_secrets(record, kind, user_id, settings, group_id=None): ) -def _sync_editor_reminders(saved, kind, user_id, settings, group_id=None): +def _sync_editor_reminders(saved, kind, user_id, settings, group_id=None, *, global_scope=False): if kind != "actions" or not settings.get("enable_key_vault_secret_storage"): return saved metadata = saved.get("metadata") or {} @@ -652,15 +699,21 @@ def _sync_editor_reminders(saved, kind, user_id, settings, group_id=None): return saved keyvault = import_module("functions_keyvault") updated = deepcopy(saved) - partition = group_id or user_id - scope = "group" if group_id else "personal" + if global_scope: + # Global records are partitioned by their own id. + partition, scope = saved["id"], "global" + else: + partition = group_id or user_id + scope = "group" if group_id else "personal" try: # The main CAS already succeeded. In particular, a stale request cannot # update expiration on a previously stored credential or reminder inventory. for path in _supported_storage_paths(updated, kind): reference = _get(updated, path, None) if isinstance(reference, str) and keyvault.validate_secret_name_dynamic(reference): - scope_value, source, kv_scope = _secret_scope(kind, user_id, updated, path, group_id) + scope_value, source, kv_scope = _secret_scope( + kind, user_id, updated, path, group_id, global_scope=global_scope, + ) keyvault._sync_plugin_secret_reminder(updated, path, reference, scope_value, source, kv_scope) sync_status = (updated.get("metadata") or {}).get("key_vault_secret_reminder_sync") if sync_status is not None and sync_status != metadata.get("key_vault_secret_reminder_sync"): @@ -679,11 +732,17 @@ def _sync_editor_reminders(saved, kind, user_id, settings, group_id=None): return saved -def _invalidate_editor_catalog(kind, user_id, operation, group_id=None): +def _invalidate_editor_catalog(kind, user_id, operation, group_id=None, *, global_scope=False): builtins.kernel_reload_needed = True try: cache = import_module("functions_chat_bootstrap_cache") - if group_id: + if global_scope: + # A global agent or action reaches every user, exactly as the classic + # global saves invalidate it. + cache.bump_chat_bootstrap_global_cache_version( + reason=f"global_{'agent' if kind == 'agents' else 'action'}_{operation}", + ) + elif group_id: # Group resource changes affect every member, so invalidate globally, # exactly as the classic group action and agent saves do (M4C §2.2). cache.bump_chat_bootstrap_global_cache_version( @@ -1072,9 +1131,242 @@ def apply_group_agent_write(user_id, group_id, existing, body, prepare, settings return save_group_editor_record("agents", user_id, group_id, prepared, existing, settings) +# === Global (administrator) agent and action editor === +# Global agents and actions reuse the same engine once more. The administrator +# boundary is enforced by the routes; these helpers bind the global containers +# (partitioned by id), the global Key Vault namespace (keyed by the record id, as the +# classic global saves name it), the global catalogue invalidation, and the two +# rules the classic admin routes apply: a name is unique among global records of +# its kind ignoring case, and the selected default agent cannot be deleted. + +def _assert_global_record_access(record): + """A stored global record must not claim a group or personal owner.""" + if ( + record.get("is_group") + or record.get("group_id") + or record.get("scope") not in (None, "", "global") + ): + raise LookupError("This resource is unavailable.") + + +def read_global_editor_record(kind, record_id): + """Read one raw global record for the editor.""" + if not isinstance(record_id, str) or not record_id or any(char in record_id for char in "/\\?#"): + raise LookupError("This resource is unavailable.") + exceptions = import_module("azure.cosmos.exceptions") + try: + record = _container(kind, "global").read_item(item=record_id, partition_key=record_id) + except exceptions.CosmosResourceNotFoundError as exc: + raise LookupError("This resource is unavailable.") from exc + if record.get("id") != record_id: + raise LookupError("This resource is unavailable.") + _assert_global_record_access(record) + return deepcopy(record) + + +def list_global_editor_records(kind): + """Return every raw global record of a kind, enabled or not, for the admin list.""" + records = [] + for record in _container(kind, "global").query_items( + query="SELECT * FROM c", enable_cross_partition_query=True, + ): + try: + _assert_global_record_access(record) + except LookupError: + continue + records.append(record) + return records + + +def _assert_global_available_name(kind, record, existing): + """Refuse a name another global record of the kind already holds, ignoring case. + + The classic admin routes apply the same rule. An unchanged name is accepted even + when a legacy duplicate exists, so an edit is never blocked by data it did not + create. + """ + name = record.get("name") + if not isinstance(name, str) or not name.strip(): + raise WorkspaceAuthoringValidation("A name is required.") + if existing and name == existing.get("name"): + return + matches = _container(kind, "global").query_items( + query="SELECT c.id FROM c WHERE STRINGEQUALS(c.name, @name, true)", + parameters=[{"name": "@name", "value": name}], enable_cross_partition_query=True, + ) + if any(match.get("id") != (existing or {}).get("id") for match in matches): + label = "agent" if kind == "agents" else "action" + raise WorkspaceAuthoringValidation(f"A global {label} with this name already exists.") + + +def save_global_editor_record(kind, user_id, record, existing, settings): + """Save one prepared global manifest with a mandatory conditional write.""" + if existing: + try: + current = read_global_editor_record(kind, existing["id"]) + except LookupError as exc: + raise WorkspaceAuthoringConflict("This resource changed. Reload it before saving.") from exc + if not existing.get("_etag") or existing["_etag"] != current.get("_etag"): + raise WorkspaceAuthoringConflict("This resource changed. Reload it before saving.") + delegation = import_module("functions_agent_delegation") + if kind == "agents": + delegation.validate_agent_delegation_bindings( + record, user_id=user_id, scope_type="global", scope_id="global", + settings=settings, existing_agent=existing, + ) + else: + record = delegation.validate_agent_action_for_scope( + record, user_id=user_id, scope_type="global", scope_id="global", settings=settings, + ) + identities = import_module("functions_workspace_identities") + identities.validate_action_identity_reference( + record, identities.WORKSPACE_IDENTITY_SCOPE_GLOBAL, identities.WORKSPACE_IDENTITY_SCOPE_GLOBAL, + ) + result = { + key: deepcopy(value) for key, value in record.items() + if not key.startswith("_") and key not in _MANAGED_FIELDS + } + now = datetime.now(timezone.utc).isoformat() + result.update({ + "id": existing["id"] if existing else record["id"], + "created_at": (existing or {}).get("created_at", now), + "created_by": (existing or {}).get("created_by", user_id), + "modified_at": now, "modified_by": user_id, "updated_at": now, + # The stored shape the classic global saves write, which the runtime and the + # chat catalogue branch on. + "is_global": True, "is_group": False, + }) + if kind == "actions": + result.update({"scope": "global", "scope_id": "global"}) + result["is_enabled"] = bool(result.get("is_enabled", (existing or {}).get("is_enabled", True))) + staged = [] + write_started = False + exceptions = import_module("azure.cosmos.exceptions") + try: + _stage_editor_secrets(result, kind, user_id, settings, staged, global_scope=True) + container = _container(kind, "global") + write_started = True + if existing: + saved = container.replace_item( + item=existing["id"], body=result, etag=existing["_etag"], + match_condition=import_module("azure.core").MatchConditions.IfNotModified, + ) + else: + saved = container.create_item(body=result) + except Exception as exc: + conflict_response = isinstance(exc, exceptions.CosmosHttpResponseError) and ( + exc.status_code in (409, 412) or (existing is not None and exc.status_code == 404) + ) + if not write_started: + _cleanup_staged_secrets(staged) + if conflict_response: + raise WorkspaceAuthoringConflict("This resource changed. Reload it before saving.") from exc + raise + saved = _sync_editor_reminders(saved, kind, user_id, settings, global_scope=True) + _cleanup_replaced_secrets(existing, kind, user_id, settings, global_scope=True) + _invalidate_editor_catalog(kind, user_id, "saved", global_scope=True) + return saved + + +def delete_global_editor_record(kind, user_id, record, settings): + """Delete one global record with a mandatory conditional write.""" + _assert_global_record_access(record) + if not record.get("_etag"): + raise WorkspaceAuthoringConflict("Reload this resource before deleting it.") + if kind == "agents": + selected = settings.get("global_selected_agent") + if isinstance(selected, dict) and selected.get("name") == record.get("name"): + raise WorkspaceAuthoringValidation("Choose another default agent before deleting this agent.") + exceptions = import_module("azure.cosmos.exceptions") + try: + _container(kind, "global").delete_item( + item=record["id"], partition_key=record["id"], etag=record["_etag"], + match_condition=import_module("azure.core").MatchConditions.IfNotModified, + ) + except exceptions.CosmosHttpResponseError as exc: + if exc.status_code in (404, 412): + raise WorkspaceAuthoringConflict("This resource changed. Reload it before deleting.") from exc + raise + _cleanup_replaced_secrets(record, kind, user_id, settings, global_scope=True) + _invalidate_editor_catalog(kind, user_id, "deleted", global_scope=True) + + +def _prepared_or_error(prepared, error, kind): + if error: + status = error[1] if isinstance(error, tuple) else 400 + if status == 403: + raise PermissionError("This configuration is unavailable.") + raise WorkspaceAuthoringValidation( + "Invalid agent configuration." if kind == "agents" else "Invalid action configuration." + ) + return prepared + + +def apply_global_action_write(user_id, existing, body, prepare, settings): + """Merge editor intent, validate through ``prepare``, and persist one global action. + + Mirrors :func:`apply_group_action_write`: the server allocates a new action's id, + and stored secret placeholders resolve against the global Key Vault namespace. + ``prepare`` takes ``(user_id, plugin, settings, existing)``. + """ + merged = merge_editor_write( + existing or {}, body, "actions", creating=existing is None, + resolve_stored_placeholder=lambda record, path: _legacy_editor_secret_reference( + record, path, "actions", user_id, settings, global_scope=True, + ), + ) + if not existing: + merged["id"] = str(uuid.uuid4()) + merged.setdefault("displayName", merged.get("name", "")) + merged.setdefault("description", "") + proposed = deepcopy(merged) + prepared, error = prepare( + user_id, _editor_validation_input("actions", merged, existing), settings, existing, + ) + prepared = _prepared_or_error(prepared, error, "actions") + if existing: + prepared = _preserve_unedited_values(existing, proposed, prepared) + _assert_global_available_name("actions", prepared, existing) + return save_global_editor_record("actions", user_id, prepared, existing, settings) + + +def apply_global_agent_write(user_id, existing, body, prepare, settings): + """Merge editor intent, validate through ``prepare``, and persist one global agent. + + Mirrors :func:`apply_group_agent_write`: the id is a client-allocated UUID, and the + agent-type gate runs against the tenant-wide Foundry flags the classic admin editor + reads. ``prepare`` takes ``(user_id, agent, settings, existing)``. + """ + merged = merge_editor_write( + existing or {}, body, "agents", creating=existing is None, + resolve_stored_placeholder=lambda record, path: _legacy_editor_secret_reference( + record, path, "agents", user_id, settings, global_scope=True, + ), + ) + if not existing: + try: + merged["id"] = str(uuid.UUID(merged.get("id", ""))) + except (ValueError, TypeError, AttributeError) as exc: + raise WorkspaceAuthoringValidation("Allocate an agent ID before saving.") from exc + merged.setdefault("display_name", merged.get("name", "")) + merged.setdefault("description", "") + merged.setdefault("instructions", "") + _validate_agent_editor_changes(merged, existing, settings, user_id, scope="global") + proposed = deepcopy(merged) + prepared, error = prepare( + user_id, _editor_validation_input("agents", merged, existing), settings, existing, + ) + prepared = _prepared_or_error(prepared, error, "agents") + if existing: + prepared = _preserve_unedited_values(existing, proposed, prepared) + _assert_global_available_name("agents", prepared, existing) + return save_global_editor_record("agents", user_id, prepared, existing, settings) + + def _validate_agent_editor_changes(record, existing, settings, user_id, *, scope="personal"): is_group = scope == "group" - type_table = _GROUP_AGENT_TYPES if is_group else _AGENT_TYPES + is_global = scope == "global" + type_table = _GROUP_AGENT_TYPES if is_group else _GLOBAL_AGENT_TYPES if is_global else _AGENT_TYPES endpoint_flag = "allow_group_custom_endpoints" if is_group else "allow_user_custom_endpoints" endpoint_governance = "governance_group_endpoints" if is_group else "governance_user_endpoints" surface = "group" if is_group else "personal" @@ -1086,6 +1378,11 @@ def _validate_agent_editor_changes(record, existing, settings, user_id, *, scope raise PermissionError(f"This agent type is disabled for {surface} workspaces.") if kind != "local" and record.get("actions_to_load"): raise WorkspaceAuthoringValidation("Remove local actions explicitly before selecting a Foundry agent type.") + if is_global: + # An administrator may give a global agent its own model connection, as the + # classic admin editor always allowed; the workspace custom-endpoint + # permissions govern people's own agents, not the organisation's. + return if kind == "local": configured_connection = any( record.get(field) not in (None, "") @@ -1315,6 +1612,30 @@ def log_committed_group_editor_change(kind, user_id, group_id, stored, operation ) +def log_committed_global_editor_change(kind, user_id, stored, operation): + """Record a committed global editor write, mirroring the group logger. + + Called only after the conditional write has committed, so a refused write is never + logged, and a logging failure never fails the write. ``scope`` is ``"global"``, + the scope the classic admin routes record. + """ + activity = import_module("functions_activity_logging") + prefix = "agent" if kind == "agents" else "action" + event = getattr(activity, f"log_{prefix}_{operation}") + try: + event( + user_id=user_id, scope="global", + **{f"{prefix}_id": stored.get("id", ""), f"{prefix}_name": stored.get("name", "")}, + **({"agent_display_name": stored.get("display_name", "")} if prefix == "agent" and operation != "deletion" else {}), + **({"action_type": stored.get("type", "")} if prefix == "action" and operation != "deletion" else {}), + ) + except Exception as exc: + import_module("functions_appinsights").log_event( + "[WORKSPACE_ACTIVITY] Unable to record a committed editor change.", + level=logging.WARNING, extra={"error_type": type(exc).__name__}, + ) + + def build_secret_reminder_defaults(settings): """The tenant-level Key Vault reminder defaults both editor option builders share. @@ -1501,6 +1822,58 @@ def build_group_agent_editor_options(user_id, group_id, settings, model_endpoint } +def build_global_agent_editor_options(settings, model_endpoints): + """The global agent editor options, for an administrator. + + The personal and group shape, with what a global agent needs: every agent type is + offered, as the classic admin editor offers them all, every global model connection + is offered (an administrator manages them, so no per-user governance filter + applies), and template submission follows the gate the submit route applies to an + administrator. Secrets are masked and reminder defaults are shared with the other + builders. + """ + settings_module = import_module("functions_settings") + sanitized = settings_module.sanitize_settings_for_user({ + key: deepcopy(settings.get(key, default)) for key, default in _GLOBAL_OPTION_DEFAULTS.items() + }) + safe = {key: sanitized[key] for key in _GLOBAL_OPTION_DEFAULTS if key in sanitized} + model_endpoints = settings_module.sanitize_settings_for_user( + {"model_endpoints": filter_model_endpoints_by_capability(model_endpoints, preserve_empty=True)}, + ).get("model_endpoints", []) + # These names contain "key"/"secret", but their values are strictly UI flags/defaults. + reminder_options = build_secret_reminder_defaults(settings) + safe.update({ + "enable_key_vault_secret_storage": reminder_options["storage_enabled"], + "enable_key_vault_secret_expiration_reminders": reminder_options["reminders_enabled"], + "key_vault_secret_expiration_default_lead_days": reminder_options["lead_days"], + "key_vault_secret_expiration_default_contact_email": reminder_options["contact_email"], + "key_vault_secret_expiration_require_expiration": reminder_options["require_expiration"], + }) + templates_module = import_module("functions_agent_templates") + safe["agent_template_submission_allowed"] = templates_module.agent_template_submission_decision( + settings, True, + )[0] + endpoints = [deepcopy(endpoint) for endpoint in model_endpoints if endpoint.get("scope") == "global"] + for endpoint in endpoints: + for path in editor_secret_paths(endpoint, "agents"): + _set(endpoint, path, EDITOR_SECRET_MASK) + safe["enable_multi_model_endpoints"] = bool( + safe.get("enable_multi_model_endpoints") or any(endpoint.get("models") for endpoint in endpoints) + ) + return { + "agent_types": [ + {"value": value, "label": label, "enabled": True} + for value, label, _flag in _GLOBAL_AGENT_TYPES + ], + "settings": safe, + "model_endpoints": endpoints, + "builtin_actions": [ + {"id": value, "label": label} + for value, label, flag in _BUILTIN_ACTIONS if safe.get(flag) + ], + } + + def build_action_editor_types(discovered_types): """Enrich governed discovery with canonical local schemas, without example defaults.""" validation = import_module("json_schema_validation") diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 5ec735f7e..194a53247 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -1344,6 +1344,67 @@ def _prepare_group_agent_payload(user_id, group_id, agent, settings, existing): return cleaned_agent, None +def _prepare_global_agent_payload(user_id, agent, settings, existing): + """Clean, enrich and validate one global agent for the V2 admin editor routes. + + Applies the rules the classic ``/api/admin/agents`` routes apply: the payload is + sanitized and marked global, assigned knowledge resolves in global scope with the + administrator's access, the schema validates, and Call agent attachments validate + against the global scope. Returns ``(cleaned_agent, error_response)`` with exactly + one set; the signature matches ``apply_global_agent_write``. + """ + action = 'create' if existing is None else 'edit' + # A refusal carries a fixed message and keeps the reason in the server log, so no + # exception text reaches a response (CodeQL py/stack-trace-exposure). + try: + cleaned_agent = sanitize_agent_payload(agent) + except AgentPayloadError as exc: + log_event( + "Global agent save refused: invalid payload", + level=logging.WARNING, + extra={"scope": "global", "action": action, "error": str(exc)}, + ) + return None, (jsonify({'error': 'Invalid agent configuration.'}), 400) + + cleaned_agent['is_global'] = True + cleaned_agent['is_group'] = False + + try: + if existing is None or ( + cleaned_agent.get('other_settings', {}).get('assigned_knowledge') + != (existing.get('other_settings') or {}).get('assigned_knowledge') + ): + cleaned_agent = apply_assigned_knowledge_to_agent_payload( + cleaned_agent, + user_id=user_id, + agent_scope='global', + is_admin=True, + ) + except AssignedKnowledgeError as exc: + log_event( + "Global agent save refused: assigned knowledge", + level=logging.WARNING, + extra={"scope": "global", "action": action, "error": str(exc)}, + ) + return None, (jsonify({'error': 'Invalid assigned knowledge configuration.'}), 400) + + validation_error = validate_agent(cleaned_agent) + if validation_error: + return None, (jsonify({'error': f'Agent validation failed: {validation_error}'}), 400) + + try: + validate_agent_delegation_bindings( + cleaned_agent, user_id=user_id, scope_type='global', scope_id='global', + settings=settings, existing_agent=existing, + ) + except PermissionError: + return None, (jsonify({'error': 'You are not authorized to attach these agent actions.'}), 403) + except (ValueError, LookupError): + return None, (jsonify({'error': 'Invalid or unavailable Call agent action selection.'}), 400) + + return cleaned_agent, None + + def _create_personal_agent(user_id, payload): """Create one personal agent from an object body.""" try: diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index ee806d00a..c2681bf6d 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -1495,6 +1495,82 @@ def _prepare_group_action_payload(user_id, group_id, plugin, settings, existing) return plugin_to_save, None +def _prepare_global_action_payload(user_id, plugin, settings, existing): + """Clean, default and validate one global action for the V2 admin editor routes. + + Mirrors ``_prepare_group_action_payload`` with the checks the classic + ``/api/admin/plugins`` routes apply to a global action: the type must be one the + deployment provides (a legacy type only when editing a record that already has + it), the identity and the MCP destination policy resolve against the global + scope, and per-type workspace governance does not apply, because an administrator + authors the organisation's actions. Returns ``(plugin_to_save, error_response)`` + with exactly one set; the signature matches ``apply_global_action_write``. + """ + validate_legacy_action_update(plugin, existing) + plugin_to_save = dict(plugin) + if 'is_global' in plugin_to_save: + del plugin_to_save['is_global'] + if 'is_group' in plugin_to_save: + del plugin_to_save['is_group'] + + plugin_to_save.setdefault('name', '') + plugin_to_save.setdefault('displayName', plugin_to_save.get('name', '')) + plugin_to_save.setdefault('description', '') + plugin_to_save.setdefault('metadata', {}) + plugin_to_save.setdefault('additionalFields', {}) + + for field in PLUGIN_STORAGE_MANAGED_FIELDS: + if field == 'id': + continue + plugin_to_save.pop(field, None) + for field in ('execution_status', 'is_legacy', 'legacy_source', 'legacy_locator', 'runtime_user_id'): + plugin_to_save.pop(field, None) + + plugin_to_save.setdefault('endpoint', '') + _apply_plugin_runtime_defaults(plugin_to_save) + plugin_type = plugin_to_save['type'] + allowed_types = discover_plugin_types(include_legacy=existing is not None) + if allowed_types is not None and plugin_type not in allowed_types: + return None, (jsonify({'error': 'This action type is not available.'}), 400) + try: + _validate_action_identity_for_scope( + plugin_to_save, + WORKSPACE_IDENTITY_SCOPE_GLOBAL, + WORKSPACE_IDENTITY_SCOPE_GLOBAL, + ) + except (ValueError, LookupError, PermissionError): + return None, (jsonify({'error': 'Action identity configuration is invalid.'}), 400) + + if 'auth' not in plugin_to_save: + plugin_to_save['auth'] = {'type': 'identity'} + elif not isinstance(plugin_to_save['auth'], dict): + plugin_to_save['auth'] = {'type': 'identity'} + elif 'type' not in plugin_to_save['auth']: + plugin_to_save['auth']['type'] = 'identity' + + validation_error = validate_editor_action_manifest(plugin_to_save) + if validation_error: + return None, (jsonify({'error': f'Plugin validation failed: {validation_error}'}), 400) + is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(plugin_to_save, plugin_type) + if not is_valid: + return None, (jsonify({'error': f'Plugin validation failed: {"; ".join(validation_errors)}'}), 400) + + try: + _enforce_mcp_destination_policy( + plugin_to_save, + WORKSPACE_IDENTITY_SCOPE_GLOBAL, + WORKSPACE_IDENTITY_SCOPE_GLOBAL, + operation='global_action_save', + user_id=user_id, + ) + except PermissionError: + return None, (jsonify({'error': 'MCP destination is not allowed by governance policy.'}), 403) + except ValueError: + return None, (jsonify({'error': 'MCP destination configuration is invalid.'}), 400) + + return plugin_to_save, None + + def _save_personal_action_or_error(user_id, plugin_to_save, legacy_locator=None): """Persist one personal action, mapping the storage failures onto HTTP responses.""" try: diff --git a/application/single_app/route_backend_v2_admin_agents_actions.py b/application/single_app/route_backend_v2_admin_agents_actions.py new file mode 100644 index 000000000..036b15318 --- /dev/null +++ b/application/single_app/route_backend_v2_admin_agents_actions.py @@ -0,0 +1,212 @@ +# route_backend_v2_admin_agents_actions.py +"""V2 Admin Settings routes for the global agent and action editors. + +These serve the same V2 agent and action editors personal and group workspaces use, +for the organisation's global agents and actions. Every route requires the Admin +role; the shared editor contract, conditional writes and secret handling are +described in ``functions_global_editor_access``. +""" + +from functools import wraps + +from flask import jsonify + +from functions_authentication import admin_required, get_current_user_id, login_required +from functions_global_editor_access import ( + create_global_action, + create_global_agent, + delete_global_action_record, + delete_global_agent_record, + get_global_action, + get_global_action_options, + get_global_agent, + get_global_agent_options, + global_editor_error_response, + list_global_actions, + list_global_agents, + read_json_body, + reject_query_parameters, + reject_request_body, + update_global_action, + update_global_agent, +) +from functions_workspace_authoring import build_action_editor_types +from route_backend_agents import _prepare_global_agent_payload +from route_backend_plugins import _prepare_global_action_payload, get_plugin_types +from swagger_wrapper import swagger_route, get_auth_security + + +def _global_editor_boundary(function): + @wraps(function) + def guarded(*args, **kwargs): + try: + return function(*args, **kwargs) + except Exception as error: # noqa: BLE001 - all mapped to stable HTTP responses + return global_editor_error_response(error) + return guarded + + +def _no_store(payload, status): + # Editor reads carry configuration, so they stay out of shared caches. + response = jsonify(payload) + response.headers['Cache-Control'] = 'no-store' + return response, status + + +def register_route_backend_v2_admin_agents_actions(bp): + """Register the global agent and action editor routes. + + - GET /api/v2/admin/agents + - POST /api/v2/admin/agents + - GET /api/v2/admin/agent-options + - GET /api/v2/admin/agents/ + - PATCH /api/v2/admin/agents/ + - DELETE /api/v2/admin/agents/ + - GET /api/v2/admin/actions + - POST /api/v2/admin/actions + - GET /api/v2/admin/actions/types + - GET /api/v2/admin/action-options + - GET /api/v2/admin/actions/ + - PATCH /api/v2/admin/actions/ + - DELETE /api/v2/admin/actions/ + """ + + @bp.route('/api/v2/admin/agents', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agents_list(): + reject_query_parameters() + reject_request_body() + return _no_store(*list_global_agents()) + + @bp.route('/api/v2/admin/agents', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agents_create(): + reject_query_parameters() + return _no_store(*create_global_agent( + get_current_user_id(), read_json_body(), _prepare_global_agent_payload, + )) + + @bp.route('/api/v2/admin/agent-options', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agent_options(): + # Its own path segment so it can never collide with /agents/. + reject_query_parameters() + reject_request_body() + return _no_store(*get_global_agent_options()) + + @bp.route('/api/v2/admin/agents/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agent_read(agent_id): + reject_query_parameters() + reject_request_body() + return _no_store(*get_global_agent(agent_id)) + + @bp.route('/api/v2/admin/agents/', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agent_update(agent_id): + reject_query_parameters() + return _no_store(*update_global_agent( + get_current_user_id(), agent_id, read_json_body(), _prepare_global_agent_payload, + )) + + @bp.route('/api/v2/admin/agents/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_agent_delete(agent_id): + reject_query_parameters() + reject_request_body() + return _no_store(*delete_global_agent_record(get_current_user_id(), agent_id)) + + @bp.route('/api/v2/admin/actions', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_actions_list(): + reject_query_parameters() + reject_request_body() + return _no_store(*list_global_actions()) + + @bp.route('/api/v2/admin/actions', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_actions_create(): + reject_query_parameters() + return _no_store(*create_global_action( + get_current_user_id(), read_json_body(), _prepare_global_action_payload, + )) + + @bp.route('/api/v2/admin/actions/types', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_types(): + # Every type the deployment provides, as the classic admin catalogue offers: + # per-type workspace governance applies to people's own actions, not to the + # organisation's. + reject_query_parameters() + reject_request_body() + discovered = get_plugin_types() + return _no_store({'types': build_action_editor_types(discovered.get_json())}, 200) + + @bp.route('/api/v2/admin/action-options', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_options(): + # Its own path segment so it can never collide with /actions/. + reject_query_parameters() + reject_request_body() + return _no_store(*get_global_action_options()) + + @bp.route('/api/v2/admin/actions/', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_read(action_id): + reject_query_parameters() + reject_request_body() + return _no_store(*get_global_action(action_id)) + + @bp.route('/api/v2/admin/actions/', methods=['PATCH']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_update(action_id): + reject_query_parameters() + return _no_store(*update_global_action( + get_current_user_id(), action_id, read_json_body(), _prepare_global_action_payload, + )) + + @bp.route('/api/v2/admin/actions/', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + @_global_editor_boundary + def v2_admin_global_action_delete(action_id): + reject_query_parameters() + reject_request_body() + return _no_store(*delete_global_action_record(get_current_user_id(), action_id)) diff --git a/application/single_app/templates/admin/_panes/agents.html b/application/single_app/templates/admin/_panes/agents.html index 86164afba..a9f814e19 100644 --- a/application/single_app/templates/admin/_panes/agents.html +++ b/application/single_app/templates/admin/_panes/agents.html @@ -207,7 +207,7 @@

Agent Orchestration Settings

-
+

Global Agents

- ))} +
+ - -
- {error ? -
{error}
-
: null} -
- {readOnly ? 'Configuration details' : 'Configuration'} - {sections.map((section) => section.id === 'advanced' ? ( -
- {section.label} - {section.description ?

{section.description}

: null} -
{section.content}
-
- ) : ( -
-

{section.label}

- {section.description ?

{section.description}

: null} -
{section.content}
-
- ))} -
+
+ +
+
+
+ {error ? ( + + + ) : null} +
+ {readOnly ? 'Configuration details' : 'Configuration'} + {sections.map((section) => section.id === 'advanced' ? ( +
+ + + + {section.label} + {section.description ? {section.description} : null} + + +
{section.content}
+
+ ) : ( + +
+ +
+

{section.label}

+ {section.description ?

{section.description}

: null} +
+
+
{section.content}
+
+ ))} +
+
{blocker.state === 'blocked' ? ( diff --git a/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx b/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx index 370fb9a28..a81757e0a 100644 --- a/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx +++ b/application/v2_ui/src/components/workspaceActions/ActionAdvancedFields.tsx @@ -1,7 +1,7 @@ // ActionAdvancedFields.tsx import { useId } from 'react'; -import { Toggle } from '../ui/primitives'; +import { EditorDependents, EditorPanelFieldset, EditorRow, EditorSwitch } from '../workspace/EditorLayout'; import { ActionField, ActionJsonInput, ACTION_INPUT_CLASS } from './ActionFields'; import { ActionSchemaFields } from './ActionSchemaFields'; import { isRecord, type ActionTypeDefinition } from '../../lib/workspaceAuthoring'; @@ -26,30 +26,25 @@ export function ActionAdvancedFields(props: ActionConnectorProps & { const syncStates = isRecord(sync) ? Object.entries(sync) : []; return (
-
- + onChange((current) => ({ ...current, name: event.target.value }))} /> -
-

Action ID

-

{draft.id || 'Assigned when the action is saved.'}

-

The ID, not the display name, identifies this action in agent assignments.

-
+ +

{draft.id || 'Assigned when the action is saved.'}

+
{draft.type !== 'agent' ? ( -
- Secret expiration and reminders -

- Track expiration and send a rotation reminder for this action’s stored secrets. Tracking does not rotate credentials automatically. -

+ {hints ?

Key Vault storage is {hints.storageEnabled ? 'enabled' : 'disabled'}; reminder delivery is {hints.remindersEnabled ? 'enabled' : 'disabled'}. {hints.requireExpiration ? ' Your administrator requires expiration dates for tracked secrets.' : ''} @@ -57,7 +52,7 @@ export function ActionAdvancedFields(props: ActionConnectorProps & { {!hints?.storageEnabled && hints ?

This configuration is retained, but reminders for Key Vault secrets require enabled Key Vault storage and reminder delivery.

: null} - onChange((current) => { const existing = actionValueAt(current, reminderPath); @@ -71,43 +66,41 @@ export function ActionAdvancedFields(props: ActionConnectorProps & { } : {}), }); })} /> - {enabled ?
- + updateReminder('expires_on', event.target.value)} /> - updateReminder('contact_email', event.target.value)} /> - updateReminder('lead_days', event.target.value === '' ? undefined : event.target.valueAsNumber)} /> - + updateReminder('label', event.target.value)} /> -
- -