From a6b6c206919610a5a07274fedafb2d7cc9fd3e61 Mon Sep 17 00:00:00 2001 From: Bionic711 Date: Fri, 29 May 2026 19:22:52 -0500 Subject: [PATCH 01/12] init governance --- .gitignore | 2 +- application/single_app/app.py | 4 + application/single_app/app_settings_cache.py | 41 +- application/single_app/config.py | 14 +- .../single_app/functions_activity_logging.py | 64 + .../single_app/functions_governance.py | 643 ++++ .../single_app/functions_group_actions.py | 4 + .../single_app/functions_group_agents.py | 4 + .../single_app/functions_personal_actions.py | 2 + .../single_app/functions_personal_agents.py | 2 + application/single_app/functions_settings.py | 9 + .../single_app/route_backend_agents.py | 107 +- application/single_app/route_backend_chats.py | 83 +- .../single_app/route_backend_governance.py | 200 ++ .../single_app/route_backend_groups.py | 3 +- .../single_app/route_backend_models.py | 70 +- .../single_app/route_backend_plugins.py | 49 +- .../route_frontend_admin_settings.py | 58 +- .../single_app/route_frontend_chats.py | 79 +- .../route_frontend_group_workspaces.py | 16 +- .../single_app/route_frontend_workspace.py | 17 +- .../static/js/admin/admin_governance.js | 2595 +++++++++++++++++ .../single_app/templates/_sidebar_nav.html | 5 + .../single_app/templates/admin_settings.html | 175 ++ .../templates/group_workspaces.html | 40 +- .../single_app/templates/workspace.html | 34 +- .../features/v0.242.001/GOVERNANCE.md | 372 +++ ...est_admin_agent_default_model_migration.py | 25 +- .../test_governance_activity_logging.py | 143 + .../test_governance_enforcement_logic.py | 138 + ...st_governance_route_and_wiring_coverage.py | 309 ++ .../test_group_plugin_global_merge_fix.py | 9 +- ...eyvault_plugin_secret_scope_enforcement.py | 39 +- ui_tests/test_admin_governance_tab.py | 213 ++ ...st_workspace_governance_template_gating.py | 64 + 35 files changed, 5535 insertions(+), 97 deletions(-) create mode 100644 application/single_app/functions_governance.py create mode 100644 application/single_app/route_backend_governance.py create mode 100644 application/single_app/static/js/admin/admin_governance.js create mode 100644 docs/explanation/features/v0.242.001/GOVERNANCE.md create mode 100644 functional_tests/test_governance_activity_logging.py create mode 100644 functional_tests/test_governance_enforcement_logic.py create mode 100644 functional_tests/test_governance_route_and_wiring_coverage.py create mode 100644 ui_tests/test_admin_governance_tab.py create mode 100644 ui_tests/test_workspace_governance_template_gating.py diff --git a/.gitignore b/.gitignore index 05e2a5ac0..7faa6693f 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,4 @@ nul /artifacts/tmp scripts/agent.json scripts/me.json -.github/instructions/python-venv-path.instructions.md \ No newline at end of file +.github/instructions/local.instructions.md diff --git a/application/single_app/app.py b/application/single_app/app.py index 422133651..5433d5266 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -68,6 +68,7 @@ from route_backend_control_center import * from route_backend_notifications import * from route_backend_retention_policy import * +from route_backend_governance import register_route_backend_governance from route_backend_plugins import bpap as admin_plugins_bp, bpdp as dynamic_plugins_bp from route_backend_agents import bpa as admin_agents_bp from route_backend_agent_templates import bp_agent_templates @@ -934,6 +935,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Retention Policy Routes -------- register_route_backend_retention_policy(app) +# ------------------- API Governance Routes -------------- +register_route_backend_governance(app) + # ------------------- API Public Workspaces Routes ------- register_route_backend_public_workspaces(app) diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py index b9c4a3cc2..711ec0190 100644 --- a/application/single_app/app_settings_cache.py +++ b/application/single_app/app_settings_cache.py @@ -19,6 +19,8 @@ APP_SETTINGS_CACHE = {} APP_STREAM_SESSION_METADATA = {} APP_STREAM_SESSION_EVENTS = {} +APP_GOVERNANCE_CACHE_VERSION = 0 +GOVERNANCE_CACHE_VERSION_KEY = 'GOVERNANCE_CACHE_VERSION' update_settings_cache = None get_settings_cache = None initialize_stream_session_cache = None @@ -27,6 +29,8 @@ append_stream_session_event = None get_stream_session_events = None delete_stream_session_cache = None +get_governance_cache_version = None +bump_governance_cache_version = None app_cache_is_using_redis = False _app_cache_lock = threading.Lock() @@ -43,11 +47,22 @@ def _is_expired(entry): expires_at = entry.get('expires_at') return expires_at is not None and expires_at <= time.time() + +def _normalize_cache_version(value): + if isinstance(value, bytes): + value = value.decode('utf-8') + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + def configure_app_cache(settings, redis_cache_endpoint=None): global _settings, update_settings_cache, get_settings_cache, APP_SETTINGS_CACHE global APP_STREAM_SESSION_METADATA, APP_STREAM_SESSION_EVENTS + global APP_GOVERNANCE_CACHE_VERSION global initialize_stream_session_cache, set_stream_session_meta, get_stream_session_meta global append_stream_session_event, get_stream_session_events, delete_stream_session_cache + global get_governance_cache_version, bump_governance_cache_version global app_cache_is_using_redis # Local import to avoid circular dependency: functions_keyvault imports app_settings_cache. from functions_appinsights import log_event @@ -174,6 +189,16 @@ def delete_stream_session_cache_redis(cache_key): get_stream_session_events_key(cache_key), ) + def get_governance_cache_version_redis(): + cached = redis_client.get(GOVERNANCE_CACHE_VERSION_KEY) + if cached is None: + redis_client.setnx(GOVERNANCE_CACHE_VERSION_KEY, 0) + return 0 + return _normalize_cache_version(cached) + + def bump_governance_cache_version_redis(): + return _normalize_cache_version(redis_client.incr(GOVERNANCE_CACHE_VERSION_KEY)) + update_settings_cache = update_settings_cache_redis get_settings_cache = get_settings_cache_redis initialize_stream_session_cache = initialize_stream_session_cache_redis @@ -182,6 +207,8 @@ def delete_stream_session_cache_redis(cache_key): append_stream_session_event = append_stream_session_event_redis get_stream_session_events = get_stream_session_events_redis delete_stream_session_cache = delete_stream_session_cache_redis + get_governance_cache_version = get_governance_cache_version_redis + bump_governance_cache_version = bump_governance_cache_version_redis else: def update_settings_cache_mem(new_settings): @@ -258,6 +285,16 @@ def delete_stream_session_cache_mem(cache_key): APP_STREAM_SESSION_METADATA.pop(cache_key, None) APP_STREAM_SESSION_EVENTS.pop(cache_key, None) + def get_governance_cache_version_mem(): + with _app_cache_lock: + return APP_GOVERNANCE_CACHE_VERSION + + def bump_governance_cache_version_mem(): + global APP_GOVERNANCE_CACHE_VERSION + with _app_cache_lock: + APP_GOVERNANCE_CACHE_VERSION += 1 + return APP_GOVERNANCE_CACHE_VERSION + update_settings_cache = update_settings_cache_mem get_settings_cache = get_settings_cache_mem initialize_stream_session_cache = initialize_stream_session_cache_mem @@ -265,4 +302,6 @@ def delete_stream_session_cache_mem(cache_key): get_stream_session_meta = get_stream_session_meta_mem append_stream_session_event = append_stream_session_event_mem get_stream_session_events = get_stream_session_events_mem - delete_stream_session_cache = delete_stream_session_cache_mem \ No newline at end of file + delete_stream_session_cache = delete_stream_session_cache_mem + get_governance_cache_version = get_governance_cache_version_mem + bump_governance_cache_version = bump_governance_cache_version_mem \ No newline at end of file diff --git a/application/single_app/config.py b/application/single_app/config.py index 30cb1b95f..1c168c0d6 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.007" +VERSION = "0.242.011" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -478,6 +478,18 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/id") ) +cosmos_governance_policies_container_name = "governance_policies" +cosmos_governance_policies_container = cosmos_database.create_container_if_not_exists( + id=cosmos_governance_policies_container_name, + partition_key=PartitionKey(path="/id") +) + +cosmos_governance_item_policies_container_name = "governance_item_policies" +cosmos_governance_item_policies_container = cosmos_database.create_container_if_not_exists( + id=cosmos_governance_item_policies_container_name, + partition_key=PartitionKey(path="/id") +) + cosmos_agent_templates_container_name = "agent_templates" cosmos_agent_templates_container = cosmos_database.create_container_if_not_exists( id=cosmos_agent_templates_container_name, diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index 92c78dfb8..666f012f5 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -1788,6 +1788,70 @@ def log_general_admin_action( debug_print(f"⚠️ Warning: Failed to log admin action: {str(e)}") +def log_governance_change( + admin_user_id: str, + admin_email: str, + action: str, + scope: str, + target_id: str, + before_state: Optional[Dict[str, Any]] = None, + after_state: Optional[Dict[str, Any]] = None, + change_details: Optional[Dict[str, Any]] = None, +) -> None: + """Log governance mutations with detailed before/after payloads.""" + normalized_admin_user_id = coerce_activity_log_user_id(admin_user_id) + timestamp = datetime.utcnow().isoformat() + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': normalized_admin_user_id, + 'activity_type': 'governance', + 'type': 'governance', + 'timestamp': timestamp, + 'created_at': timestamp, + 'admin': { + 'user_id': normalized_admin_user_id, + 'email': admin_email, + }, + 'action': action, + 'workspace_type': 'admin', + 'workspace_context': { + 'action': action, + 'scope': scope, + 'target_id': target_id, + }, + 'governance_change': { + 'scope': scope, + 'target_id': target_id, + 'before': before_state or {}, + 'after': after_state or {}, + 'details': change_details or {}, + }, + } + + try: + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Governance change logged: {action} on {scope}/{target_id}", + extra=activity_record, + level=logging.INFO, + ) + debug_print(f"✅ Governance change logged: {action} on {scope}/{target_id}") + except Exception as e: + log_event( + message=f"Error logging governance change: {str(e)}", + extra={ + 'admin_user_id': normalized_admin_user_id, + 'admin_email': admin_email, + 'action': action, + 'scope': scope, + 'target_id': target_id, + 'error': str(e), + }, + level=logging.ERROR, + ) + debug_print(f"⚠️ Warning: Failed to log governance change: {str(e)}") + + # === AGENT & ACTION ACTIVITY LOGGING === def log_agent_creation( user_id: str, diff --git a/application/single_app/functions_governance.py b/application/single_app/functions_governance.py new file mode 100644 index 000000000..ee8d944fa --- /dev/null +++ b/application/single_app/functions_governance.py @@ -0,0 +1,643 @@ +# functions_governance.py + +"""Governance policy helpers for agents, actions, and endpoints.""" + +from copy import deepcopy +from datetime import datetime +from threading import RLock +from time import monotonic +from typing import Any, Dict, List, Optional, Set + +from flask import g, has_request_context + +import app_settings_cache +from config import cosmos_governance_item_policies_container, cosmos_governance_policies_container +from functions_activity_logging import log_governance_change +from functions_group import get_user_groups +from functions_public_workspaces import get_user_public_workspaces +from functions_settings import get_settings + + +DEFAULT_FEATURE_POLICIES = { + "governance_user_endpoints": "user_endpoints", + "governance_group_endpoints": "group_endpoints", + "governance_global_endpoints": "global_endpoints", + "governance_user_agents": "user_agents", + "governance_group_agents": "group_agents", + "governance_global_agents_usage": "global_agents_usage", + "governance_user_actions": "user_actions", + "governance_group_actions": "group_actions", + "governance_global_actions_usage": "global_actions_usage", +} + + +DEFAULT_ITEM_POLICY_ENTITY_TYPES = { + "endpoint", + "global_agent", + "global_action", +} + + +GOVERNANCE_CACHE_TTL_SECONDS = 60 +_GOVERNANCE_REQUEST_CACHE_ATTR = "simplechat_governance_request_cache" +_GOVERNANCE_CACHE_MISS = object() +_governance_cache_lock = RLock() +_governance_cache_version = 0 +_governance_process_cache: Dict[Any, Dict[str, Any]] = {} + + +def _clone_cache_value(value: Any) -> Any: + return deepcopy(value) + + +def _get_request_cache() -> Optional[Dict[Any, Any]]: + if not has_request_context(): + return None + + cache = getattr(g, _GOVERNANCE_REQUEST_CACHE_ATTR, None) + if cache is None: + cache = {} + setattr(g, _GOVERNANCE_REQUEST_CACHE_ATTR, cache) + return cache + + +def _get_request_cache_value(cache_key: Any) -> Any: + cache = _get_request_cache() + if cache is None or cache_key not in cache: + return _GOVERNANCE_CACHE_MISS + return _clone_cache_value(cache[cache_key]) + + +def _set_request_cache_value(cache_key: Any, value: Any) -> None: + cache = _get_request_cache() + if cache is not None: + cache[cache_key] = _clone_cache_value(value) + + +def _get_shared_governance_cache_version() -> int: + getter = getattr(app_settings_cache, "get_governance_cache_version", None) + if callable(getter): + try: + return int(getter() or 0) + except Exception: + return _governance_cache_version + return _governance_cache_version + + +def _bump_shared_governance_cache_version() -> int: + global _governance_cache_version + + bumper = getattr(app_settings_cache, "bump_governance_cache_version", None) + if callable(bumper): + try: + _governance_cache_version = int(bumper() or 0) + return _governance_cache_version + except Exception: + pass + + _governance_cache_version += 1 + return _governance_cache_version + + +def _get_process_cache_value(cache_key: Any) -> Any: + now = monotonic() + current_version = _get_shared_governance_cache_version() + with _governance_cache_lock: + entry = _governance_process_cache.get(cache_key) + if not entry: + return _GOVERNANCE_CACHE_MISS + + if entry.get("version") != current_version or entry.get("expires_at", 0) <= now: + _governance_process_cache.pop(cache_key, None) + return _GOVERNANCE_CACHE_MISS + + return _clone_cache_value(entry.get("value")) + + +def _set_process_cache_value(cache_key: Any, value: Any) -> None: + current_version = _get_shared_governance_cache_version() + with _governance_cache_lock: + _governance_process_cache[cache_key] = { + "expires_at": monotonic() + GOVERNANCE_CACHE_TTL_SECONDS, + "version": current_version, + "value": _clone_cache_value(value), + } + + +def _get_request_cached_governance_value(cache_key: Any, loader) -> Any: + cached_value = _get_request_cache_value(cache_key) + if cached_value is not _GOVERNANCE_CACHE_MISS: + return cached_value + + loaded_value = loader() + _set_request_cache_value(cache_key, loaded_value) + return loaded_value + + +def _get_cached_governance_value(cache_key: Any, loader) -> Any: + cached_value = _get_request_cache_value(cache_key) + if cached_value is not _GOVERNANCE_CACHE_MISS: + return cached_value + + cached_value = _get_process_cache_value(cache_key) + if cached_value is not _GOVERNANCE_CACHE_MISS: + _set_request_cache_value(cache_key, cached_value) + return cached_value + + loaded_value = loader() + _set_process_cache_value(cache_key, loaded_value) + _set_request_cache_value(cache_key, loaded_value) + return loaded_value + + +def invalidate_governance_cache() -> None: + _bump_shared_governance_cache_version() + + with _governance_cache_lock: + _governance_process_cache.clear() + + request_cache = _get_request_cache() + if request_cache is not None: + request_cache.clear() + + +def _normalize_str_list(values: Any) -> List[str]: + if not isinstance(values, list): + return [] + normalized: List[str] = [] + seen: Set[str] = set() + for value in values: + as_str = str(value or "").strip() + if not as_str: + continue + if as_str in seen: + continue + seen.add(as_str) + normalized.append(as_str) + return normalized + + +def _extract_group_ids(group_docs: Any) -> List[str]: + if not isinstance(group_docs, list): + return [] + group_ids: List[str] = [] + seen: Set[str] = set() + for group_doc in group_docs: + group_id = str((group_doc or {}).get("id") or "").strip() + if not group_id or group_id in seen: + continue + seen.add(group_id) + group_ids.append(group_id) + return group_ids + + +def _extract_workspace_ids(workspace_docs: Any) -> List[str]: + if not isinstance(workspace_docs, list): + return [] + workspace_ids: List[str] = [] + seen: Set[str] = set() + for workspace_doc in workspace_docs: + workspace_id = str((workspace_doc or {}).get("id") or "").strip() + if not workspace_id or workspace_id in seen: + continue + seen.add(workspace_id) + workspace_ids.append(workspace_id) + return workspace_ids + + +def _default_feature_policy_doc(feature_key: str) -> Dict[str, Any]: + return { + "id": f"feature:{feature_key}", + "feature_key": feature_key, + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + "updated_at": datetime.utcnow().isoformat(), + } + + +def _default_item_policy_doc(entity_type: str, item_id: str) -> Dict[str, Any]: + return { + "id": f"item:{entity_type}:{item_id}", + "entity_type": entity_type, + "item_id": item_id, + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + "updated_at": datetime.utcnow().isoformat(), + } + + +def _normalize_policy_state(payload: Dict[str, Any]) -> Dict[str, Any]: + allow_all = bool(payload.get("allow_all", True)) + allowed_users = _normalize_str_list(payload.get("allowed_users", [])) + allowed_groups = _normalize_str_list(payload.get("allowed_groups", [])) + + if allow_all and (allowed_users or allowed_groups): + allow_all = False + + if allow_all: + allowed_users = [] + allowed_groups = [] + + return { + "allow_all": allow_all, + "allowed_users": allowed_users, + "allowed_groups": allowed_groups, + } + + +def _read_feature_policy(feature_key: str) -> Dict[str, Any]: + try: + return _read_stored_feature_policy(feature_key) + except Exception: + return _default_feature_policy_doc(feature_key) + + +def _read_stored_feature_policy(feature_key: str) -> Dict[str, Any]: + policy_id = f"feature:{feature_key}" + return cosmos_governance_policies_container.read_item(item=policy_id, partition_key=policy_id) + + +def _read_item_policy(entity_type: str, item_id: str) -> Dict[str, Any]: + normalized_item_id = str(item_id or "").strip() + if not normalized_item_id: + return _default_item_policy_doc(entity_type, normalized_item_id) + + try: + return _read_stored_item_policy(entity_type, normalized_item_id) + except Exception: + return _default_item_policy_doc(entity_type, normalized_item_id) + + +def _read_stored_item_policy(entity_type: str, item_id: str) -> Dict[str, Any]: + normalized_item_id = str(item_id or "").strip() + policy_id = f"item:{entity_type}:{normalized_item_id}" + return cosmos_governance_item_policies_container.read_item(item=policy_id, partition_key=policy_id) + + +def get_feature_policy(feature_key: str) -> Dict[str, Any]: + normalized_feature_key = str(feature_key or "").strip() + + def load_policy() -> Dict[str, Any]: + policy = dict(_read_feature_policy(normalized_feature_key)) + normalized = _normalize_policy_state(policy) + policy.update(normalized) + return policy + + return _get_cached_governance_value(("feature_policy", normalized_feature_key), load_policy) + + +def get_item_policy(entity_type: str, item_id: str) -> Dict[str, Any]: + normalized_entity_type = str(entity_type or "").strip() + normalized_item_id = str(item_id or "").strip() + + def load_policy() -> Dict[str, Any]: + policy = dict(_read_item_policy(normalized_entity_type, normalized_item_id)) + normalized = _normalize_policy_state(policy) + policy.update(normalized) + return policy + + return _get_cached_governance_value( + ("item_policy", normalized_entity_type, normalized_item_id), + load_policy, + ) + + +def _build_diff(before_doc: Dict[str, Any], after_doc: Dict[str, Any]) -> Dict[str, Any]: + before_users = set(_normalize_str_list(before_doc.get("allowed_users", []))) + after_users = set(_normalize_str_list(after_doc.get("allowed_users", []))) + before_groups = set(_normalize_str_list(before_doc.get("allowed_groups", []))) + after_groups = set(_normalize_str_list(after_doc.get("allowed_groups", []))) + + return { + "allow_all": { + "before": bool(before_doc.get("allow_all", True)), + "after": bool(after_doc.get("allow_all", True)), + }, + "users_added": sorted(list(after_users - before_users)), + "users_removed": sorted(list(before_users - after_users)), + "groups_added": sorted(list(after_groups - before_groups)), + "groups_removed": sorted(list(before_groups - after_groups)), + } + + +def upsert_feature_policy( + feature_key: str, + payload: Dict[str, Any], + actor_user_id: str, + actor_email: str, +) -> Dict[str, Any]: + before_policy = _read_feature_policy(feature_key) + policy_id = f"feature:{feature_key}" + normalized_payload = _normalize_policy_state(payload) + after_policy = { + "id": policy_id, + "feature_key": feature_key, + "allow_all": normalized_payload["allow_all"], + "allowed_users": normalized_payload["allowed_users"], + "allowed_groups": normalized_payload["allowed_groups"], + "updated_by": str(actor_user_id or "").strip(), + "updated_at": datetime.utcnow().isoformat(), + } + + stored = cosmos_governance_policies_container.upsert_item(body=after_policy) + invalidate_governance_cache() + + log_governance_change( + admin_user_id=str(actor_user_id or "").strip(), + admin_email=str(actor_email or "").strip(), + action="feature_policy_upsert", + scope="feature", + target_id=feature_key, + before_state=before_policy, + after_state=stored, + change_details=_build_diff(before_policy, stored), + ) + + return stored + + +def upsert_item_policy( + entity_type: str, + item_id: str, + payload: Dict[str, Any], + actor_user_id: str, + actor_email: str, +) -> Dict[str, Any]: + normalized_item_id = str(item_id or "").strip() + before_policy = _read_item_policy(entity_type, normalized_item_id) + policy_id = f"item:{entity_type}:{normalized_item_id}" + normalized_payload = _normalize_policy_state(payload) + + after_policy = { + "id": policy_id, + "entity_type": str(entity_type or "").strip(), + "item_id": normalized_item_id, + "allow_all": normalized_payload["allow_all"], + "allowed_users": normalized_payload["allowed_users"], + "allowed_groups": normalized_payload["allowed_groups"], + "updated_by": str(actor_user_id or "").strip(), + "updated_at": datetime.utcnow().isoformat(), + } + + stored = cosmos_governance_item_policies_container.upsert_item(body=after_policy) + invalidate_governance_cache() + + log_governance_change( + admin_user_id=str(actor_user_id or "").strip(), + admin_email=str(actor_email or "").strip(), + action="item_policy_upsert", + scope=entity_type, + target_id=normalized_item_id, + before_state=before_policy, + after_state=stored, + change_details=_build_diff(before_policy, stored), + ) + + return stored + + +def delete_item_policy( + entity_type: str, + item_id: str, + actor_user_id: str, + actor_email: str, +) -> Dict[str, Any]: + normalized_entity_type = str(entity_type or "").strip() + normalized_item_id = str(item_id or "").strip() + policy_id = f"item:{normalized_entity_type}:{normalized_item_id}" + before_policy = _read_stored_item_policy(normalized_entity_type, normalized_item_id) + + cosmos_governance_item_policies_container.delete_item( + item=policy_id, + partition_key=policy_id, + ) + invalidate_governance_cache() + + after_policy = _default_item_policy_doc(normalized_entity_type, normalized_item_id) + log_governance_change( + admin_user_id=str(actor_user_id or "").strip(), + admin_email=str(actor_email or "").strip(), + action="item_policy_delete", + scope=normalized_entity_type, + target_id=normalized_item_id, + before_state=before_policy, + after_state=after_policy, + change_details=_build_diff(before_policy, after_policy), + ) + + return before_policy + + +def list_feature_policies() -> List[Dict[str, Any]]: + query = "SELECT * FROM c" + rows = list(cosmos_governance_policies_container.query_items(query=query, enable_cross_partition_query=True)) + + rows_by_feature_key = { + str(row.get("feature_key") or "").strip(): row + for row in rows + if isinstance(row, dict) and str(row.get("feature_key") or "").strip() + } + + normalized_rows = [] + seen_feature_keys = set() + + for feature_key in DEFAULT_FEATURE_POLICIES.keys(): + row = rows_by_feature_key.get(feature_key) or _default_feature_policy_doc(feature_key) + normalized_row = dict(row) + normalized_row["allowed_users"] = _normalize_str_list(normalized_row.get("allowed_users", [])) + normalized_row["allowed_groups"] = _normalize_str_list(normalized_row.get("allowed_groups", [])) + normalized_row["allow_all"] = bool(normalized_row.get("allow_all", True)) + normalized_rows.append(normalized_row) + + seen_feature_keys.add(feature_key) + + for row in rows: + feature_key = str((row or {}).get("feature_key") or "").strip() + if not feature_key or feature_key in seen_feature_keys: + continue + normalized_row = dict(row) + normalized_row["allowed_users"] = _normalize_str_list(normalized_row.get("allowed_users", [])) + normalized_row["allowed_groups"] = _normalize_str_list(normalized_row.get("allowed_groups", [])) + normalized_row["allow_all"] = bool(normalized_row.get("allow_all", True)) + normalized_rows.append(normalized_row) + seen_feature_keys.add(feature_key) + + return sorted(normalized_rows, key=lambda item: str(item.get("feature_key") or "")) + + +def list_item_policies(entity_type: Optional[str] = None) -> List[Dict[str, Any]]: + if entity_type: + query = "SELECT * FROM c WHERE c.entity_type = @entity_type" + parameters = [{"name": "@entity_type", "value": entity_type}] + rows = list( + cosmos_governance_item_policies_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + ) + ) + else: + query = "SELECT * FROM c" + rows = list( + cosmos_governance_item_policies_container.query_items( + query=query, + enable_cross_partition_query=True, + ) + ) + + normalized_rows = [] + for row in rows: + normalized_row = dict(row) + normalized_row.update(_normalize_policy_state(normalized_row)) + normalized_rows.append(normalized_row) + return sorted( + normalized_rows, + key=lambda item: (str(item.get("entity_type") or ""), str(item.get("item_id") or "")), + ) + + +def get_user_governance_group_ids(user_id: str) -> Set[str]: + normalized_user_id = str(user_id or "").strip() + if not normalized_user_id: + return set() + + def load_group_ids() -> Set[str]: + group_ids = set() + + try: + user_groups = get_user_groups(normalized_user_id) + group_ids.update(_extract_group_ids(user_groups)) + except Exception: + pass + + try: + user_workspaces = get_user_public_workspaces(normalized_user_id) + group_ids.update(_extract_workspace_ids(user_workspaces)) + except Exception: + pass + + return group_ids + + return _get_cached_governance_value(("user_governance_group_ids", normalized_user_id), load_group_ids) + + +def _passes_policy(policy: Dict[str, Any], user_id: str, group_ids: Set[str]) -> bool: + if bool(policy.get("allow_all", True)): + return True + + allowed_users = set(_normalize_str_list(policy.get("allowed_users", []))) + allowed_groups = set(_normalize_str_list(policy.get("allowed_groups", []))) + + if not allowed_users and not allowed_groups: + return True + + normalized_user_id = str(user_id or "").strip() + if normalized_user_id and normalized_user_id in allowed_users: + return True + + if group_ids.intersection(allowed_groups): + return True + + return False + + +def ensure_governance_access( + feature_key: str, + user_id: str, + item_entity_type: Optional[str] = None, + item_id: Optional[str] = None, +) -> None: + normalized_feature_key = str(feature_key or "").strip() + normalized_user_id = str(user_id or "").strip() + normalized_item_entity_type = str(item_entity_type or "").strip() + normalized_item_id = str(item_id or "").strip() + decision_key = ( + "governance_access_decision", + normalized_feature_key, + normalized_user_id, + normalized_item_entity_type, + normalized_item_id, + ) + + cached_decision = _get_request_cache_value(decision_key) + if cached_decision is True: + return + + settings = _get_request_cached_governance_value(("settings",), get_settings) + if not bool((settings or {}).get(normalized_feature_key, False)): + _set_request_cache_value(decision_key, True) + return + + user_group_ids = get_user_governance_group_ids(normalized_user_id) + + feature_policy = get_feature_policy(normalized_feature_key) + if not _passes_policy(feature_policy, normalized_user_id, user_group_ids): + raise PermissionError(f"Governance policy blocks access for feature '{feature_key}'.") + + if normalized_item_entity_type and normalized_item_id: + item_policy = get_item_policy(normalized_item_entity_type, normalized_item_id) + if not _passes_policy(item_policy, normalized_user_id, user_group_ids): + raise PermissionError( + f"Governance policy blocks access to {item_entity_type} '{item_id}'." + ) + + _set_request_cache_value(decision_key, True) + + +def is_governance_access_allowed( + feature_key: str, + user_id: str, + item_entity_type: Optional[str] = None, + item_id: Optional[str] = None, +) -> bool: + try: + ensure_governance_access(feature_key, user_id, item_entity_type, item_id) + return True + except PermissionError: + return False + + +def filter_governed_model_endpoints( + user_id: str, + endpoints: Any, + feature_key: str, +) -> List[Dict[str, Any]]: + if not is_governance_access_allowed(feature_key, user_id): + return [] + + governed_endpoints = [] + for endpoint in endpoints or []: + if not isinstance(endpoint, dict): + continue + + endpoint_id = str(endpoint.get("id") or "").strip() + if endpoint_id and not is_governance_access_allowed( + feature_key, + user_id, + item_entity_type="endpoint", + item_id=endpoint_id, + ): + continue + + governed_endpoints.append(endpoint) + + return governed_endpoints + + +def bootstrap_default_feature_policies() -> None: + created_defaults = False + for feature_key in DEFAULT_FEATURE_POLICIES.keys(): + try: + _read_stored_feature_policy(feature_key) + continue + except Exception: + pass + + default_policy = _default_feature_policy_doc(feature_key) + cosmos_governance_policies_container.upsert_item(body=default_policy) + created_defaults = True + + if created_defaults: + invalidate_governance_cache() diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py index 450d34e59..7ed0685b1 100644 --- a/application/single_app/functions_group_actions.py +++ b/application/single_app/functions_group_actions.py @@ -17,6 +17,7 @@ keyvault_plugin_get_helper, keyvault_plugin_save_helper, ) +from functions_governance import ensure_governance_access _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -84,6 +85,9 @@ def get_group_action( def save_group_action(group_id: str, action_data: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: """Create or update a group action entry.""" + if user_id: + ensure_governance_access('governance_group_actions', user_id) + payload = dict(action_data) action_id = payload.get("id") or str(uuid.uuid4()) diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py index ae79309bc..ec8216e35 100644 --- a/application/single_app/functions_group_agents.py +++ b/application/single_app/functions_group_agents.py @@ -17,6 +17,7 @@ keyvault_agent_save_helper, ) from functions_agent_payload import sanitize_agent_payload +from functions_governance import ensure_governance_access _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -65,6 +66,9 @@ def get_group_agent(group_id: str, agent_id: str) -> Optional[Dict[str, Any]]: def save_group_agent(group_id: str, agent_data: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: """Create or update a group agent entry.""" + if user_id: + ensure_governance_access('governance_group_agents', user_id) + payload = sanitize_agent_payload(agent_data) agent_id = payload.get("id") or str(uuid.uuid4()) payload["id"] = agent_id diff --git a/application/single_app/functions_personal_actions.py b/application/single_app/functions_personal_actions.py index 56d5e36fb..e67ca889d 100644 --- a/application/single_app/functions_personal_actions.py +++ b/application/single_app/functions_personal_actions.py @@ -16,6 +16,7 @@ from functions_debug import debug_print from config import cosmos_personal_actions_container import logging +from functions_governance import ensure_governance_access def get_personal_actions(user_id, return_type=SecretReturnType.TRIGGER): """ @@ -107,6 +108,7 @@ def save_personal_action(user_id, action_data): dict: Saved action data with ID """ try: + ensure_governance_access('governance_user_actions', user_id) # Check if an action with this name already exists existing_action = None if action_data.get('id'): diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index d6720d103..63fc38528 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -20,6 +20,7 @@ from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper from functions_agent_payload import sanitize_agent_payload from functions_debug import debug_print +from functions_governance import ensure_governance_access def get_personal_agents(user_id): """ @@ -118,6 +119,7 @@ def save_personal_agent(user_id, agent_data, actor_user_id=None): dict: Saved agent data with ID """ try: + ensure_governance_access('governance_user_agents', user_id) modifying_user_id = actor_user_id or user_id cleaned_agent = sanitize_agent_payload(agent_data) for field in ['name', 'display_name', 'description', 'instructions']: diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 0091d0a38..3bbc5cc34 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -105,6 +105,15 @@ def get_settings(use_cosmos=False, include_source=False): 'allow_group_agents': False, 'allow_group_custom_endpoints': False, 'allow_group_custom_agent_endpoints': False, + 'governance_user_endpoints': False, + 'governance_group_endpoints': False, + 'governance_global_endpoints': False, + 'governance_user_agents': False, + 'governance_group_agents': False, + 'governance_global_agents_usage': False, + 'governance_user_actions': False, + 'governance_group_actions': False, + 'governance_global_actions_usage': False, 'allow_ai_foundry_agents': False, 'allow_group_ai_foundry_agents': False, 'allow_personal_ai_foundry_agents': False, diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 561f1d181..db32c00ba 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -42,10 +42,29 @@ log_agent_deletion, log_general_admin_action, ) +from functions_governance import ensure_governance_access, upsert_item_policy bpa = Blueprint('admin_agents', __name__) +def _is_agent_allowed_for_user_selection(user_id, agent): + try: + if agent.get('is_global'): + ensure_governance_access( + 'governance_global_agents_usage', + user_id, + item_entity_type='global_agent', + item_id=str(agent.get('id') or agent.get('name') or ''), + ) + elif agent.get('is_group'): + ensure_governance_access('governance_group_agents', user_id) + else: + ensure_governance_access('governance_user_agents', user_id) + return True + except PermissionError: + return False + + def _build_user_selectable_agents(user_id, requested_agent=None): """Build the set of agents the current user is allowed to select.""" settings = get_settings() @@ -545,6 +564,8 @@ def get_user_agents(): agent['is_group'] = False agent.setdefault('agent_type', 'local') + agents = [agent for agent in agents if _is_agent_allowed_for_user_selection(user_id, agent)] + # Check global/merge toggles per_user = settings.get('per_user_semantic_kernel', False) merge_global = settings.get('merge_global_semantic_kernel_with_workspace', False) @@ -556,6 +577,8 @@ def get_user_agents(): agent['is_global'] = True agent['is_group'] = False agent.setdefault('agent_type', 'local') + + global_agents = [agent for agent in global_agents if _is_agent_allowed_for_user_selection(user_id, agent)] # Merge agents using ID as key to avoid name conflicts # This allows both personal and global agents with same name to coexist @@ -626,6 +649,11 @@ def set_user_agents(): current_agent_names = set(agent['name'] for agent in current_agents) # Save new/updated agents to personal_agents container + try: + ensure_governance_access('governance_user_agents', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + for agent in filtered_agents: save_personal_agent(user_id, agent) @@ -714,6 +742,12 @@ def get_group_agents_route(): return jsonify({'error': str(exc)}), 403 agents = get_group_agents(active_group) + for agent in agents: + if isinstance(agent, dict): + agent['is_global'] = False + agent['is_group'] = True + agent['group_id'] = active_group + agents = [agent for agent in agents if isinstance(agent, dict) and _is_agent_allowed_for_user_selection(user_id, agent)] return jsonify({'agents': agents}), 200 @@ -789,6 +823,8 @@ def create_group_agent_route(): try: saved = save_group_agent(active_group, cleaned_payload, user_id=user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as exc: debug_print('Failed to save group agent: %s', exc) return jsonify({'error': 'Unable to save agent'}), 500 @@ -858,6 +894,8 @@ def update_group_agent_route(agent_id): try: saved = save_group_agent(active_group, cleaned_payload, user_id=user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as exc: debug_print('Failed to update group agent %s: %s', agent_id, exc) return jsonify({'error': 'Unable to update agent'}), 500 @@ -917,6 +955,21 @@ def set_user_selected_agent(): if not matched_agent: return jsonify({'error': 'Selected agent is not available for this user or scope.'}), 400 + try: + if matched_agent.get('is_global'): + ensure_governance_access( + 'governance_global_agents_usage', + user_id, + item_entity_type='global_agent', + item_id=str(matched_agent.get('id') or matched_agent.get('name') or ''), + ) + elif matched_agent.get('is_group'): + ensure_governance_access('governance_group_agents', user_id) + else: + ensure_governance_access('governance_user_agents', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + user_settings = get_user_settings(user_id) settings_to_update = user_settings.get('settings', {}) agent_name = (matched_agent.get('name') or '').strip() @@ -1201,6 +1254,8 @@ def add_agent(): except AgentPayloadError as exc: log_event("Add agent failed: payload error", level=logging.WARNING, extra={"action": "add", "error": str(exc)}) return jsonify({'error': str(exc)}), 400 + + governance_policy_payload = cleaned_agent.pop('governance_policy', None) cleaned_agent['is_global'] = True cleaned_agent['is_group'] = False validation_error = validate_agent(cleaned_agent) @@ -1224,6 +1279,15 @@ def add_agent(): if not result: return jsonify({'error': 'Failed to save agent.'}), 500 + if isinstance(governance_policy_payload, dict): + upsert_item_policy( + entity_type='global_agent', + item_id=str(cleaned_agent.get('id') or ''), + payload=governance_policy_payload, + actor_user_id=str(get_current_user_id() or ''), + actor_email=str((session.get('user') or {}).get('email') or ''), + ) + log_agent_creation(user_id=str(get_current_user_id()), agent_id=cleaned_agent.get('id', ''), agent_name=cleaned_agent.get('name', ''), agent_display_name=cleaned_agent.get('display_name', ''), scope='global') log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- @@ -1307,6 +1371,8 @@ def edit_agent(agent_name): except AgentPayloadError as exc: log_event("Edit agent failed: payload error", level=logging.WARNING, extra={"action": "edit", "agent_name": agent_name, "error": str(exc)}) return jsonify({'error': str(exc)}), 400 + + governance_policy_payload = cleaned_agent.pop('governance_policy', None) cleaned_agent['is_global'] = True cleaned_agent['is_group'] = False validation_error = validate_agent(cleaned_agent) @@ -1336,6 +1402,15 @@ def edit_agent(agent_name): if not result: return jsonify({'error': 'Failed to save agent.'}), 500 + if isinstance(governance_policy_payload, dict): + upsert_item_policy( + entity_type='global_agent', + item_id=str(cleaned_agent.get('id') or ''), + payload=governance_policy_payload, + actor_user_id=str(get_current_user_id() or ''), + actor_email=str((session.get('user') or {}).get('email') or ''), + ) + log_agent_update(user_id=str(get_current_user_id()), agent_id=cleaned_agent.get('id', ''), agent_name=agent_name, agent_display_name=cleaned_agent.get('display_name', ''), scope='global') log_event( f"Agent {agent_name} edited", @@ -1457,20 +1532,28 @@ def build_combined_model_endpoints(settings, user_id=None, group_id=None): if group_id: if allow_group_custom_endpoints: - group_endpoints = get_group_model_endpoints(group_id) - for endpoint in group_endpoints: - enriched = dict(endpoint) - enriched["scope"] = "group" - enriched["group_id"] = group_id - endpoints.append(enriched) + try: + ensure_governance_access("governance_group_endpoints", user_id) + group_endpoints = get_group_model_endpoints(group_id) + for endpoint in group_endpoints: + enriched = dict(endpoint) + enriched["scope"] = "group" + enriched["group_id"] = group_id + endpoints.append(enriched) + except PermissionError: + pass elif user_id: if allow_user_custom_endpoints: - user_settings = get_user_settings(user_id) - personal = user_settings.get("settings", {}).get("personal_model_endpoints", []) - for endpoint in personal: - enriched = dict(endpoint) - enriched["scope"] = "user" - endpoints.append(enriched) + try: + ensure_governance_access("governance_user_endpoints", user_id) + user_settings = get_user_settings(user_id) + personal = user_settings.get("settings", {}).get("personal_model_endpoints", []) + for endpoint in personal: + enriched = dict(endpoint) + enriched["scope"] = "user" + endpoints.append(enriched) + except PermissionError: + pass return sanitize_model_endpoints_for_frontend(endpoints) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 4d31db45f..6f67da267 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -39,6 +39,7 @@ from functions_conversation_metadata import collect_conversation_metadata, update_conversation_with_metadata from functions_conversation_unread import mark_conversation_unread from functions_debug import debug_print +from functions_governance import ensure_governance_access from functions_notifications import create_chat_response_notification from functions_activity_logging import log_chat_activity, log_conversation_creation, log_token_usage from flask import current_app @@ -5819,41 +5820,63 @@ def get_streaming_model_endpoint_candidates(settings, user_id, active_group_ids= user_settings = user_settings_doc.get('settings', {}) if isinstance(user_settings_doc, dict) else {} if settings.get('allow_user_custom_endpoints', False): - personal_endpoints, _ = normalize_model_endpoints(user_settings.get('personal_model_endpoints', []) or []) - endpoints.extend([ - {**endpoint, '_endpoint_scope': 'user'} - for endpoint in personal_endpoints - if isinstance(endpoint, dict) - ]) + try: + ensure_governance_access('governance_user_endpoints', user_id) + personal_endpoints, _ = normalize_model_endpoints(user_settings.get('personal_model_endpoints', []) or []) + endpoints.extend([ + {**endpoint, '_endpoint_scope': 'user'} + for endpoint in personal_endpoints + if isinstance(endpoint, dict) + ]) + except PermissionError: + debug_print('[Streaming][Model Resolution] User endpoint governance policy denied access to personal endpoints.') if settings.get('allow_group_custom_endpoints', False): - seen_group_ids = set() - for group_id in active_group_ids: - group_key = str(group_id or '').strip() - if not group_key or group_key in seen_group_ids: - continue - seen_group_ids.add(group_key) + try: + ensure_governance_access('governance_group_endpoints', user_id) + seen_group_ids = set() + for group_id in active_group_ids: + group_key = str(group_id or '').strip() + if not group_key or group_key in seen_group_ids: + continue + seen_group_ids.add(group_key) - try: - group_endpoints, _ = normalize_model_endpoints(get_group_model_endpoints(group_key) or []) - except Exception as group_error: - debug_print( - f"[Streaming][Model Resolution] Failed to load group endpoints for group_id={group_key}: {group_error}" - ) - continue + try: + group_endpoints, _ = normalize_model_endpoints(get_group_model_endpoints(group_key) or []) + except Exception as group_error: + debug_print( + f"[Streaming][Model Resolution] Failed to load group endpoints for group_id={group_key}: {group_error}" + ) + continue - endpoints.extend([ - {**endpoint, '_endpoint_scope': 'group'} - for endpoint in group_endpoints - if isinstance(endpoint, dict) - ]) + endpoints.extend([ + {**endpoint, '_endpoint_scope': 'group'} + for endpoint in group_endpoints + if isinstance(endpoint, dict) + ]) + except PermissionError: + debug_print('[Streaming][Model Resolution] Group endpoint governance policy denied access to group endpoints.') - global_endpoints, _ = normalize_model_endpoints(settings.get('model_endpoints', []) or []) - endpoints.extend([ - {**endpoint, '_endpoint_scope': 'global'} - for endpoint in global_endpoints - if isinstance(endpoint, dict) - ]) + try: + ensure_governance_access('governance_global_endpoints', user_id) + global_endpoints, _ = normalize_model_endpoints(settings.get('model_endpoints', []) or []) + for endpoint in global_endpoints: + if not isinstance(endpoint, dict): + continue + endpoint_id = str(endpoint.get('id') or '').strip() + if endpoint_id: + try: + ensure_governance_access( + 'governance_global_endpoints', + user_id, + item_entity_type='endpoint', + item_id=endpoint_id, + ) + except PermissionError: + continue + endpoints.append({**endpoint, '_endpoint_scope': 'global'}) + except PermissionError: + debug_print('[Streaming][Model Resolution] Global endpoint governance policy denied access to global endpoints.') return endpoints diff --git a/application/single_app/route_backend_governance.py b/application/single_app/route_backend_governance.py new file mode 100644 index 000000000..61cf2b690 --- /dev/null +++ b/application/single_app/route_backend_governance.py @@ -0,0 +1,200 @@ +# route_backend_governance.py + +"""Admin API routes for governance policy management.""" + +from flask import jsonify, request, session + +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_feature_policies, + list_item_policies, + upsert_feature_policy, + upsert_item_policy, +) +from swagger_wrapper import get_auth_security, swagger_route + + +DEFAULT_GOVERNANCE_REVIEW_PAGE_SIZE = 25 +MAX_GOVERNANCE_REVIEW_PAGE_SIZE = 100 + + +def _normalize_actor_email() -> str: + user = session.get("user") if isinstance(session.get("user"), dict) else {} + return str(user.get("email") or "").strip() + + +def _sanitize_policy_payload(payload): + if not isinstance(payload, dict): + return { + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + } + + allowed_users = payload.get("allowed_users", []) + if not isinstance(allowed_users, list): + allowed_users = [] + + allowed_groups = payload.get("allowed_groups", []) + if not isinstance(allowed_groups, list): + allowed_groups = [] + + return { + "allow_all": bool(payload.get("allow_all", True)), + "allowed_users": allowed_users, + "allowed_groups": allowed_groups, + } + + +def _normalize_review_pagination(args): + try: + page = int(str(args.get("page") or "1").strip()) + except (TypeError, ValueError): + page = 1 + + try: + per_page = int(str(args.get("per_page") or str(DEFAULT_GOVERNANCE_REVIEW_PAGE_SIZE)).strip()) + except (TypeError, ValueError): + per_page = DEFAULT_GOVERNANCE_REVIEW_PAGE_SIZE + + page = max(page, 1) + per_page = max(1, min(per_page, MAX_GOVERNANCE_REVIEW_PAGE_SIZE)) + return page, per_page + + +def _build_item_policy_search_haystack(policy): + return " ".join([ + str(policy.get("entity_type") or ""), + str(policy.get("item_id") or ""), + str(policy.get("allow_all") or ""), + " ".join(str(value or "") for value in policy.get("allowed_users", []) if value), + " ".join(str(value or "") for value in policy.get("allowed_groups", []) if value), + ]).lower() + + +def register_route_backend_governance(app): + @app.route('/api/admin/governance/policies', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_governance_policies_route(): + bootstrap_default_feature_policies() + return jsonify({ + 'features': list_feature_policies(), + 'feature_keys': list(DEFAULT_FEATURE_POLICIES.keys()), + }), 200 + + @app.route('/api/admin/governance/policies/', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def update_governance_feature_policy_route(feature_key): + if feature_key not in DEFAULT_FEATURE_POLICIES: + return jsonify({'error': f"Unknown feature policy: {feature_key}"}), 400 + + payload = _sanitize_policy_payload(request.get_json(silent=True) or {}) + actor_user_id = str(get_current_user_id() or '').strip() + actor_email = _normalize_actor_email() + + updated = upsert_feature_policy( + feature_key=feature_key, + payload=payload, + actor_user_id=actor_user_id, + actor_email=actor_email, + ) + return jsonify({'policy': updated}), 200 + + @app.route('/api/admin/governance/item-policies//', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def delete_governance_item_policy_route(entity_type, item_id): + normalized_entity_type = str(entity_type or '').strip().lower() + normalized_item_id = str(item_id or '').strip() + if not normalized_entity_type or not normalized_item_id: + return jsonify({'error': 'entity_type and item_id are required.'}), 400 + + actor_user_id = str(get_current_user_id() or '').strip() + actor_email = _normalize_actor_email() + + try: + deleted = delete_item_policy( + entity_type=normalized_entity_type, + item_id=normalized_item_id, + actor_user_id=actor_user_id, + actor_email=actor_email, + ) + except Exception: + return jsonify({'error': 'Item governance policy not found.'}), 404 + + return jsonify({'deleted': deleted}), 200 + + @app.route('/api/admin/governance/item-policies', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_governance_item_policies_route(): + entity_type = str(request.args.get('entity_type') or '').strip() or None + return jsonify({'item_policies': list_item_policies(entity_type=entity_type)}), 200 + + @app.route('/api/admin/governance/item-policies/review', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def review_governance_item_policies_route(): + entity_type = str(request.args.get('entity_type') or '').strip().lower() or None + 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) + if search: + policies = [ + policy for policy in policies + if search in _build_item_policy_search_haystack(policy) + ] + + total_items = len(policies) + total_pages = (total_items + per_page - 1) // per_page if total_items else 1 + page = min(page, total_pages) + offset = (page - 1) * per_page + paged_policies = policies[offset:offset + per_page] + + return jsonify({ + 'item_policies': paged_policies, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total_items': total_items, + 'total_pages': total_pages, + 'has_prev': page > 1, + 'has_next': page < total_pages, + }, + 'search': search, + 'entity_type': entity_type, + }), 200 + + @app.route('/api/admin/governance/item-policies//', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def update_governance_item_policy_route(entity_type, item_id): + normalized_entity_type = str(entity_type or '').strip().lower() + normalized_item_id = str(item_id or '').strip() + if not normalized_entity_type or not normalized_item_id: + return jsonify({'error': 'entity_type and item_id are required.'}), 400 + + payload = _sanitize_policy_payload(request.get_json(silent=True) or {}) + actor_user_id = str(get_current_user_id() or '').strip() + actor_email = _normalize_actor_email() + + updated = upsert_item_policy( + entity_type=normalized_entity_type, + item_id=normalized_item_id, + payload=payload, + actor_user_id=actor_user_id, + actor_email=actor_email, + ) + return jsonify({'policy': updated}), 200 diff --git a/application/single_app/route_backend_groups.py b/application/single_app/route_backend_groups.py index 9186e4ce4..705e301ef 100644 --- a/application/single_app/route_backend_groups.py +++ b/application/single_app/route_backend_groups.py @@ -41,9 +41,10 @@ def discover_groups(): for g in all_items: name = g.get("name", "").lower() desc = g.get("description", "").lower() + group_id = str(g.get("id", "")).lower() if search_query: - if search_query not in name and search_query not in desc: + if search_query not in name and search_query not in desc and search_query not in group_id: continue if not show_all: diff --git a/application/single_app/route_backend_models.py b/application/single_app/route_backend_models.py index c047b5b00..a34178d49 100644 --- a/application/single_app/route_backend_models.py +++ b/application/single_app/route_backend_models.py @@ -4,6 +4,7 @@ from config import * from functions_authentication import * +from functions_governance import ensure_governance_access from functions_group import assert_group_role, get_group_model_endpoints, require_active_group, update_group_model_endpoints from functions_keyvault import SecretReturnType, keyvault_model_endpoint_cleanup_helper, keyvault_model_endpoint_delete_helper, keyvault_model_endpoint_get_helper, keyvault_model_endpoint_save_helper from functions_settings import * @@ -81,20 +82,60 @@ def build_group_access_error_response(user_id, exception, resource_name): def resolve_scoped_model_endpoints(user_id, scope): settings = get_settings() endpoints = [] + + def get_governed_endpoints(candidate_endpoints, feature_key, endpoint_scope): + try: + ensure_governance_access(feature_key, user_id) + except PermissionError: + return [] + + governed_endpoints = [] + for endpoint in candidate_endpoints or []: + if not isinstance(endpoint, dict): + continue + endpoint_id = str(endpoint.get("id") or "").strip() + if endpoint_id: + try: + ensure_governance_access( + feature_key, + user_id, + item_entity_type="endpoint", + item_id=endpoint_id, + ) + except PermissionError: + continue + governed_endpoint = dict(endpoint) + governed_endpoint["_governance_endpoint_scope"] = endpoint_scope + governed_endpoints.append(governed_endpoint) + return governed_endpoints + if scope == "group": if settings.get("allow_group_custom_endpoints", False): group_id = require_active_group(user_id) - endpoints.extend(get_group_model_endpoints(group_id)) + endpoints.extend(get_governed_endpoints(get_group_model_endpoints(group_id), "governance_group_endpoints", "group")) elif scope == "user": if settings.get("allow_user_custom_endpoints", False): user_settings = get_user_settings(user_id) - endpoints.extend(user_settings.get("settings", {}).get("personal_model_endpoints", [])) - endpoints.extend(settings.get("model_endpoints", []) or []) + endpoints.extend(get_governed_endpoints(user_settings.get("settings", {}).get("personal_model_endpoints", []), "governance_user_endpoints", "user")) + endpoints.extend(get_governed_endpoints(settings.get("model_endpoints", []) or [], "governance_global_endpoints", "global")) return endpoints def resolve_endpoint_by_id(user_id, scope, endpoint_id): endpoints = resolve_scoped_model_endpoints(user_id, scope) - return next((endpoint for endpoint in endpoints if endpoint.get("id") == endpoint_id), None) + endpoint = next((endpoint for endpoint in endpoints if endpoint.get("id") == endpoint_id), None) + if endpoint: + endpoint = dict(endpoint) + endpoint_scope = endpoint.pop("_governance_endpoint_scope", scope) + feature_key = "governance_global_endpoints" + if endpoint_scope in ("user", "group"): + feature_key = f"governance_{endpoint_scope}_endpoints" + ensure_governance_access( + feature_key, + user_id, + item_entity_type="endpoint", + item_id=endpoint_id, + ) + return endpoint def resolve_endpoint_scope_value(endpoint_cfg, fallback_endpoint_id=""): endpoint_id = (fallback_endpoint_id or endpoint_cfg.get("id") or "").strip() @@ -709,6 +750,9 @@ def test_model_inference_connection(): level=logging.WARNING, ) return build_safe_error_response("The selected model endpoint could not be found.", 404) + except PermissionError as e: + log_models_exception("Test connection blocked by governance policy", e, level=logging.WARNING) + return build_safe_error_response(str(e), 403) except ValueError as e: log_models_exception("Test connection validation failed", e, level=logging.WARNING) return build_safe_error_response( @@ -739,6 +783,10 @@ def fetch_model_list(): @enabled_required('allow_user_custom_endpoints') def get_user_model_endpoints(): user_id = get_current_user_id() + try: + ensure_governance_access("governance_user_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 user_settings = get_user_settings(user_id) endpoints = user_settings.get("settings", {}).get("personal_model_endpoints", []) return jsonify({ @@ -753,6 +801,10 @@ def get_user_model_endpoints(): @enabled_required('allow_user_custom_endpoints') def save_user_model_endpoints(): user_id = get_current_user_id() + try: + ensure_governance_access("governance_user_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 data = request.get_json() or {} incoming = data.get("endpoints", []) if not isinstance(incoming, list): @@ -817,6 +869,7 @@ def save_user_model_endpoints(): def get_group_model_endpoints_route(): user_id = get_current_user_id() try: + ensure_governance_access("governance_group_endpoints", user_id) group_id = require_active_group(user_id) assert_group_role( user_id, @@ -843,6 +896,10 @@ def get_group_model_endpoints_route(): @enabled_required('allow_group_custom_endpoints') def save_group_model_endpoints(): user_id = get_current_user_id() + try: + ensure_governance_access("governance_group_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 data = request.get_json() or {} incoming = data.get("endpoints", []) if not isinstance(incoming, list): @@ -932,7 +989,10 @@ def list_foundry_agents(): except PermissionError as exc: return build_group_access_error_response(user_id, exc, "group Foundry agents") - endpoint_cfg = resolve_endpoint_by_id(user_id, scope, endpoint_id) + try: + endpoint_cfg = resolve_endpoint_by_id(user_id, scope, endpoint_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 if not endpoint_cfg: return jsonify({"error": "Model endpoint not found."}), 404 endpoint_cfg = keyvault_model_endpoint_get_helper( diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index b60f81894..4db01ae21 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -3,7 +3,7 @@ import re import builtins import json -from flask import Blueprint, jsonify, request, current_app +from flask import Blueprint, jsonify, request, current_app, session from semantic_kernel_plugins.plugin_loader import get_all_plugin_metadata from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery from functions_settings import get_settings, is_tabular_processing_enabled, update_settings @@ -44,6 +44,7 @@ log_action_update, log_action_deletion, ) +from functions_governance import ensure_governance_access, upsert_item_policy def discover_plugin_types(): # Dynamically discover allowed plugin types from available plugin classes. @@ -308,8 +309,21 @@ def get_user_plugins(): if merge_global: # Import and get global actions from container global_plugins = get_global_actions() - # Mark global plugins + filtered_global_plugins = [] for plugin in global_plugins: + try: + ensure_governance_access( + 'governance_global_actions_usage', + user_id, + item_entity_type='global_action', + item_id=str(plugin.get('id') or plugin.get('name') or ''), + ) + except PermissionError: + continue + filtered_global_plugins.append(plugin) + + # Mark global plugins + for plugin in filtered_global_plugins: plugin['is_global'] = True # Merge plugins using ID as key to avoid name conflicts @@ -322,7 +336,7 @@ def get_user_plugins(): all_plugins[key] = plugin # Add global plugins - for plugin in global_plugins: + for plugin in filtered_global_plugins: key = f"global_{plugin.get('id', plugin['name'])}" all_plugins[key] = plugin @@ -336,6 +350,11 @@ def get_user_plugins(): @enabled_required("allow_user_plugins") def set_user_plugins(): user_id = get_current_user_id() + try: + ensure_governance_access('governance_user_actions', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + plugins = request.json if isinstance(request.json, list) else [] # Get global plugin names (case-insensitive) @@ -581,6 +600,8 @@ def create_group_action_route(): try: saved = save_group_action(active_group, payload, user_id=user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as exc: debug_print('Failed to save group action: %s', exc) return jsonify({'error': 'Unable to save action'}), 500 @@ -650,6 +671,8 @@ def update_group_action_route(action_id): try: saved = save_group_action(active_group, merged, user_id=user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as exc: debug_print('Failed to update group action %s: %s', action_id, exc) return jsonify({'error': 'Unable to update action'}), 500 @@ -819,9 +842,19 @@ def add_plugin(): # Assign a unique ID plugin_id = str(uuid.uuid4()) new_plugin['id'] = plugin_id + governance_policy_payload = new_plugin.pop('governance_policy', None) # Save to global actions container save_global_action(new_plugin, user_id=str(get_current_user_id())) + + if isinstance(governance_policy_payload, dict): + upsert_item_policy( + entity_type='global_action', + item_id=plugin_id, + payload=governance_policy_payload, + actor_user_id=str(get_current_user_id() or ''), + actor_email=str((session.get('user') or {}).get('email') or ''), + ) log_action_creation(user_id=str(get_current_user_id()), action_id=plugin_id, action_name=new_plugin.get('name', ''), action_type=new_plugin.get('type', ''), scope='global') log_event("Plugin added", extra={"action": "add", "plugin": _redact_plugin_for_logging(new_plugin), "user": str(get_current_user_id())}) @@ -841,6 +874,7 @@ def edit_plugin(plugin_name): try: plugins = get_global_actions() updated_plugin = request.json + governance_policy_payload = updated_plugin.pop('governance_policy', None) if isinstance(updated_plugin, dict) else None # Strict validation with dynamic allowed types allowed_types = discover_plugin_types() @@ -889,6 +923,15 @@ def edit_plugin(plugin_name): updated_plugin['id'] = str(uuid.uuid4()) save_global_action(updated_plugin, user_id=str(get_current_user_id())) + + if isinstance(governance_policy_payload, dict): + upsert_item_policy( + entity_type='global_action', + item_id=str(updated_plugin.get('id') or ''), + payload=governance_policy_payload, + actor_user_id=str(get_current_user_id() or ''), + actor_email=str((session.get('user') or {}).get('email') or ''), + ) log_action_update(user_id=str(get_current_user_id()), action_id=updated_plugin.get('id', ''), action_name=plugin_name, action_type=updated_plugin.get('type', ''), scope='global') log_event("Plugin edited", extra={"action": "edit", "plugin": _redact_plugin_for_logging(updated_plugin), "user": str(get_current_user_id())}) diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 435134c65..cd7860bec 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -5,7 +5,7 @@ from functions_authentication import * from functions_keyvault import keyvault_model_endpoint_cleanup_helper, keyvault_model_endpoint_delete_helper, keyvault_model_endpoint_save_helper, redact_model_endpoint_secret_values from functions_settings import * -from functions_activity_logging import log_web_search_consent_acceptance, log_general_admin_action +from functions_activity_logging import log_web_search_consent_acceptance, log_general_admin_action, log_governance_change from functions_notifications import broadcast_system_notification from functions_logging import * from swagger_wrapper import swagger_route, get_auth_security @@ -1112,6 +1112,15 @@ def is_valid_url(url): 'enable_agent_template_gallery': form_data.get('enable_agent_template_gallery') == 'on', 'agent_templates_allow_user_submission': form_data.get('agent_templates_allow_user_submission') == 'on', 'agent_templates_require_approval': form_data.get('agent_templates_require_approval') == 'on', + 'governance_user_endpoints': form_data.get('governance_user_endpoints') == 'on', + 'governance_group_endpoints': form_data.get('governance_group_endpoints') == 'on', + 'governance_global_endpoints': form_data.get('governance_global_endpoints') == 'on', + 'governance_user_agents': form_data.get('governance_user_agents') == 'on', + 'governance_group_agents': form_data.get('governance_group_agents') == 'on', + 'governance_global_agents_usage': form_data.get('governance_global_agents_usage') == 'on', + 'governance_user_actions': form_data.get('governance_user_actions') == 'on', + 'governance_group_actions': form_data.get('governance_group_actions') == 'on', + 'governance_global_actions_usage': form_data.get('governance_global_actions_usage') == 'on', # GPT (Direct & APIM) 'enable_gpt_apim': form_data.get('enable_gpt_apim') == 'on', @@ -1621,6 +1630,27 @@ def is_valid_url(url): flash(f"Error processing favicon file: {e}. Existing favicon preserved.", "danger") log_event(f"Error processing favicon file: {e}", level=logging.ERROR) + governance_toggle_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', + ] + governance_toggle_changes = {} + for toggle_key in governance_toggle_keys: + before_value = bool(settings.get(toggle_key, False)) + after_value = bool(new_settings.get(toggle_key, False)) + if before_value != after_value: + governance_toggle_changes[toggle_key] = { + 'before': before_value, + 'after': after_value, + } + # --- Update settings in DB --- # new_settings now contains either the new logo/favicon base64 or the original ones if update_settings(new_settings): @@ -1638,6 +1668,32 @@ def is_valid_url(url): else: print("ERROR: Could not fetch settings after update to ensure logo/favicon files.") + if governance_toggle_changes: + try: + log_governance_change( + admin_user_id=user_id, + admin_email=admin_email, + action='governance_feature_toggles_updated', + scope='feature_policy', + target_id='governance_feature_toggles', + before_state={ + key: bool(settings.get(key, False)) + for key in governance_toggle_keys + }, + after_state={ + key: bool(new_settings.get(key, False)) + for key in governance_toggle_keys + }, + change_details={ + 'changed_toggles': governance_toggle_changes + }, + ) + except Exception as governance_log_error: + log_event( + f"Failed to log governance toggle change: {governance_log_error}", + level=logging.ERROR, + ) + if chunk_size_changed: try: log_general_admin_action( diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 2679f57b4..d1b1e66c2 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -9,6 +9,7 @@ from functions_group import find_group_by_id, get_group_model_endpoints, get_user_groups from functions_group_agents import get_group_agents from functions_global_agents import get_global_agents +from functions_governance import ensure_governance_access from functions_personal_agents import ensure_migration_complete, get_personal_agents from functions_prompts import list_all_prompts_for_scope from functions_public_workspaces import find_public_workspace_by_id, get_user_visible_public_workspace_ids_from_settings @@ -46,6 +47,49 @@ def _normalize_chat_model_value(value): return str(value or '').strip() +def _is_chat_agent_allowed_by_governance(user_id, agent, scope_type): + try: + if scope_type == 'global': + ensure_governance_access( + 'governance_global_agents_usage', + user_id, + item_entity_type='global_agent', + item_id=str(agent.get('id') or agent.get('name') or ''), + ) + elif scope_type == 'group': + ensure_governance_access('governance_group_agents', user_id) + else: + ensure_governance_access('governance_user_agents', user_id) + return True + except PermissionError: + return False + + +def _filter_chat_model_endpoints_by_governance(user_id, endpoints, feature_key): + try: + ensure_governance_access(feature_key, user_id) + except PermissionError: + return [] + + allowed_endpoints = [] + for endpoint in endpoints or []: + if not isinstance(endpoint, dict): + continue + endpoint_id = str(endpoint.get('id') or '').strip() + if endpoint_id: + try: + ensure_governance_access( + feature_key, + user_id, + item_entity_type='endpoint', + item_id=endpoint_id, + ) + except PermissionError: + continue + allowed_endpoints.append(endpoint) + return allowed_endpoints + + def _build_initial_chat_model_selection(*, chat_model_options, preferred_model_id=None, preferred_model_deployment=None): scope_order = { 'global': 0, @@ -165,11 +209,16 @@ def append_models(endpoints, scope_type, scope_id=None, scope_name=None): 'scope_name': scope_name, }) - append_models(settings.get('model_endpoints', []) or [], 'global', None, 'Global') + append_models( + _filter_chat_model_endpoints_by_governance(user_id, settings.get('model_endpoints', []) or [], 'governance_global_endpoints'), + 'global', + None, + 'Global' + ) if settings.get('allow_user_custom_endpoints', False): append_models( - user_settings_dict.get('personal_model_endpoints', []) or [], + _filter_chat_model_endpoints_by_governance(user_id, user_settings_dict.get('personal_model_endpoints', []) or [], 'governance_user_endpoints'), 'personal', user_id, 'Personal' @@ -181,7 +230,7 @@ def append_models(endpoints, scope_type, scope_id=None, scope_name=None): if not group_id: continue append_models( - get_group_model_endpoints(group_id), + _filter_chat_model_endpoints_by_governance(user_id, get_group_model_endpoints(group_id), 'governance_group_endpoints'), 'group', group_id, group_doc.get('name', 'Unnamed Group') @@ -280,7 +329,8 @@ def chats(): multi_endpoint_models = [] if enable_multi_model_endpoints: - endpoints = public_settings.get("model_endpoints", []) or [] + endpoints = _filter_chat_model_endpoints_by_governance(user_id, settings.get("model_endpoints", []) or [], 'governance_global_endpoints') + endpoints = sanitize_model_endpoints_for_frontend(endpoints) for endpoint in endpoints: if not endpoint.get("enabled", True): continue @@ -326,12 +376,14 @@ def chats(): if settings.get('allow_user_agents', False): ensure_migration_complete(user_id) for agent in get_personal_agents(user_id): - chat_agent_options.append(_serialize_chat_agent_option(agent)) + if _is_chat_agent_allowed_by_governance(user_id, agent, 'personal'): + chat_agent_options.append(_serialize_chat_agent_option(agent)) merge_global = settings.get('per_user_semantic_kernel', False) and settings.get('merge_global_semantic_kernel_with_workspace', False) if merge_global: for agent in get_global_agents(): - chat_agent_options.append(_serialize_chat_agent_option(agent, is_global=True)) + if _is_chat_agent_allowed_by_governance(user_id, agent, 'global'): + chat_agent_options.append(_serialize_chat_agent_option(agent, is_global=True)) if settings.get('enable_group_workspaces', False) and settings.get('allow_group_agents', False): for group_doc in user_groups_raw: @@ -340,14 +392,15 @@ def chats(): continue group_name = group_doc.get('name', 'Unnamed Group') for agent in get_group_agents(group_id): - chat_agent_options.append( - _serialize_chat_agent_option( - agent, - is_group=True, - group_id=group_id, - group_name=group_name, + if _is_chat_agent_allowed_by_governance(user_id, agent, 'group'): + chat_agent_options.append( + _serialize_chat_agent_option( + agent, + is_group=True, + group_id=group_id, + group_name=group_name, + ) ) - ) except Exception as e: logger.warning(f"Failed to load chat agent options: {e}") diff --git a/application/single_app/route_frontend_group_workspaces.py b/application/single_app/route_frontend_group_workspaces.py index 6e3186f62..27b661853 100644 --- a/application/single_app/route_frontend_group_workspaces.py +++ b/application/single_app/route_frontend_group_workspaces.py @@ -3,6 +3,7 @@ from config import * from functions_authentication import * from functions_group import get_group_model_endpoints, require_active_group, update_active_group_for_user +from functions_governance import filter_governed_model_endpoints, is_governance_access_allowed from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -57,11 +58,19 @@ def group_workspaces(): )) allowed_extensions_str = "Allowed: " + ", ".join(allowed_extensions) + workspace_governance = { + "group_agents": is_governance_access_allowed("governance_group_agents", user_id), + "group_actions": is_governance_access_allowed("governance_group_actions", user_id), + "group_endpoints": is_governance_access_allowed("governance_group_endpoints", user_id), + "global_endpoints": is_governance_access_allowed("governance_global_endpoints", user_id), + } + + group_endpoints = get_group_model_endpoints(active_group_id) if active_group_id else [] group_model_endpoints = sanitize_model_endpoints_for_frontend( - get_group_model_endpoints(active_group_id) if active_group_id else [] + filter_governed_model_endpoints(user_id, group_endpoints, "governance_group_endpoints") ) global_model_endpoints = sanitize_model_endpoints_for_frontend( - settings.get("model_endpoints", []) + filter_governed_model_endpoints(user_id, settings.get("model_endpoints", []), "governance_global_endpoints") ) # Build allowed extensions string @@ -87,7 +96,8 @@ def group_workspaces(): legacy_docs_count=legacy_count, allowed_extensions=allowed_extensions_str, group_model_endpoints=group_model_endpoints, - global_model_endpoints=global_model_endpoints + global_model_endpoints=global_model_endpoints, + workspace_governance=workspace_governance ) @app.route('/set_active_group', methods=['POST']) diff --git a/application/single_app/route_frontend_workspace.py b/application/single_app/route_frontend_workspace.py index 98ccb65af..4bab89a42 100644 --- a/application/single_app/route_frontend_workspace.py +++ b/application/single_app/route_frontend_workspace.py @@ -2,6 +2,7 @@ from config import * from functions_authentication import * +from functions_governance import filter_governed_model_endpoints, is_governance_access_allowed from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -51,10 +52,19 @@ def workspace(): )) allowed_extensions_str = "Allowed: " + ", ".join(allowed_extensions) + workspace_governance = { + "user_agents": is_governance_access_allowed("governance_user_agents", user_id), + "user_actions": is_governance_access_allowed("governance_user_actions", user_id), + "user_endpoints": is_governance_access_allowed("governance_user_endpoints", user_id), + "global_endpoints": is_governance_access_allowed("governance_global_endpoints", user_id), + } + personal_endpoints = user_settings.get("settings", {}).get("personal_model_endpoints", []) - personal_model_endpoints = sanitize_model_endpoints_for_frontend(personal_endpoints) + personal_model_endpoints = sanitize_model_endpoints_for_frontend( + filter_governed_model_endpoints(user_id, personal_endpoints, "governance_user_endpoints") + ) global_model_endpoints = sanitize_model_endpoints_for_frontend( - settings.get("model_endpoints", []) + filter_governed_model_endpoints(user_id, settings.get("model_endpoints", []), "governance_global_endpoints") ) return render_template( @@ -68,7 +78,8 @@ def workspace(): legacy_docs_count=legacy_count, allowed_extensions=allowed_extensions_str, personal_model_endpoints=personal_model_endpoints, - global_model_endpoints=global_model_endpoints + global_model_endpoints=global_model_endpoints, + workspace_governance=workspace_governance ) \ No newline at end of file diff --git a/application/single_app/static/js/admin/admin_governance.js b/application/single_app/static/js/admin/admin_governance.js new file mode 100644 index 000000000..49a0ee7a6 --- /dev/null +++ b/application/single_app/static/js/admin/admin_governance.js @@ -0,0 +1,2595 @@ +// admin_governance.js + +const GOVERNANCE_FEATURE_LABELS = { + governance_user_endpoints: 'User Endpoints', + governance_group_endpoints: 'Group Endpoints', + governance_global_endpoints: 'Global Endpoints', + governance_user_agents: 'User Agents', + governance_group_agents: 'Group Agents', + governance_global_agents_usage: 'Global Agent Usage', + governance_user_actions: 'User Actions', + governance_group_actions: 'Group Actions', + governance_global_actions_usage: 'Global Action Usage', +}; + +const GOVERNANCE_ITEM_ENTITY_LABELS = { + endpoint: 'Endpoint', + global_agent: 'Global Agent', + global_action: 'Global Action', +}; + +const GOVERNANCE_ITEM_LOOKUP_HINTS = { + endpoint: 'Select an endpoint configured in Admin Settings.', + global_agent: 'Select a global agent available for delegation.', + global_action: 'Select a global action available for delegation.', +}; + +const GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT = 50; +const GOVERNANCE_ALLOWLIST_PAGE_SIZES = [10, 25, 50, 100]; +const GOVERNANCE_ALLOWLIST_TRUNCATE_ID_LENGTH = 35; + +const GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE = 25; + +const governanceItemReviewState = { + search: '', + entityType: '', + page: 1, + perPage: GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE, +}; + +let governanceItemReviewModal = null; +let governanceAllowListEditorModal = null; +let governanceAllowListEditorContext = null; +let governanceItemPolicyDeleteModal = null; +let governanceItemPolicyDeleteContext = null; +const governanceItemLookupState = { + endpoint: [], + global_agent: [], + global_action: [], +}; + +const governanceAllowListSelectionViewState = { + users: { + search: '', + page: 1, + pageSize: GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT, + }, + groups: { + search: '', + page: 1, + pageSize: GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT, + }, +}; + +const governanceAllowListDisplayNameCache = { + users: {}, + groups: {}, +}; + +const governanceAllowListHydrationState = { + users: new Set(), + groups: new Set(), +}; + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function splitPrincipalList(value) { + if (!value) { + return []; + } + + return String(value) + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +function joinPrincipalList(values) { + if (!Array.isArray(values) || values.length === 0) { + return ''; + } + + return values.join(', '); +} + +function parseCsvPrincipalLines(csvText) { + if (!csvText) { + return []; + } + + return String(csvText) + .split(/\r?\n/) + .flatMap((line) => line.split(',')) + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function uniquePrincipalList(values) { + return Array.from(new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter((value) => value))); +} + +function buildAllowListSummary(users, groups) { + const usersCount = Array.isArray(users) ? users.length : 0; + const groupsCount = Array.isArray(groups) ? groups.length : 0; + if (usersCount === 0 && groupsCount === 0) { + return 'No explicit users or groups configured'; + } + return `${usersCount} user${usersCount === 1 ? '' : 's'}, ${groupsCount} group${groupsCount === 1 ? '' : 's'}`; +} + +function getGovernanceUsersInputForFeatureRow(row) { + return row?.querySelector('.governance-allowed-users') || null; +} + +function getGovernanceGroupsInputForFeatureRow(row) { + return row?.querySelector('.governance-allowed-groups') || null; +} + +function getGovernanceFeatureAllowAllInput(row) { + return row?.querySelector('.governance-allow-all') || null; +} + +function getItemAllowAllInput() { + return document.getElementById('governance-item-allow-all'); +} + +function getItemUsersInput() { + return document.getElementById('governance-item-users'); +} + +function getItemGroupsInput() { + return document.getElementById('governance-item-groups'); +} + +function getItemEntityTypeInput() { + return document.getElementById('governance-item-entity-type'); +} + +function getItemIdInput() { + return document.getElementById('governance-item-id'); +} + +function setGovernanceItemLookupStatus(message, level = 'muted') { + const status = document.getElementById('governance-item-id-status'); + if (!status) { + return; + } + + status.classList.remove('text-muted', 'text-success', 'text-warning', 'text-danger'); + const className = { + muted: 'text-muted', + success: 'text-success', + warning: 'text-warning', + danger: 'text-danger', + }[level] || 'text-muted'; + status.classList.add(className); + status.textContent = message || ''; +} + +function normalizeGovernanceLookupOption(option, fallbackLabelPrefix) { + const value = String(option?.value || option?.id || '').trim(); + if (!value) { + return null; + } + + const label = String(option?.label || option?.name || option?.display_name || `${fallbackLabelPrefix} ${value}`).trim(); + const subtitle = String(option?.subtitle || option?.description || '').trim(); + return { + value, + label, + subtitle, + }; +} + +function buildGovernanceItemLookupOption(option) { + const label = option.subtitle ? `${option.label} (${option.subtitle})` : option.label; + const element = document.createElement('option'); + element.value = option.value; + element.textContent = label; + return element; +} + +function getAdminEndpointLookupOptionsFromWindow() { + const fromWindow = Array.isArray(window.modelEndpoints) ? window.modelEndpoints : []; + const fromHiddenInputRaw = document.getElementById('model_endpoints_json')?.value || '[]'; + + let fromHiddenInput = []; + try { + const parsed = JSON.parse(fromHiddenInputRaw); + fromHiddenInput = Array.isArray(parsed) ? parsed : []; + } catch (error) { + fromHiddenInput = []; + } + + const merged = [...fromWindow, ...fromHiddenInput]; + return merged + .map((endpoint) => normalizeGovernanceLookupOption({ + value: endpoint?.id, + label: endpoint?.name || endpoint?.id, + subtitle: endpoint?.connection?.endpoint || endpoint?.endpoint || '', + }, 'Endpoint')) + .filter((endpoint) => endpoint !== null) + .filter((endpoint, index, arr) => arr.findIndex((candidate) => candidate.value === endpoint.value) === index); +} + +async function fetchAdminGlobalAgentLookupOptions() { + const response = await fetch('/api/admin/agents', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to load global agents lookup.'); + } + + const payload = await response.json(); + return (Array.isArray(payload) ? payload : []) + .map((agent) => normalizeGovernanceLookupOption({ + value: agent?.id, + label: agent?.display_name || agent?.name || agent?.id, + subtitle: agent?.name && agent?.display_name && agent?.display_name !== agent?.name ? agent?.name : '', + }, 'Agent')) + .filter((option) => option !== null); +} + +async function fetchAdminGlobalActionLookupOptions() { + const response = await fetch('/api/admin/plugins', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to load global actions lookup.'); + } + + const payload = await response.json(); + return (Array.isArray(payload) ? payload : []) + .map((action) => normalizeGovernanceLookupOption({ + value: action?.id, + label: action?.name || action?.id, + subtitle: action?.type || '', + }, 'Action')) + .filter((option) => option !== null); +} + +async function loadGovernanceItemLookup(entityType, forceReload = false) { + const normalizedEntityType = String(entityType || '').trim(); + if (!normalizedEntityType) { + return []; + } + + if (!forceReload && Array.isArray(governanceItemLookupState[normalizedEntityType]) && governanceItemLookupState[normalizedEntityType].length > 0) { + return governanceItemLookupState[normalizedEntityType]; + } + + if (normalizedEntityType === 'endpoint') { + governanceItemLookupState.endpoint = getAdminEndpointLookupOptionsFromWindow(); + return governanceItemLookupState.endpoint; + } + if (normalizedEntityType === 'global_agent') { + governanceItemLookupState.global_agent = await fetchAdminGlobalAgentLookupOptions(); + return governanceItemLookupState.global_agent; + } + if (normalizedEntityType === 'global_action') { + governanceItemLookupState.global_action = await fetchAdminGlobalActionLookupOptions(); + return governanceItemLookupState.global_action; + } + + return []; +} + +function renderGovernanceItemLookupOptions(entityType, preferredValue = '') { + const itemIdInput = getItemIdInput(); + if (!itemIdInput) { + return; + } + + const options = Array.isArray(governanceItemLookupState[entityType]) ? governanceItemLookupState[entityType] : []; + const currentValue = String(preferredValue || '').trim(); + + itemIdInput.innerHTML = ''; + + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = options.length > 0 ? 'Select an item' : 'No items available'; + itemIdInput.appendChild(placeholder); + + options.forEach((option) => { + itemIdInput.appendChild(buildGovernanceItemLookupOption(option)); + }); + + if (currentValue && options.some((option) => option.value === currentValue)) { + itemIdInput.value = currentValue; + } else { + itemIdInput.value = ''; + } + + const hint = GOVERNANCE_ITEM_LOOKUP_HINTS[entityType] || 'Select a delegated item.'; + if (options.length > 0) { + setGovernanceItemLookupStatus(`${hint} Loaded ${options.length} item${options.length === 1 ? '' : 's'}.`, 'muted'); + } else { + setGovernanceItemLookupStatus(`${hint} No items found for this type.`, 'warning'); + } +} + +async function refreshGovernanceItemLookup(entityType, forceReload = false, preferredValue = '') { + const refreshButton = document.getElementById('governance-item-id-refresh-btn'); + if (refreshButton) { + refreshButton.disabled = true; + } + + try { + await loadGovernanceItemLookup(entityType, forceReload); + renderGovernanceItemLookupOptions(entityType, preferredValue); + } catch (error) { + renderGovernanceItemLookupOptions(entityType, ''); + setGovernanceItemLookupStatus(error.message || 'Failed to load delegated item lookup.', 'danger'); + } finally { + if (refreshButton) { + refreshButton.disabled = false; + } + } +} + +function updateFeatureAllowListSummary(row) { + const usersInput = getGovernanceUsersInputForFeatureRow(row); + const groupsInput = getGovernanceGroupsInputForFeatureRow(row); + const summaryEl = row?.querySelector('.governance-allowlist-summary'); + if (!usersInput || !groupsInput || !summaryEl) { + return; + } + + const users = splitPrincipalList(usersInput.value); + const groups = splitPrincipalList(groupsInput.value); + summaryEl.textContent = buildAllowListSummary(users, groups); +} + +function updateItemAllowListSummary() { + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + const summaryInput = document.getElementById('governance-item-allowlist-summary'); + if (!usersInput || !groupsInput || !summaryInput) { + return; + } + + summaryInput.value = buildAllowListSummary(splitPrincipalList(usersInput.value), splitPrincipalList(groupsInput.value)); +} + +function applyFeatureAllowAllUiState(row) { + const allowAllInput = getGovernanceFeatureAllowAllInput(row); + const editButton = row?.querySelector('.governance-edit-feature-allowlist-btn'); + const usersInput = getGovernanceUsersInputForFeatureRow(row); + const groupsInput = getGovernanceGroupsInputForFeatureRow(row); + if (!allowAllInput || !usersInput || !groupsInput) { + return; + } + + if (allowAllInput.checked) { + usersInput.value = ''; + groupsInput.value = ''; + } + + if (editButton) { + editButton.disabled = allowAllInput.checked; + } + + updateFeatureAllowListSummary(row); +} + +function applyItemAllowAllUiState() { + const allowAllInput = getItemAllowAllInput(); + const editButton = document.getElementById('governance-edit-item-allowlist-btn'); + const allowedPrincipalsControls = document.getElementById('governance-item-allowed-principals-controls'); + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + if (!allowAllInput || !usersInput || !groupsInput) { + return; + } + + if (allowAllInput.checked) { + usersInput.value = ''; + groupsInput.value = ''; + } + + if (editButton) { + editButton.disabled = allowAllInput.checked; + } + + if (allowedPrincipalsControls) { + allowedPrincipalsControls.classList.toggle('d-none', allowAllInput.checked); + } + + updateItemAllowListSummary(); +} + +async function governanceLookupUsers(query) { + const response = await fetch(`/api/userSearch?query=${encodeURIComponent(String(query || '').trim())}`, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('User lookup failed.'); + } + + const payload = await response.json(); + return Array.isArray(payload) ? payload : []; +} + +async function governanceLookupGroups(query) { + const response = await fetch(`/api/groups/discover?search=${encodeURIComponent(String(query || '').trim())}&showAll=true`, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Group lookup failed.'); + } + + const payload = await response.json(); + return Array.isArray(payload) ? payload : []; +} + +// Prep hook for chat/workspace follow-up to reuse the same normalized lookup behavior. +window.governancePrincipalLookup = { + searchUsers: governanceLookupUsers, + searchGroups: governanceLookupGroups, +}; + +function buildItemPolicyEntityLabel(entityType) { + return GOVERNANCE_ITEM_ENTITY_LABELS[entityType] || entityType || ''; +} + +function mapGovernanceLevelToToastVariant(level = 'info') { + const normalized = String(level || 'info').toLowerCase(); + if (normalized === 'error') { + return 'danger'; + } + if (normalized === 'warn') { + return 'warning'; + } + return normalized; +} + +function setGovernanceInlineStatusFallback(message, level = 'info') { + const status = document.getElementById('governance-status'); + if (!status) { + return; + } + + const alertLevel = mapGovernanceLevelToToastVariant(level); + status.className = `alert alert-${alertLevel}`; + status.classList.remove('d-none'); + status.textContent = String(message || ''); +} + +function setGovernanceStatus(message, level = 'info') { + if (!message) { + return; + } + showGovernanceToast(message, mapGovernanceLevelToToastVariant(level)); +} + +function clearGovernanceStatus() { + const status = document.getElementById('governance-status'); + if (!status) { + return; + } + + status.className = 'alert d-none'; + status.textContent = ''; +} + +function showGovernanceToast(message, variant = 'success') { + const normalizedVariant = mapGovernanceLevelToToastVariant(variant); + const container = document.getElementById('toast-container'); + if (!container || typeof bootstrap?.Toast !== 'function') { + setGovernanceInlineStatusFallback(message, normalizedVariant === 'danger' ? 'danger' : normalizedVariant); + return; + } + + const toastEl = document.createElement('div'); + toastEl.className = `toast align-items-center text-bg-${normalizedVariant}`; + toastEl.setAttribute('role', 'alert'); + toastEl.setAttribute('aria-live', 'assertive'); + toastEl.setAttribute('aria-atomic', 'true'); + + const contentEl = document.createElement('div'); + contentEl.className = 'd-flex'; + + const bodyEl = document.createElement('div'); + bodyEl.className = 'toast-body'; + bodyEl.textContent = String(message || ''); + + const closeButtonEl = document.createElement('button'); + closeButtonEl.type = 'button'; + closeButtonEl.className = 'btn-close btn-close-white me-2 m-auto'; + closeButtonEl.setAttribute('data-bs-dismiss', 'toast'); + closeButtonEl.setAttribute('aria-label', 'Close'); + + contentEl.appendChild(bodyEl); + contentEl.appendChild(closeButtonEl); + toastEl.appendChild(contentEl); + container.appendChild(toastEl); + + const bsToast = new bootstrap.Toast(toastEl, { delay: 5000 }); + bsToast.show(); + + toastEl.addEventListener('hidden.bs.toast', () => { + toastEl.remove(); + }); +} + +function getGovernanceFeatureToggle(featureKey) { + const toggle = document.getElementById(featureKey); + return toggle instanceof HTMLInputElement ? toggle : null; +} + +function syncGovernanceFeaturePolicyRowVisibility(row) { + const featureKey = String(row?.dataset?.featureKey || '').trim(); + if (!row || !featureKey) { + return; + } + + const featureToggle = getGovernanceFeatureToggle(featureKey); + const shouldShow = !featureToggle || featureToggle.checked; + row.classList.toggle('d-none', !shouldShow); +} + +function syncGovernanceFeaturePolicyVisibility() { + Array.from(document.querySelectorAll('#governance-feature-policies-body tr')).forEach((row) => { + syncGovernanceFeaturePolicyRowVisibility(row); + }); +} + +function buildFeaturePolicyRow(policy) { + const row = document.createElement('tr'); + row.dataset.featureKey = policy.feature_key; + + const featureCell = document.createElement('td'); + featureCell.textContent = GOVERNANCE_FEATURE_LABELS[policy.feature_key] || policy.feature_key; + + const allowAllCell = document.createElement('td'); + const allowAll = document.createElement('input'); + allowAll.type = 'checkbox'; + allowAll.className = 'form-check-input governance-allow-all'; + allowAll.checked = Boolean(policy.allow_all); + allowAllCell.appendChild(allowAll); + + const usersCell = document.createElement('td'); + const usersInput = document.createElement('input'); + usersInput.type = 'text'; + usersInput.className = 'form-control form-control-sm governance-allowed-users d-none'; + usersInput.value = joinPrincipalList(policy.allowed_users); + usersCell.appendChild(usersInput); + + const usersSummary = document.createElement('div'); + usersSummary.className = 'small text-body-secondary governance-allowlist-summary'; + usersSummary.textContent = buildAllowListSummary(policy.allowed_users, policy.allowed_groups); + usersCell.appendChild(usersSummary); + + const usersEditButton = document.createElement('button'); + usersEditButton.type = 'button'; + usersEditButton.className = 'btn btn-sm btn-outline-primary mt-1 governance-edit-feature-allowlist-btn'; + usersEditButton.textContent = 'Edit Allow List'; + usersCell.appendChild(usersEditButton); + + const groupsCell = document.createElement('td'); + const groupsInput = document.createElement('input'); + groupsInput.type = 'text'; + groupsInput.className = 'form-control form-control-sm governance-allowed-groups d-none'; + groupsInput.value = joinPrincipalList(policy.allowed_groups); + groupsCell.appendChild(groupsInput); + + const groupsSummary = document.createElement('div'); + groupsSummary.className = 'small text-body-secondary'; + groupsSummary.textContent = 'Includes group IDs added in the editor.'; + groupsCell.appendChild(groupsSummary); + + row.appendChild(featureCell); + row.appendChild(allowAllCell); + row.appendChild(usersCell); + row.appendChild(groupsCell); + + applyFeatureAllowAllUiState(row); + syncGovernanceFeaturePolicyRowVisibility(row); + + return row; +} + +async function loadFeaturePolicies() { + const tbody = document.getElementById('governance-feature-policies-body'); + if (!tbody) { + return; + } + + const response = await fetch('/api/admin/governance/policies', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to load governance feature policies.'); + } + + const payload = await response.json(); + const featurePolicies = Array.isArray(payload.features) ? payload.features : []; + + tbody.innerHTML = ''; + featurePolicies.forEach((policy) => { + tbody.appendChild(buildFeaturePolicyRow(policy)); + }); + syncGovernanceFeaturePolicyVisibility(); +} + +async function saveFeaturePolicies() { + const rows = Array.from(document.querySelectorAll('#governance-feature-policies-body tr')); + if (rows.length === 0) { + setGovernanceStatus('No feature policies are available to save.', 'warning'); + return; + } + + for (const row of rows) { + const featureKey = row.dataset.featureKey; + const allowAllInput = row.querySelector('.governance-allow-all'); + const usersInput = row.querySelector('.governance-allowed-users'); + const groupsInput = row.querySelector('.governance-allowed-groups'); + + if (!featureKey || !allowAllInput || !usersInput || !groupsInput) { + continue; + } + + const body = { + allow_all: allowAllInput.checked, + allowed_users: splitPrincipalList(usersInput.value), + allowed_groups: splitPrincipalList(groupsInput.value), + }; + + const response = await fetch(`/api/admin/governance/policies/${encodeURIComponent(featureKey)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Failed to save policy for ${featureKey}.`); + } + } + + clearGovernanceStatus(); + showGovernanceToast('Governance feature policies saved successfully.', 'success'); +} + +function buildItemPolicyRow(policy) { + const row = document.createElement('tr'); + + const entityTypeCell = document.createElement('td'); + const entityType = String(policy.entity_type || ''); + const itemId = String(policy.item_id || ''); + const allowAll = Boolean(policy.allow_all); + const allowedUsers = Array.isArray(policy.allowed_users) ? policy.allowed_users : []; + const allowedGroups = Array.isArray(policy.allowed_groups) ? policy.allowed_groups : []; + + entityTypeCell.textContent = buildItemPolicyEntityLabel(entityType); + + const itemIdCell = document.createElement('td'); + itemIdCell.textContent = itemId; + + const allowAllCell = document.createElement('td'); + allowAllCell.textContent = allowAll ? 'Yes' : 'No'; + + const usersCell = document.createElement('td'); + renderGovernancePrincipalReviewCell(usersCell, 'users', allowedUsers); + + const groupsCell = document.createElement('td'); + renderGovernancePrincipalReviewCell(groupsCell, 'groups', allowedGroups); + + const actionsCell = document.createElement('td'); + actionsCell.className = 'text-nowrap'; + const editButton = document.createElement('button'); + editButton.type = 'button'; + editButton.className = 'btn btn-sm btn-outline-primary governance-edit-item-policy-btn'; + editButton.textContent = 'Edit'; + editButton.dataset.entityType = entityType; + editButton.dataset.itemId = itemId; + editButton.dataset.allowAll = allowAll ? 'true' : 'false'; + editButton.dataset.allowedUsers = JSON.stringify(allowedUsers); + editButton.dataset.allowedGroups = JSON.stringify(allowedGroups); + actionsCell.appendChild(editButton); + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'btn btn-sm btn-outline-danger ms-2 governance-delete-item-policy-btn'; + deleteButton.textContent = 'Delete'; + deleteButton.dataset.entityType = entityType; + deleteButton.dataset.itemId = itemId; + actionsCell.appendChild(deleteButton); + + row.appendChild(entityTypeCell); + row.appendChild(itemIdCell); + row.appendChild(allowAllCell); + row.appendChild(usersCell); + row.appendChild(groupsCell); + row.appendChild(actionsCell); + + return row; +} + +function renderGovernancePrincipalReviewCell(cell, listType, ids, hydrateMissing = true) { + if (!cell) { + return; + } + + const normalizedIds = uniquePrincipalList(ids); + cell.textContent = ''; + cell.className = 'small'; + + if (normalizedIds.length === 0) { + cell.className = 'small text-muted'; + cell.textContent = 'None'; + return; + } + + const missingIds = []; + normalizedIds.forEach((idValue) => { + const displayName = getGovernanceDisplayName(listType, idValue); + const truncatedId = truncateGovernanceId(idValue); + const wrapper = document.createElement('div'); + wrapper.className = 'mb-1'; + + const primary = document.createElement('div'); + primary.textContent = displayName || truncatedId; + primary.title = displayName || idValue; + wrapper.appendChild(primary); + + if (displayName) { + const secondary = document.createElement('div'); + secondary.className = 'text-muted'; + secondary.textContent = truncatedId; + secondary.title = idValue; + wrapper.appendChild(secondary); + } else { + missingIds.push(idValue); + } + + cell.appendChild(wrapper); + }); + + if (hydrateMissing && missingIds.length > 0) { + void hydrateGovernanceDisplayNames(listType, missingIds).then(() => { + renderGovernancePrincipalReviewCell(cell, listType, normalizedIds, false); + }).catch(() => { + renderGovernancePrincipalReviewCell(cell, listType, normalizedIds, false); + }); + } +} + +function parseGovernancePrincipalDataset(value) { + try { + const parsed = JSON.parse(value || '[]'); + return uniquePrincipalList(Array.isArray(parsed) ? parsed : []); + } catch (error) { + return []; + } +} + +function ensureGovernanceItemIdOption(itemIdInput, itemId) { + const normalizedItemId = String(itemId || '').trim(); + if (!itemIdInput || !normalizedItemId) { + return; + } + + const hasOption = Array.from(itemIdInput.options || []).some((option) => option.value === normalizedItemId); + if (!hasOption) { + const option = document.createElement('option'); + option.value = normalizedItemId; + option.textContent = normalizedItemId; + itemIdInput.appendChild(option); + } + itemIdInput.value = normalizedItemId; +} + +async function loadGovernanceItemPolicyIntoEditor(policy) { + const entityTypeInput = document.getElementById('governance-item-entity-type'); + const itemIdInput = document.getElementById('governance-item-id'); + const allowAllInput = document.getElementById('governance-item-allow-all'); + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + + if (!entityTypeInput || !itemIdInput || !allowAllInput || !usersInput || !groupsInput) { + return; + } + + const entityType = String(policy?.entity_type || '').trim(); + const itemId = String(policy?.item_id || '').trim(); + if (!entityType || !itemId) { + return; + } + + entityTypeInput.value = entityType; + await refreshGovernanceItemLookup(entityType, false, itemId); + ensureGovernanceItemIdOption(itemIdInput, itemId); + + allowAllInput.checked = Boolean(policy.allow_all); + usersInput.value = joinPrincipalList(policy.allowed_users || []); + groupsInput.value = joinPrincipalList(policy.allowed_groups || []); + + updateItemAllowListSummary(); + applyItemAllowAllUiState(); + document.getElementById('governance-item-policy-form')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + itemIdInput.focus(); +} + +function openCurrentDelegatedItemAllowListEditor() { + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + const entityTypeInput = document.getElementById('governance-item-entity-type'); + const itemIdInput = document.getElementById('governance-item-id'); + + if (!usersInput || !groupsInput) { + return; + } + + const entityType = String(entityTypeInput?.value || '').trim(); + const itemId = String(itemIdInput?.value || '').trim(); + const contextSuffix = entityType && itemId + ? ` (${GOVERNANCE_ITEM_ENTITY_LABELS[entityType] || entityType}: ${itemId})` + : ''; + + openGovernanceAllowListEditor({ + title: `Edit Delegated Item Allow List${contextSuffix}`, + description: 'Manage explicitly allowed users and groups for this delegated item policy.', + getUsers: () => splitPrincipalList(usersInput.value), + getGroups: () => splitPrincipalList(groupsInput.value), + setValues: (users, groups) => { + usersInput.value = joinPrincipalList(users); + groupsInput.value = joinPrincipalList(groups); + const allowAllInput = getItemAllowAllInput(); + if (allowAllInput) { + allowAllInput.checked = false; + } + applyItemAllowAllUiState(); + }, + }); +} + +function openCurrentDelegatedItemAllowListEditorAfterReviewModalCloses() { + const reviewModalElement = document.getElementById('governance-item-policies-review-modal'); + if (!reviewModalElement?.classList.contains('show')) { + openCurrentDelegatedItemAllowListEditor(); + return; + } + + reviewModalElement.addEventListener('hidden.bs.modal', () => { + openCurrentDelegatedItemAllowListEditor(); + }, { once: true }); + governanceItemReviewModal?.hide(); +} + +function ensureGovernanceItemPolicyDeleteModal() { + let modalElement = document.getElementById('governance-item-policy-delete-confirm-modal'); + if (!modalElement) { + const modalMarkup = ` + + `; + + const wrapper = document.createElement('div'); + wrapper.innerHTML = modalMarkup.trim(); + modalElement = wrapper.firstElementChild; + document.body.appendChild(modalElement); + } + + if (!governanceItemPolicyDeleteModal) { + governanceItemPolicyDeleteModal = bootstrap.Modal.getOrCreateInstance(modalElement); + } + + if (!modalElement.dataset.wired) { + modalElement.dataset.wired = 'true'; + const confirmButton = document.getElementById('governance-item-policy-delete-confirm-btn'); + if (confirmButton) { + confirmButton.addEventListener('click', async () => { + try { + await deleteGovernanceItemPolicyFromContext(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to delete item governance policy.', 'danger'); + } + }); + } + } + + return modalElement; +} + +function openGovernanceItemPolicyDeleteModal(entityType, itemId) { + const normalizedEntityType = String(entityType || '').trim(); + const normalizedItemId = String(itemId || '').trim(); + if (!normalizedEntityType || !normalizedItemId) { + return; + } + + governanceItemPolicyDeleteContext = { + entityType: normalizedEntityType, + itemId: normalizedItemId, + }; + + ensureGovernanceItemPolicyDeleteModal(); + const summary = document.getElementById('governance-item-policy-delete-summary'); + if (summary) { + summary.textContent = `${buildItemPolicyEntityLabel(normalizedEntityType)}: ${normalizedItemId}`; + } + governanceItemPolicyDeleteModal?.show(); +} + +async function deleteGovernanceItemPolicyFromContext() { + if (!governanceItemPolicyDeleteContext) { + return; + } + + const { entityType, itemId } = governanceItemPolicyDeleteContext; + const response = await fetch( + `/api/admin/governance/item-policies/${encodeURIComponent(entityType)}/${encodeURIComponent(itemId)}`, + { + method: 'DELETE', + headers: { + Accept: 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error('Unable to delete item governance policy.'); + } + + governanceItemPolicyDeleteModal?.hide(); + governanceItemPolicyDeleteContext = null; + + const entityTypeInput = document.getElementById('governance-item-entity-type'); + const itemIdInput = document.getElementById('governance-item-id'); + if (entityTypeInput?.value === entityType && itemIdInput?.value === itemId) { + const allowAllInput = getItemAllowAllInput(); + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + if (allowAllInput) { + allowAllInput.checked = true; + } + if (usersInput) { + usersInput.value = ''; + } + if (groupsInput) { + groupsInput.value = ''; + } + applyItemAllowAllUiState(); + } + + await loadItemPolicies(); + await loadGovernanceItemPolicyReview(); + showGovernanceToast('Delegated item policy deleted.', 'success'); +} + +function ensureGovernanceItemReviewModal() { + const existingModal = document.getElementById('governance-item-policies-review-modal'); + if (existingModal) { + wireGovernanceItemReviewHandlers(existingModal); + return existingModal; + } + + const modalMarkup = ` + + `; + + const wrapper = document.createElement('div'); + wrapper.innerHTML = modalMarkup.trim(); + const modalElement = wrapper.firstElementChild; + document.body.appendChild(modalElement); + wireGovernanceItemReviewHandlers(modalElement); + return modalElement; +} + +function wireGovernanceItemReviewHandlers(modalElement) { + if (!modalElement || modalElement.dataset.reviewWired) { + return; + } + + const itemPolicyReviewBody = modalElement.querySelector('#governance-item-policies-review-body'); + if (!itemPolicyReviewBody) { + return; + } + + modalElement.dataset.reviewWired = 'true'; + itemPolicyReviewBody.addEventListener('click', async (event) => { + const target = event.target; + const editButton = target instanceof HTMLElement ? target.closest('.governance-edit-item-policy-btn') : null; + const deleteButton = target instanceof HTMLElement ? target.closest('.governance-delete-item-policy-btn') : null; + + if (deleteButton) { + openGovernanceItemPolicyDeleteModal(deleteButton.dataset.entityType, deleteButton.dataset.itemId); + return; + } + + if (!editButton) { + return; + } + + const policy = { + entity_type: editButton.dataset.entityType || '', + item_id: editButton.dataset.itemId || '', + allow_all: editButton.dataset.allowAll === 'true', + allowed_users: parseGovernancePrincipalDataset(editButton.dataset.allowedUsers), + allowed_groups: parseGovernancePrincipalDataset(editButton.dataset.allowedGroups), + }; + + await loadGovernanceItemPolicyIntoEditor(policy); + openCurrentDelegatedItemAllowListEditorAfterReviewModalCloses(); + }); +} + +function syncGovernanceItemReviewControls() { + const searchInput = document.getElementById('governance-item-review-search'); + const entityTypeSelect = document.getElementById('governance-item-review-entity-type'); + const pageSizeSelect = document.getElementById('governance-item-review-page-size'); + + if (searchInput) { + searchInput.value = governanceItemReviewState.search; + } + if (entityTypeSelect) { + entityTypeSelect.value = governanceItemReviewState.entityType; + } + if (pageSizeSelect) { + pageSizeSelect.value = String(governanceItemReviewState.perPage); + } +} + +function renderGovernanceItemReviewRows(itemPolicies) { + const tbody = document.getElementById('governance-item-policies-review-body'); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(itemPolicies) || itemPolicies.length === 0) { + const emptyRow = document.createElement('tr'); + const emptyCell = document.createElement('td'); + emptyCell.colSpan = 6; + emptyCell.className = 'text-center text-muted'; + emptyCell.textContent = 'No delegated item policies found.'; + emptyRow.appendChild(emptyCell); + tbody.appendChild(emptyRow); + return; + } + + itemPolicies.forEach((policy) => { + tbody.appendChild(buildItemPolicyRow(policy)); + }); +} + +function updateGovernanceItemReviewSummary(pagination, totalVisible) { + const summary = document.getElementById('governance-item-review-summary'); + if (!summary) { + return; + } + + if (!pagination) { + summary.textContent = ''; + return; + } + + const currentStart = pagination.total_items === 0 ? 0 : ((pagination.page - 1) * pagination.per_page) + 1; + const currentEnd = pagination.total_items === 0 ? 0 : Math.min(pagination.page * pagination.per_page, pagination.total_items); + summary.textContent = `Showing ${currentStart}-${currentEnd} of ${pagination.total_items} configured item policy${pagination.total_items === 1 ? '' : 'ies'} (${totalVisible} on page ${pagination.page} of ${pagination.total_pages}).`; +} + +function updateGovernanceItemReviewPagination(pagination) { + const prevButton = document.getElementById('governance-item-review-prev-btn'); + const nextButton = document.getElementById('governance-item-review-next-btn'); + + if (prevButton) { + prevButton.disabled = !pagination || !pagination.has_prev; + } + if (nextButton) { + nextButton.disabled = !pagination || !pagination.has_next; + } +} + +async function loadGovernanceItemPolicyReview(page = governanceItemReviewState.page) { + const tbody = document.getElementById('governance-item-policies-review-body'); + if (!tbody) { + return; + } + + governanceItemReviewState.page = Math.max(1, Number(page) || 1); + + const params = new URLSearchParams(); + if (governanceItemReviewState.search) { + params.set('search', governanceItemReviewState.search); + } + if (governanceItemReviewState.entityType) { + params.set('entity_type', governanceItemReviewState.entityType); + } + params.set('page', String(governanceItemReviewState.page)); + params.set('per_page', String(governanceItemReviewState.perPage)); + + const response = await fetch(`/api/admin/governance/item-policies/review?${params.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to load delegated item policy review data.'); + } + + const payload = await response.json(); + const itemPolicies = Array.isArray(payload.item_policies) ? payload.item_policies : []; + renderGovernanceItemReviewRows(itemPolicies); + updateGovernanceItemReviewSummary(payload.pagination, itemPolicies.length); + updateGovernanceItemReviewPagination(payload.pagination); +} + +function openGovernanceItemReviewModal() { + const modalElement = ensureGovernanceItemReviewModal(); + if (!modalElement) { + return; + } + + if (!governanceItemReviewModal) { + governanceItemReviewModal = bootstrap.Modal.getOrCreateInstance(modalElement); + } + + governanceItemReviewState.page = 1; + syncGovernanceItemReviewControls(); + governanceItemReviewModal.show(); + loadGovernanceItemPolicyReview().catch((error) => { + setGovernanceStatus(error.message || 'Failed to load delegated item policy review.', 'danger'); + }); +} + +function ensureGovernanceAllowListEditorModal() { + let modalElement = document.getElementById('governanceAllowListEditorModal'); + if (!modalElement) { + const modalMarkup = ` + + `; + + const wrapper = document.createElement('div'); + wrapper.innerHTML = modalMarkup.trim(); + modalElement = wrapper.firstElementChild; + document.body.appendChild(modalElement); + } + + if (!governanceAllowListEditorModal) { + governanceAllowListEditorModal = bootstrap.Modal.getOrCreateInstance(modalElement); + } + + if (!modalElement.dataset.wired) { + modalElement.dataset.wired = 'true'; + wireGovernanceAllowListEditorHandlers(); + } + + return modalElement; +} + +function setGovernanceAllowListEditorStatus(message) { + const normalizedMessage = String(message || '').trim(); + if (!normalizedMessage) { + return; + } + + let variant = 'info'; + if (/failed|error|unable/i.test(normalizedMessage)) { + variant = 'danger'; + } else if (/no\s+csv|enter\s+a\s+user\s+search/i.test(normalizedMessage)) { + variant = 'warning'; + } else if (/added|removed|cleared|completed|updated/i.test(normalizedMessage)) { + variant = 'success'; + } + + showGovernanceToast(normalizedMessage, variant); +} + +function normalizeGovernanceAllowListPageSize(value) { + const parsed = Number(value) || GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT; + return GOVERNANCE_ALLOWLIST_PAGE_SIZES.includes(parsed) ? parsed : GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT; +} + +function resetGovernanceAllowListSelectionViewState() { + governanceAllowListSelectionViewState.users.search = ''; + governanceAllowListSelectionViewState.users.page = 1; + governanceAllowListSelectionViewState.users.pageSize = GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT; + + governanceAllowListSelectionViewState.groups.search = ''; + governanceAllowListSelectionViewState.groups.page = 1; + governanceAllowListSelectionViewState.groups.pageSize = GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT; +} + +function syncGovernanceAllowListSelectionControls() { + const userSearchInput = document.getElementById('governance-allowlist-selected-user-search'); + const userPageSizeSelect = document.getElementById('governance-allowlist-selected-user-page-size'); + const groupSearchInput = document.getElementById('governance-allowlist-selected-group-search'); + const groupPageSizeSelect = document.getElementById('governance-allowlist-selected-group-page-size'); + + if (userSearchInput) { + userSearchInput.value = governanceAllowListSelectionViewState.users.search; + } + if (userPageSizeSelect) { + userPageSizeSelect.value = String(governanceAllowListSelectionViewState.users.pageSize); + } + if (groupSearchInput) { + groupSearchInput.value = governanceAllowListSelectionViewState.groups.search; + } + if (groupPageSizeSelect) { + groupPageSizeSelect.value = String(governanceAllowListSelectionViewState.groups.pageSize); + } +} + +function getGovernanceSelectedIdsByType(listType) { + if (!governanceAllowListEditorContext) { + return []; + } + if (listType === 'groups') { + return Array.isArray(governanceAllowListEditorContext.workingGroups) ? governanceAllowListEditorContext.workingGroups : []; + } + return Array.isArray(governanceAllowListEditorContext.workingUsers) ? governanceAllowListEditorContext.workingUsers : []; +} + +function getFilteredGovernanceSelectedIds(listType) { + const allIds = getGovernanceSelectedIdsByType(listType); + const state = governanceAllowListSelectionViewState[listType]; + const searchValue = String(state?.search || '').trim().toLowerCase(); + if (!searchValue) { + return allIds; + } + return allIds.filter((value) => { + const idText = String(value || '').toLowerCase(); + const displayName = String(getGovernanceDisplayName(listType, value) || '').toLowerCase(); + return idText.includes(searchValue) || displayName.includes(searchValue); + }); +} + +function truncateGovernanceId(idValue, maxLength = GOVERNANCE_ALLOWLIST_TRUNCATE_ID_LENGTH) { + const str = String(idValue || ''); + if (str.length <= maxLength) { + return str; + } + return str.substring(0, maxLength - 1) + '…'; +} + +function getGovernanceDisplayName(listType, idValue) { + const cache = governanceAllowListDisplayNameCache[listType] || {}; + return cache[idValue] || null; +} + +function setGovernanceDisplayName(listType, idValue, displayName) { + if (!governanceAllowListDisplayNameCache[listType]) { + governanceAllowListDisplayNameCache[listType] = {}; + } + governanceAllowListDisplayNameCache[listType][idValue] = String(displayName || '').trim(); +} + +function buildGovernanceUserLabel(user) { + const upn = String(user?.userPrincipalName || user?.mail || user?.email || '').trim(); + const displayName = String(user?.displayName || user?.display_name || upn || '(no name)').trim(); + if (upn && upn.toLowerCase() !== displayName.toLowerCase()) { + return `${displayName} (${upn})`; + } + return displayName; +} + +async function resolveGovernanceUserLabelById(userId) { + try { + const users = await governanceLookupUsers(userId); + const matchedUser = (Array.isArray(users) ? users : []).find((user) => String(user?.id || '').trim() === userId); + if (matchedUser) { + return buildGovernanceUserLabel(matchedUser); + } + } catch { + // Fall back to local user info below. + } + + try { + const response = await fetch(`/api/user/info/${encodeURIComponent(userId)}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return ''; + } + const payload = await response.json(); + return buildGovernanceUserLabel(payload || {}); + } catch { + return ''; + } +} + +async function resolveGovernanceGroupLabelById(groupId) { + try { + const groups = await governanceLookupGroups(groupId); + const matchedGroup = (Array.isArray(groups) ? groups : []).find((group) => String(group?.id || '').trim() === groupId); + if (!matchedGroup) { + return ''; + } + return String(matchedGroup.name || 'Unnamed Group').trim(); + } catch { + return ''; + } +} + +async function hydrateGovernanceDisplayNames(listType, ids) { + const normalizedIds = uniquePrincipalList(ids); + if (normalizedIds.length === 0) { + return; + } + + const inFlight = governanceAllowListHydrationState[listType] || new Set(); + const missingIds = normalizedIds.filter((idValue) => { + return idValue && !getGovernanceDisplayName(listType, idValue) && !inFlight.has(idValue); + }); + + if (missingIds.length === 0) { + return; + } + + missingIds.forEach((idValue) => inFlight.add(idValue)); + governanceAllowListHydrationState[listType] = inFlight; + + let hasUpdates = false; + await Promise.all(missingIds.map(async (idValue) => { + try { + const resolvedLabel = listType === 'users' + ? await resolveGovernanceUserLabelById(idValue) + : await resolveGovernanceGroupLabelById(idValue); + + if (resolvedLabel) { + setGovernanceDisplayName(listType, idValue, resolvedLabel); + hasUpdates = true; + } + } finally { + inFlight.delete(idValue); + } + })); + + if (hasUpdates && governanceAllowListEditorContext) { + renderGovernanceAllowListEditorSelections(); + } +} + +function renderGovernanceSelectedList(options) { + const { + listType, + containerId, + checkboxClass, + emptyMessage, + summaryId, + prevButtonId, + nextButtonId, + } = options; + + const tbody = document.getElementById(containerId); + const summary = document.getElementById(summaryId); + const prevButton = document.getElementById(prevButtonId); + const nextButton = document.getElementById(nextButtonId); + const state = governanceAllowListSelectionViewState[listType]; + + if (!tbody || !state) { + return; + } + + const filteredIds = getFilteredGovernanceSelectedIds(listType); + const pageSize = normalizeGovernanceAllowListPageSize(state.pageSize); + state.pageSize = pageSize; + const totalItems = filteredIds.length; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + state.page = Math.min(Math.max(1, state.page), totalPages); + const startIndex = (state.page - 1) * pageSize; + const visibleIds = filteredIds.slice(startIndex, startIndex + pageSize); + + tbody.innerHTML = ''; + if (visibleIds.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 3; + cell.className = 'text-center text-muted'; + cell.textContent = emptyMessage; + row.appendChild(cell); + tbody.appendChild(row); + } else { + visibleIds.forEach((idValue) => { + const row = document.createElement('tr'); + + const checkCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = checkboxClass; + checkbox.value = idValue; + checkCell.appendChild(checkbox); + + const displayName = getGovernanceDisplayName(listType, idValue); + const truncatedId = truncateGovernanceId(idValue); + const displayText = displayName ? `${displayName} (${truncatedId})` : truncatedId; + + const infoCell = document.createElement('td'); + infoCell.className = 'small'; + infoCell.textContent = displayText; + infoCell.title = idValue; + + const copyCell = document.createElement('td'); + copyCell.className = 'text-center'; + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.className = 'btn btn-sm btn-link p-0'; + copyButton.innerHTML = ''; + copyButton.title = 'Copy ID'; + copyButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const originalHtml = copyButton.innerHTML; + navigator.clipboard.writeText(idValue).then(() => { + copyButton.innerHTML = ''; + setTimeout(() => { + copyButton.innerHTML = originalHtml; + }, 1500); + }).catch(() => { + copyButton.innerHTML = ''; + setTimeout(() => { + copyButton.innerHTML = originalHtml; + }, 1500); + }); + }); + copyCell.appendChild(copyButton); + + row.appendChild(checkCell); + row.appendChild(infoCell); + row.appendChild(copyCell); + tbody.appendChild(row); + }); + } + + if (summary) { + const viewStart = totalItems === 0 ? 0 : startIndex + 1; + const viewEnd = totalItems === 0 ? 0 : Math.min(startIndex + visibleIds.length, totalItems); + summary.textContent = `Showing ${viewStart}-${viewEnd} of ${totalItems} selected (${state.page}/${totalPages}).`; + } + + if (prevButton) { + prevButton.disabled = state.page <= 1 || totalItems === 0; + } + if (nextButton) { + nextButton.disabled = state.page >= totalPages || totalItems === 0; + } +} + +function renderPrincipalIdRows(containerId, ids, checkboxClass, emptyMessage) { + const tbody = document.getElementById(containerId); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(ids) || ids.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 2; + cell.className = 'text-center text-muted'; + cell.textContent = emptyMessage; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + ids.forEach((idValue) => { + const row = document.createElement('tr'); + + const checkCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = checkboxClass; + checkbox.value = idValue; + checkCell.appendChild(checkbox); + + const idCell = document.createElement('td'); + idCell.textContent = idValue; + + row.appendChild(checkCell); + row.appendChild(idCell); + tbody.appendChild(row); + }); +} + +function renderGovernanceAllowListEditorSelections() { + if (!governanceAllowListEditorContext) { + return; + } + + const users = uniquePrincipalList(governanceAllowListEditorContext.workingUsers); + const groups = uniquePrincipalList(governanceAllowListEditorContext.workingGroups); + + void hydrateGovernanceDisplayNames('users', users); + void hydrateGovernanceDisplayNames('groups', groups); + + renderGovernanceSelectedList({ + listType: 'users', + containerId: 'governance-allowlist-selected-users', + checkboxClass: 'governance-selected-user-checkbox', + emptyMessage: 'No users selected.', + summaryId: 'governance-allowlist-selected-users-summary', + prevButtonId: 'governance-allowlist-selected-users-prev-btn', + nextButtonId: 'governance-allowlist-selected-users-next-btn', + }); + renderGovernanceSelectedList({ + listType: 'groups', + containerId: 'governance-allowlist-selected-groups', + checkboxClass: 'governance-selected-group-checkbox', + emptyMessage: 'No groups selected.', + summaryId: 'governance-allowlist-selected-groups-summary', + prevButtonId: 'governance-allowlist-selected-groups-prev-btn', + nextButtonId: 'governance-allowlist-selected-groups-next-btn', + }); +} + +function renderGovernanceAllowListUserResults(users) { + const tbody = document.getElementById('governance-allowlist-user-results'); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(users) || users.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 3; + cell.className = 'text-center text-muted'; + cell.textContent = 'No users found.'; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + users.forEach((user) => { + const userId = String(user.id || '').trim(); + const upn = String(user.userPrincipalName || user.mail || user.email || '').trim(); + const displayName = String(user.displayName || upn || '(no name)').trim(); + const userLabel = buildGovernanceUserLabel(user); + + if (userId && userLabel) { + setGovernanceDisplayName('users', userId, userLabel); + } + + const row = document.createElement('tr'); + + const selectCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'governance-user-result-checkbox'; + checkbox.value = userId; + checkbox.dataset.displayLabel = userLabel; + selectCell.appendChild(checkbox); + + const userCell = document.createElement('td'); + userCell.textContent = displayName; + + const emailCell = document.createElement('td'); + emailCell.textContent = upn; + + row.appendChild(selectCell); + row.appendChild(userCell); + row.appendChild(emailCell); + tbody.appendChild(row); + }); +} + +function renderGovernanceAllowListGroupResults(groups) { + const tbody = document.getElementById('governance-allowlist-group-results'); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(groups) || groups.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 3; + cell.className = 'text-center text-muted'; + cell.textContent = 'No groups found.'; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + groups.forEach((group) => { + const groupId = String(group.id || '').trim(); + const groupName = String(group.name || 'Unnamed Group'); + + if (groupId && groupName) { + setGovernanceDisplayName('groups', groupId, groupName); + } + + const row = document.createElement('tr'); + + const selectCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'governance-group-result-checkbox'; + checkbox.value = groupId; + checkbox.dataset.displayLabel = groupName; + selectCell.appendChild(checkbox); + + const nameCell = document.createElement('td'); + nameCell.textContent = groupName; + + const idCell = document.createElement('td'); + idCell.textContent = groupId; + + row.appendChild(selectCell); + row.appendChild(nameCell); + row.appendChild(idCell); + tbody.appendChild(row); + }); +} + +async function loadGovernanceAllowListUserResults() { + const searchInput = document.getElementById('governance-allowlist-user-search'); + const query = String(searchInput?.value || '').trim(); + if (!query) { + setGovernanceAllowListEditorStatus('Enter a user search term.'); + renderGovernanceAllowListUserResults([]); + return; + } + + const users = await governanceLookupUsers(query); + renderGovernanceAllowListUserResults(users); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus('User results updated.'); +} + +async function loadGovernanceAllowListGroupResults() { + const searchInput = document.getElementById('governance-allowlist-group-search'); + const query = String(searchInput?.value || '').trim(); + + const groups = await governanceLookupGroups(query); + renderGovernanceAllowListGroupResults(groups); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus('Group results updated.'); +} + +function applyGovernanceAllowListToContext() { + if (!governanceAllowListEditorContext) { + return; + } + + const users = uniquePrincipalList(governanceAllowListEditorContext.workingUsers); + const groups = uniquePrincipalList(governanceAllowListEditorContext.workingGroups); + governanceAllowListEditorContext.setValues(users, groups); + governanceAllowListEditorModal?.hide(); +} + +function openGovernanceAllowListEditor(context) { + if (!context || typeof context.getUsers !== 'function' || typeof context.getGroups !== 'function' || typeof context.setValues !== 'function') { + return; + } + + ensureGovernanceAllowListEditorModal(); + + governanceAllowListEditorContext = { + ...context, + workingUsers: uniquePrincipalList(context.getUsers()), + workingGroups: uniquePrincipalList(context.getGroups()), + }; + + const title = document.getElementById('governance-allowlist-editor-title'); + const contextAlert = document.getElementById('governance-allowlist-editor-context'); + const userSearchInput = document.getElementById('governance-allowlist-user-search'); + const groupSearchInput = document.getElementById('governance-allowlist-group-search'); + const csvInput = document.getElementById('governance-allowlist-csv-input'); + + if (title) { + title.textContent = context.title || 'Edit Allow List'; + } + if (contextAlert) { + contextAlert.textContent = context.description || 'Manage users and groups that are explicitly allowed for this policy.'; + } + if (userSearchInput) { + userSearchInput.value = ''; + } + if (groupSearchInput) { + groupSearchInput.value = ''; + } + if (csvInput) { + csvInput.value = ''; + } + + resetGovernanceAllowListSelectionViewState(); + syncGovernanceAllowListSelectionControls(); + + renderGovernanceAllowListUserResults([]); + renderGovernanceAllowListGroupResults([]); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(''); + + governanceAllowListEditorModal?.show(); +} + +function readCheckedValues(selector) { + return Array.from(document.querySelectorAll(selector)) + .filter((input) => input instanceof HTMLInputElement && input.checked) + .map((input) => String(input.value || '').trim()) + .filter((value) => value); +} + +function toggleCheckboxes(selector, checked) { + Array.from(document.querySelectorAll(selector)).forEach((input) => { + if (input instanceof HTMLInputElement) { + input.checked = checked; + } + }); +} + +function removeCheckedFromList(list, checkedValues) { + const valuesToRemove = new Set((checkedValues || []).map((value) => String(value || '').trim())); + return (Array.isArray(list) ? list : []).filter((value) => !valuesToRemove.has(String(value || '').trim())); +} + +function wireGovernanceAllowListEditorHandlers() { + const userSearchButton = document.getElementById('governance-allowlist-user-search-btn'); + if (userSearchButton) { + userSearchButton.addEventListener('click', async () => { + try { + await loadGovernanceAllowListUserResults(); + } catch (error) { + setGovernanceAllowListEditorStatus(error.message || 'Failed to load user results.'); + } + }); + } + + const groupSearchButton = document.getElementById('governance-allowlist-group-search-btn'); + if (groupSearchButton) { + groupSearchButton.addEventListener('click', async () => { + try { + await loadGovernanceAllowListGroupResults(); + } catch (error) { + setGovernanceAllowListEditorStatus(error.message || 'Failed to load group results.'); + } + }); + } + + const addSelectedUsersButton = document.getElementById('governance-allowlist-add-selected-users-btn'); + if (addSelectedUsersButton) { + addSelectedUsersButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + const selectedUserIds = readCheckedValues('.governance-user-result-checkbox'); + selectedUserIds.forEach((userId) => { + const userCheckbox = Array.from(document.querySelectorAll('.governance-user-result-checkbox')).find( + (checkbox) => checkbox.value === userId + ); + const displayLabel = String(userCheckbox?.dataset.displayLabel || '').trim(); + if (displayLabel) { + setGovernanceDisplayName('users', userId, displayLabel); + } + }); + governanceAllowListEditorContext.workingUsers = uniquePrincipalList([...governanceAllowListEditorContext.workingUsers, ...selectedUserIds]); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`Added ${selectedUserIds.length} user${selectedUserIds.length === 1 ? '' : 's'}.`); + }); + } + + const addSelectedGroupsButton = document.getElementById('governance-allowlist-add-selected-groups-btn'); + if (addSelectedGroupsButton) { + addSelectedGroupsButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + const selectedGroupIds = readCheckedValues('.governance-group-result-checkbox'); + selectedGroupIds.forEach((groupId) => { + const groupCheckbox = Array.from(document.querySelectorAll('.governance-group-result-checkbox')).find( + (checkbox) => checkbox.value === groupId + ); + const displayLabel = String(groupCheckbox?.dataset.displayLabel || '').trim(); + if (displayLabel) { + setGovernanceDisplayName('groups', groupId, displayLabel); + } + }); + governanceAllowListEditorContext.workingGroups = uniquePrincipalList([...governanceAllowListEditorContext.workingGroups, ...selectedGroupIds]); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`Added ${selectedGroupIds.length} group${selectedGroupIds.length === 1 ? '' : 's'}.`); + }); + } + + const removeSelectedUsersButton = document.getElementById('governance-allowlist-remove-selected-users-btn'); + if (removeSelectedUsersButton) { + removeSelectedUsersButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + const selectedUserIds = readCheckedValues('.governance-selected-user-checkbox'); + governanceAllowListEditorContext.workingUsers = removeCheckedFromList(governanceAllowListEditorContext.workingUsers, selectedUserIds); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`Removed ${selectedUserIds.length} user${selectedUserIds.length === 1 ? '' : 's'}.`); + }); + } + + const removeSelectedGroupsButton = document.getElementById('governance-allowlist-remove-selected-groups-btn'); + if (removeSelectedGroupsButton) { + removeSelectedGroupsButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + const selectedGroupIds = readCheckedValues('.governance-selected-group-checkbox'); + governanceAllowListEditorContext.workingGroups = removeCheckedFromList(governanceAllowListEditorContext.workingGroups, selectedGroupIds); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`Removed ${selectedGroupIds.length} group${selectedGroupIds.length === 1 ? '' : 's'}.`); + }); + } + + const clearUsersButton = document.getElementById('governance-allowlist-clear-users-btn'); + if (clearUsersButton) { + clearUsersButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + governanceAllowListEditorContext.workingUsers = []; + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus('Cleared selected users.'); + }); + } + + const clearGroupsButton = document.getElementById('governance-allowlist-clear-groups-btn'); + if (clearGroupsButton) { + clearGroupsButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + governanceAllowListEditorContext.workingGroups = []; + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus('Cleared selected groups.'); + }); + } + + const csvApplyButton = document.getElementById('governance-allowlist-csv-apply-btn'); + if (csvApplyButton) { + csvApplyButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + + const targetSelect = document.getElementById('governance-allowlist-csv-target'); + const modeSelect = document.getElementById('governance-allowlist-csv-mode'); + const csvInput = document.getElementById('governance-allowlist-csv-input'); + const target = String(targetSelect?.value || 'users'); + const mode = String(modeSelect?.value || 'merge'); + const importedValues = uniquePrincipalList(parseCsvPrincipalLines(csvInput?.value || '')); + + if (importedValues.length === 0) { + setGovernanceAllowListEditorStatus('No CSV values detected.'); + return; + } + + if (target === 'groups') { + governanceAllowListEditorContext.workingGroups = mode === 'replace' + ? importedValues + : uniquePrincipalList([...governanceAllowListEditorContext.workingGroups, ...importedValues]); + } else { + governanceAllowListEditorContext.workingUsers = mode === 'replace' + ? importedValues + : uniquePrincipalList([...governanceAllowListEditorContext.workingUsers, ...importedValues]); + } + + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`CSV ${mode} completed for ${target}. Imported ${importedValues.length} ID${importedValues.length === 1 ? '' : 's'}.`); + }); + } + + const saveButton = document.getElementById('governance-allowlist-save-btn'); + if (saveButton) { + saveButton.addEventListener('click', () => { + applyGovernanceAllowListToContext(); + }); + } + + const selectedUserSearchInput = document.getElementById('governance-allowlist-selected-user-search'); + if (selectedUserSearchInput) { + selectedUserSearchInput.addEventListener('input', () => { + governanceAllowListSelectionViewState.users.search = String(selectedUserSearchInput.value || '').trim(); + governanceAllowListSelectionViewState.users.page = 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedGroupSearchInput = document.getElementById('governance-allowlist-selected-group-search'); + if (selectedGroupSearchInput) { + selectedGroupSearchInput.addEventListener('input', () => { + governanceAllowListSelectionViewState.groups.search = String(selectedGroupSearchInput.value || '').trim(); + governanceAllowListSelectionViewState.groups.page = 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedUserPageSizeSelect = document.getElementById('governance-allowlist-selected-user-page-size'); + if (selectedUserPageSizeSelect) { + selectedUserPageSizeSelect.addEventListener('change', () => { + governanceAllowListSelectionViewState.users.pageSize = normalizeGovernanceAllowListPageSize(selectedUserPageSizeSelect.value); + governanceAllowListSelectionViewState.users.page = 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedGroupPageSizeSelect = document.getElementById('governance-allowlist-selected-group-page-size'); + if (selectedGroupPageSizeSelect) { + selectedGroupPageSizeSelect.addEventListener('change', () => { + governanceAllowListSelectionViewState.groups.pageSize = normalizeGovernanceAllowListPageSize(selectedGroupPageSizeSelect.value); + governanceAllowListSelectionViewState.groups.page = 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedUsersPrevButton = document.getElementById('governance-allowlist-selected-users-prev-btn'); + if (selectedUsersPrevButton) { + selectedUsersPrevButton.addEventListener('click', () => { + governanceAllowListSelectionViewState.users.page = Math.max(1, governanceAllowListSelectionViewState.users.page - 1); + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedUsersNextButton = document.getElementById('governance-allowlist-selected-users-next-btn'); + if (selectedUsersNextButton) { + selectedUsersNextButton.addEventListener('click', () => { + governanceAllowListSelectionViewState.users.page += 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedGroupsPrevButton = document.getElementById('governance-allowlist-selected-groups-prev-btn'); + if (selectedGroupsPrevButton) { + selectedGroupsPrevButton.addEventListener('click', () => { + governanceAllowListSelectionViewState.groups.page = Math.max(1, governanceAllowListSelectionViewState.groups.page - 1); + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedGroupsNextButton = document.getElementById('governance-allowlist-selected-groups-next-btn'); + if (selectedGroupsNextButton) { + selectedGroupsNextButton.addEventListener('click', () => { + governanceAllowListSelectionViewState.groups.page += 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectAllMappings = [ + ['governance-allowlist-select-all-user-results', '.governance-user-result-checkbox'], + ['governance-allowlist-select-all-group-results', '.governance-group-result-checkbox'], + ['governance-allowlist-select-all-selected-users', '.governance-selected-user-checkbox'], + ['governance-allowlist-select-all-selected-groups', '.governance-selected-group-checkbox'], + ]; + + selectAllMappings.forEach(([masterId, checkboxSelector]) => { + const master = document.getElementById(masterId); + if (!master) { + return; + } + master.addEventListener('change', () => { + toggleCheckboxes(checkboxSelector, master.checked); + }); + }); +} + +async function loadItemPolicies() { + const tbody = document.getElementById('governance-item-policies-body'); + if (!tbody) { + return; + } + + const response = await fetch('/api/admin/governance/item-policies', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to load governance item policies.'); + } + + const payload = await response.json(); + const itemPolicies = Array.isArray(payload.item_policies) ? payload.item_policies : []; + + tbody.innerHTML = ''; + itemPolicies.forEach((policy) => { + tbody.appendChild(buildItemPolicyRow(policy)); + }); +} + +async function saveItemPolicy(event) { + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + + const entityTypeInput = document.getElementById('governance-item-entity-type'); + const itemIdInput = document.getElementById('governance-item-id'); + const allowAllInput = document.getElementById('governance-item-allow-all'); + const usersInput = document.getElementById('governance-item-users'); + const groupsInput = document.getElementById('governance-item-groups'); + + if (!entityTypeInput || !itemIdInput || !allowAllInput || !usersInput || !groupsInput) { + return; + } + + const entityType = String(entityTypeInput.value || '').trim(); + const itemId = String(itemIdInput.value || '').trim(); + + if (!entityType || !itemId) { + setGovernanceStatus('Entity type and item ID are required for item governance policies.', 'warning'); + return; + } + + const payload = { + allow_all: allowAllInput.checked, + allowed_users: allowAllInput.checked ? [] : splitPrincipalList(usersInput.value), + allowed_groups: allowAllInput.checked ? [] : splitPrincipalList(groupsInput.value), + }; + + const response = await fetch( + `/api/admin/governance/item-policies/${encodeURIComponent(entityType)}/${encodeURIComponent(itemId)}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + } + ); + + if (!response.ok) { + throw new Error('Unable to save item governance policy.'); + } + + await loadItemPolicies(); + const reviewModalElement = document.getElementById('governance-item-policies-review-modal'); + if (reviewModalElement?.classList.contains('show')) { + await loadGovernanceItemPolicyReview(); + } + updateItemAllowListSummary(); + clearGovernanceStatus(); + showGovernanceToast('Item governance policy saved successfully.', 'success'); +} + +function wireGovernanceHandlers() { + Object.keys(GOVERNANCE_FEATURE_LABELS).forEach((featureKey) => { + const featureToggle = getGovernanceFeatureToggle(featureKey); + if (!featureToggle) { + return; + } + featureToggle.addEventListener('change', () => { + syncGovernanceFeaturePolicyVisibility(); + }); + }); + + const featurePolicyTableBody = document.getElementById('governance-feature-policies-body'); + if (featurePolicyTableBody) { + featurePolicyTableBody.addEventListener('change', (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement)) { + return; + } + if (target.classList.contains('governance-allow-all')) { + const row = target.closest('tr'); + applyFeatureAllowAllUiState(row); + } + }); + + featurePolicyTableBody.addEventListener('click', (event) => { + const target = event.target; + const editButton = target instanceof HTMLElement ? target.closest('.governance-edit-feature-allowlist-btn') : null; + if (!editButton) { + return; + } + + const row = editButton.closest('tr'); + const usersInput = getGovernanceUsersInputForFeatureRow(row); + const groupsInput = getGovernanceGroupsInputForFeatureRow(row); + const featureKey = String(row?.dataset?.featureKey || '').trim(); + if (!usersInput || !groupsInput || !featureKey) { + return; + } + + openGovernanceAllowListEditor({ + title: `Edit Allow List: ${GOVERNANCE_FEATURE_LABELS[featureKey] || featureKey}`, + description: 'Manage explicitly allowed users and groups for this feature policy.', + getUsers: () => splitPrincipalList(usersInput.value), + getGroups: () => splitPrincipalList(groupsInput.value), + setValues: (users, groups) => { + usersInput.value = joinPrincipalList(users); + groupsInput.value = joinPrincipalList(groups); + const allowAllInput = getGovernanceFeatureAllowAllInput(row); + if (allowAllInput) { + allowAllInput.checked = false; + } + applyFeatureAllowAllUiState(row); + }, + }); + }); + } + + const saveFeaturePoliciesButton = document.getElementById('governance-save-feature-policies-btn'); + if (saveFeaturePoliciesButton) { + saveFeaturePoliciesButton.addEventListener('click', async () => { + clearGovernanceStatus(); + try { + await saveFeaturePolicies(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to save feature policies.', 'danger'); + } + }); + } + + const saveItemPolicyButton = document.getElementById('governance-save-item-policy-btn'); + if (saveItemPolicyButton) { + saveItemPolicyButton.addEventListener('click', async (event) => { + clearGovernanceStatus(); + try { + await saveItemPolicy(event); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to save item policy.', 'danger'); + } + }); + } + + const itemEntityTypeInput = getItemEntityTypeInput(); + if (itemEntityTypeInput) { + itemEntityTypeInput.addEventListener('change', async () => { + const entityType = String(itemEntityTypeInput.value || '').trim(); + await refreshGovernanceItemLookup(entityType, false, ''); + }); + } + + const itemLookupRefreshButton = document.getElementById('governance-item-id-refresh-btn'); + if (itemLookupRefreshButton) { + itemLookupRefreshButton.addEventListener('click', async () => { + const entityType = String(getItemEntityTypeInput()?.value || '').trim(); + await refreshGovernanceItemLookup(entityType, true, String(getItemIdInput()?.value || '').trim()); + }); + } + + const itemAllowAllInput = getItemAllowAllInput(); + if (itemAllowAllInput) { + itemAllowAllInput.addEventListener('change', () => { + applyItemAllowAllUiState(); + }); + } + + const itemEditAllowListButton = document.getElementById('governance-edit-item-allowlist-btn'); + if (itemEditAllowListButton) { + itemEditAllowListButton.addEventListener('click', () => { + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + const entityTypeInput = document.getElementById('governance-item-entity-type'); + const itemIdInput = document.getElementById('governance-item-id'); + + if (!usersInput || !groupsInput) { + return; + } + + const entityType = String(entityTypeInput?.value || '').trim(); + const itemId = String(itemIdInput?.value || '').trim(); + const contextSuffix = entityType && itemId + ? ` (${GOVERNANCE_ITEM_ENTITY_LABELS[entityType] || entityType}: ${itemId})` + : ''; + + openGovernanceAllowListEditor({ + title: `Edit Delegated Item Allow List${contextSuffix}`, + description: 'Manage explicitly allowed users and groups for this delegated item policy.', + getUsers: () => splitPrincipalList(usersInput.value), + getGroups: () => splitPrincipalList(groupsInput.value), + setValues: (users, groups) => { + usersInput.value = joinPrincipalList(users); + groupsInput.value = joinPrincipalList(groups); + const allowAllInput = getItemAllowAllInput(); + if (allowAllInput) { + allowAllInput.checked = false; + } + applyItemAllowAllUiState(); + }, + }); + }); + } + + const refreshItemPoliciesButton = document.getElementById('governance-refresh-item-policies-btn'); + if (refreshItemPoliciesButton) { + refreshItemPoliciesButton.addEventListener('click', async () => { + clearGovernanceStatus(); + try { + await loadItemPolicies(); + setGovernanceStatus('Item governance policies refreshed.', 'info'); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to refresh item policies.', 'danger'); + } + }); + } + + const reviewItemPoliciesButton = document.getElementById('governance-review-item-policies-btn'); + if (reviewItemPoliciesButton) { + reviewItemPoliciesButton.addEventListener('click', () => { + clearGovernanceStatus(); + openGovernanceItemReviewModal(); + }); + } + + const reviewSearchButton = document.getElementById('governance-item-review-search-btn'); + if (reviewSearchButton) { + reviewSearchButton.addEventListener('click', async () => { + const searchInput = document.getElementById('governance-item-review-search'); + const entityTypeSelect = document.getElementById('governance-item-review-entity-type'); + const pageSizeSelect = document.getElementById('governance-item-review-page-size'); + governanceItemReviewState.search = String(searchInput?.value || '').trim(); + governanceItemReviewState.entityType = String(entityTypeSelect?.value || '').trim(); + governanceItemReviewState.perPage = Math.max(1, Number(pageSizeSelect?.value || GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE) || GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE); + governanceItemReviewState.page = 1; + syncGovernanceItemReviewControls(); + try { + await loadGovernanceItemPolicyReview(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to search delegated item policies.', 'danger'); + } + }); + } + + const reviewResetButton = document.getElementById('governance-item-review-reset-btn'); + if (reviewResetButton) { + reviewResetButton.addEventListener('click', async () => { + governanceItemReviewState.search = ''; + governanceItemReviewState.entityType = ''; + governanceItemReviewState.page = 1; + governanceItemReviewState.perPage = GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE; + syncGovernanceItemReviewControls(); + try { + await loadGovernanceItemPolicyReview(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to reset delegated item policy filters.', 'danger'); + } + }); + } + + const reviewPrevButton = document.getElementById('governance-item-review-prev-btn'); + if (reviewPrevButton) { + reviewPrevButton.addEventListener('click', async () => { + if (governanceItemReviewState.page <= 1) { + return; + } + governanceItemReviewState.page -= 1; + syncGovernanceItemReviewControls(); + try { + await loadGovernanceItemPolicyReview(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to load previous delegated item policy page.', 'danger'); + } + }); + } + + const reviewNextButton = document.getElementById('governance-item-review-next-btn'); + if (reviewNextButton) { + reviewNextButton.addEventListener('click', async () => { + governanceItemReviewState.page += 1; + syncGovernanceItemReviewControls(); + try { + await loadGovernanceItemPolicyReview(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to load next delegated item policy page.', 'danger'); + } + }); + } +} + +async function initializeGovernanceTab() { + if (!document.getElementById('governance')) { + return; + } + + wireGovernanceHandlers(); + clearGovernanceStatus(); + + ensureGovernanceItemReviewModal(); + ensureGovernanceAllowListEditorModal(); + + try { + await loadFeaturePolicies(); + await loadItemPolicies(); + const initialEntityType = String(getItemEntityTypeInput()?.value || 'global_agent').trim(); + await refreshGovernanceItemLookup(initialEntityType, false, String(getItemIdInput()?.value || '').trim()); + applyItemAllowAllUiState(); + } catch (error) { + setGovernanceStatus(error.message || 'Unable to initialize governance settings.', 'danger'); + } +} + +document.addEventListener('DOMContentLoaded', () => { + initializeGovernanceTab(); +}); diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index d26cdfb42..ace3323b9 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -519,6 +519,11 @@ Control Center + + + + +