diff --git a/application/single_app/config.py b/application/single_app/config.py index ed085aed9..4ce4fc421 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.203" +VERSION = "0.250.208" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_governance.py b/application/single_app/functions_governance.py index 92da8fe3f..a7fae3fea 100644 --- a/application/single_app/functions_governance.py +++ b/application/single_app/functions_governance.py @@ -376,6 +376,8 @@ def _default_feature_policy_doc(feature_key: str) -> Dict[str, Any]: "allow_all": True, "allowed_users": [], "allowed_groups": [], + "denied_users": [], + "denied_groups": [], "updated_at": datetime.utcnow().isoformat(), } @@ -406,6 +408,8 @@ def _default_item_policy_doc( "allow_all": True, "allowed_users": [], "allowed_groups": [], + "denied_users": [], + "denied_groups": [], "system_managed": False, "managed_by": "", "managed_reason": "", @@ -417,6 +421,8 @@ 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", [])) + denied_users = _normalize_str_list(payload.get("denied_users", [])) + denied_groups = _normalize_str_list(payload.get("denied_groups", [])) if allow_all and (allowed_users or allowed_groups): allow_all = False @@ -429,6 +435,8 @@ def _normalize_policy_state(payload: Dict[str, Any]) -> Dict[str, Any]: "allow_all": allow_all, "allowed_users": allowed_users, "allowed_groups": allowed_groups, + "denied_users": denied_users, + "denied_groups": denied_groups, } @@ -567,6 +575,10 @@ def _build_diff(before_doc: Dict[str, Any], after_doc: Dict[str, Any]) -> Dict[s 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", []))) + before_denied_users = set(_normalize_str_list(before_doc.get("denied_users", []))) + after_denied_users = set(_normalize_str_list(after_doc.get("denied_users", []))) + before_denied_groups = set(_normalize_str_list(before_doc.get("denied_groups", []))) + after_denied_groups = set(_normalize_str_list(after_doc.get("denied_groups", []))) return { "allow_all": { @@ -577,6 +589,10 @@ def _build_diff(before_doc: Dict[str, Any], after_doc: Dict[str, Any]) -> Dict[s "users_removed": sorted(list(before_users - after_users)), "groups_added": sorted(list(after_groups - before_groups)), "groups_removed": sorted(list(before_groups - after_groups)), + "denied_users_added": sorted(list(after_denied_users - before_denied_users)), + "denied_users_removed": sorted(list(before_denied_users - after_denied_users)), + "denied_groups_added": sorted(list(after_denied_groups - before_denied_groups)), + "denied_groups_removed": sorted(list(before_denied_groups - after_denied_groups)), } @@ -595,6 +611,8 @@ def upsert_feature_policy( "allow_all": normalized_payload["allow_all"], "allowed_users": normalized_payload["allowed_users"], "allowed_groups": normalized_payload["allowed_groups"], + "denied_users": normalized_payload["denied_users"], + "denied_groups": normalized_payload["denied_groups"], "updated_by": str(actor_user_id or "").strip(), "updated_at": datetime.utcnow().isoformat(), } @@ -658,6 +676,8 @@ def upsert_item_policy( "allow_all": normalized_payload["allow_all"], "allowed_users": normalized_payload["allowed_users"], "allowed_groups": normalized_payload["allowed_groups"], + "denied_users": normalized_payload["denied_users"], + "denied_groups": normalized_payload["denied_groups"], "system_managed": incoming_system_managed, "managed_by": str((payload or {}).get("managed_by") or "").strip() if incoming_system_managed else "", "managed_reason": str((payload or {}).get("managed_reason") or "").strip() if incoming_system_managed else "", @@ -704,7 +724,7 @@ def upsert_item_policy( def _read_existing_item_policy_for_delete(entity_type: str, item_id: str, policy_id: Optional[str] = None) -> Tuple[Dict[str, Any], str, str]: normalized_entity_type = _normalize_item_policy_entity_type(entity_type) - normalized_item_id = str(item_id or "").strip() + normalized_item_id = _normalize_item_policy_item_id(normalized_entity_type, item_id) normalized_policy_id = str(policy_id or "").strip() candidate_entity_types = [normalized_entity_type] + _get_legacy_item_policy_entity_types(normalized_entity_type) @@ -727,10 +747,19 @@ def _read_existing_item_policy_for_delete(entity_type: str, item_id: str, policy last_exception = None for candidate_entity_type in candidate_entity_types: try: - policies = _read_item_policies(candidate_entity_type, normalized_item_id) - if policies: - policy = policies[0] - document_id = str(policy.get("id") or _item_policy_document_id(candidate_entity_type, normalized_item_id, policy.get("policy_id") or "default")) + policies = _read_item_policies( + candidate_entity_type, + normalized_item_id, + include_default=not normalized_policy_id, + ) + for policy in policies: + if normalized_policy_id and str(policy.get("policy_id") or "").strip() != normalized_policy_id: + continue + document_id = str(policy.get("id") or _item_policy_document_id( + candidate_entity_type, + normalized_item_id, + policy.get("policy_id") or "default", + )) return policy, candidate_entity_type, document_id except Exception as ex: last_exception = ex @@ -740,6 +769,77 @@ def _read_existing_item_policy_for_delete(entity_type: str, item_id: str, policy raise ValueError("Item governance policy not found.") +def retarget_item_policy( + entity_type: str, + item_id: str, + payload: Dict[str, Any], + original_entity_type: str, + original_item_id: str, + actor_user_id: str, + actor_email: str, +) -> Dict[str, Any]: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = _normalize_item_policy_item_id(normalized_entity_type, item_id) + normalized_original_entity_type = _normalize_item_policy_entity_type(original_entity_type) + normalized_original_item_id = _normalize_item_policy_item_id( + normalized_original_entity_type, + original_item_id, + ) + normalized_policy_id = str((payload or {}).get("policy_id") or "").strip() + + if not normalized_policy_id: + raise ValueError("Policy ID is required to retarget an item governance policy.") + + if ( + normalized_entity_type == normalized_original_entity_type + and normalized_item_id == normalized_original_item_id + ): + return 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, + ) + + source_policy, _, source_document_id = _read_existing_item_policy_for_delete( + normalized_original_entity_type, + normalized_original_item_id, + normalized_policy_id, + ) + before_policy = _normalize_item_policy_doc(source_policy) + if bool(before_policy.get("system_managed", False)) and not bool((payload or {}).get("system_managed", False)): + raise PermissionError("System-managed item governance policies cannot be edited directly.") + + stored = 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, + ) + + if source_document_id != str(stored.get("id") or ""): + cosmos_governance_item_policies_container.delete_item( + item=source_document_id, + partition_key=source_document_id, + ) + 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_retarget", + scope=normalized_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, @@ -794,6 +894,8 @@ def list_feature_policies() -> List[Dict[str, Any]]: 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["denied_users"] = _normalize_str_list(normalized_row.get("denied_users", [])) + normalized_row["denied_groups"] = _normalize_str_list(normalized_row.get("denied_groups", [])) normalized_row["allow_all"] = bool(normalized_row.get("allow_all", True)) normalized_rows.append(normalized_row) @@ -806,6 +908,8 @@ def list_feature_policies() -> List[Dict[str, Any]]: 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["denied_users"] = _normalize_str_list(normalized_row.get("denied_users", [])) + normalized_row["denied_groups"] = _normalize_str_list(normalized_row.get("denied_groups", [])) normalized_row["allow_all"] = bool(normalized_row.get("allow_all", True)) normalized_rows.append(normalized_row) seen_feature_keys.add(feature_key) @@ -863,6 +967,41 @@ def list_item_policies(entity_type: Optional[str] = None) -> List[Dict[str, Any] ) +def list_item_policies_by_policy_id(policy_id: str) -> List[Dict[str, Any]]: + normalized_policy_id = str(policy_id or "").strip() + if not normalized_policy_id: + return [] + + query = "SELECT * FROM c WHERE c.policy_id = @policy_id" + parameters = [{"name": "@policy_id", "value": normalized_policy_id}] + rows = list( + cosmos_governance_item_policies_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + ) + ) + + normalized_rows_by_key = {} + for row in rows: + normalized_row = _normalize_item_policy_doc(row) + row_key = ( + str(normalized_row.get("entity_type") or ""), + str(normalized_row.get("item_id") or ""), + str(normalized_row.get("policy_id") or ""), + ) + normalized_rows_by_key[row_key] = normalized_row + + return sorted( + normalized_rows_by_key.values(), + key=lambda item: ( + str(item.get("entity_type") or ""), + str(item.get("item_id") or ""), + str(item.get("policy_name") 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: @@ -888,7 +1027,39 @@ def load_group_ids() -> Set[str]: 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: +def _normalize_group_id_set(group_ids: Any) -> Set[str]: + if not isinstance(group_ids, (list, tuple, set)): + return set() + return { + str(group_id or "").strip() + for group_id in group_ids + if str(group_id or "").strip() + } + + +def policy_denies_principal(policy: Dict[str, Any], user_id: str, group_ids: Any) -> bool: + if not isinstance(policy, dict): + return False + + denied_users = set(_normalize_str_list(policy.get("denied_users", []))) + denied_groups = set(_normalize_str_list(policy.get("denied_groups", []))) + if not denied_users and not denied_groups: + return False + + normalized_user_id = str(user_id or "").strip() + if normalized_user_id and normalized_user_id in denied_users: + return True + + return bool(_normalize_group_id_set(group_ids).intersection(denied_groups)) + + +def policy_allows_principal(policy: Dict[str, Any], user_id: str, group_ids: Any) -> bool: + if not isinstance(policy, dict): + return False + + if policy_denies_principal(policy, user_id, group_ids): + return False + if bool(policy.get("allow_all", True)): return True @@ -902,12 +1073,16 @@ def _passes_policy(policy: Dict[str, Any], user_id: str, group_ids: Set[str]) -> if normalized_user_id and normalized_user_id in allowed_users: return True - if group_ids.intersection(allowed_groups): + if _normalize_group_id_set(group_ids).intersection(allowed_groups): return True return False +def _passes_policy(policy: Dict[str, Any], user_id: str, group_ids: Set[str]) -> bool: + return policy_allows_principal(policy, user_id, group_ids) + + def ensure_governance_access( feature_key: str, user_id: str, @@ -938,11 +1113,18 @@ def ensure_governance_access( user_group_ids = get_user_governance_group_ids(normalized_user_id) feature_policy = get_feature_policy(normalized_feature_key) + if policy_denies_principal(feature_policy, normalized_user_id, user_group_ids): + raise PermissionError(f"Governance policy blocks access for feature '{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_policies = get_item_policies(normalized_item_entity_type, normalized_item_id) + if any(policy_denies_principal(item_policy, normalized_user_id, user_group_ids) for item_policy in item_policies): + raise PermissionError( + f"Governance policy blocks access to {item_entity_type} '{item_id}'." + ) if not any(_passes_policy(item_policy, normalized_user_id, user_group_ids) for item_policy in item_policies): raise PermissionError( f"Governance policy blocks access to {item_entity_type} '{item_id}'." @@ -1030,11 +1212,23 @@ def ensure_action_type_access( user_group_ids = get_user_governance_group_ids(normalized_user_id) feature_policy = get_feature_policy(normalized_feature_key) + if policy_denies_principal(feature_policy, normalized_user_id, user_group_ids): + action_type_label = get_governed_action_type_label(normalized_action_type) + raise PermissionError( + f"Governance policy blocks access to {normalized_scope} action type '{action_type_label}'." + ) + + action_type_policies = get_explicit_item_policies(action_type_entity_type, normalized_action_type) + if any(policy_denies_principal(policy, normalized_user_id, user_group_ids) for policy in action_type_policies): + action_type_label = get_governed_action_type_label(normalized_action_type) + raise PermissionError( + f"Governance policy blocks access to {normalized_scope} action type '{action_type_label}'." + ) + if _passes_policy(feature_policy, normalized_user_id, user_group_ids): _set_request_cache_value(decision_key, True) return - action_type_policies = get_explicit_item_policies(action_type_entity_type, normalized_action_type) if any(_passes_policy(policy, normalized_user_id, user_group_ids) for policy in action_type_policies): _set_request_cache_value(decision_key, True) return @@ -1082,6 +1276,9 @@ def is_action_scope_access_allowed(feature_key: str, user_id: str, scope: str) - user_group_ids = get_user_governance_group_ids(normalized_user_id) feature_policy = get_feature_policy(normalized_feature_key) + if policy_denies_principal(feature_policy, normalized_user_id, user_group_ids): + return False + if _passes_policy(feature_policy, normalized_user_id, user_group_ids): return True @@ -1116,6 +1313,9 @@ def ensure_global_action_access(user_id: str, action: Dict[str, Any]) -> None: user_group_ids = get_user_governance_group_ids(normalized_user_id) item_policies = get_item_policies("global_action", action_id) + if any(policy_denies_principal(policy, normalized_user_id, user_group_ids) for policy in item_policies): + raise PermissionError(f"Governance policy blocks access to global action '{action_id}'.") + if not any(_passes_policy(policy, normalized_user_id, user_group_ids) for policy in item_policies): raise PermissionError(f"Governance policy blocks access to global action '{action_id}'.") diff --git a/application/single_app/functions_mcp_destinations.py b/application/single_app/functions_mcp_destinations.py index 1dc896c3e..abc556537 100644 --- a/application/single_app/functions_mcp_destinations.py +++ b/application/single_app/functions_mcp_destinations.py @@ -213,6 +213,8 @@ def _get_governance_group_ids_for_user(user_id): def _governance_item_policy_applies_to_user(policy, user_id, user_group_ids): if not isinstance(policy, dict): return False + if _governance_item_policy_denies_user(policy, user_id, user_group_ids): + return False if policy.get("allow_all", True): return True @@ -233,6 +235,33 @@ def _governance_item_policy_applies_to_user(policy, user_id, user_group_ids): return bool(allowed_groups.intersection(user_group_ids or set())) +def _governance_item_policy_denies_user(policy, user_id, user_group_ids): + if not isinstance(policy, dict): + return False + + normalized_user_id = str(user_id or "").strip() + denied_users = { + str(denied_user_id or "").strip() + for denied_user_id in policy.get("denied_users", []) + if str(denied_user_id or "").strip() + } + if normalized_user_id and normalized_user_id in denied_users: + return True + + denied_groups = { + str(denied_group_id or "").strip() + for denied_group_id in policy.get("denied_groups", []) + if str(denied_group_id or "").strip() + } + return bool(denied_groups.intersection(user_group_ids or set())) + + +def _policy_has_group_principal_rules(policy): + if not isinstance(policy, dict): + return False + return bool(policy.get("allowed_groups") or policy.get("denied_groups")) + + def _normalize_governance_destination_item_id(scope_type, item_id): normalized_item_id = str(item_id or "").strip() normalized_scope = normalize_mcp_destination_scope(scope_type) @@ -263,26 +292,31 @@ def _append_governance_destination_patterns(policy_config, user_id=""): normalized_user_id = str(user_id or "").strip() or _get_request_user_id() user_group_ids = None + def ensure_user_group_ids(): + nonlocal user_group_ids + if user_group_ids is None: + user_group_ids = _get_governance_group_ids_for_user(normalized_user_id) + return user_group_ids + for scope_type, entity_type in MCP_DESTINATION_ITEM_POLICY_ENTITY_TYPES.items(): patterns = [] scoped_group_patterns = {} + denied_patterns = [] + denied_scoped_group_patterns = {} for policy in _list_governance_item_policies(entity_type): - if not _governance_item_policy_applies_to_user( - policy, - normalized_user_id, - user_group_ids if user_group_ids is not None else set(), - ): - if user_group_ids is None: - user_group_ids = _get_governance_group_ids_for_user(normalized_user_id) - if _governance_item_policy_applies_to_user(policy, normalized_user_id, user_group_ids): - group_id, pattern = _normalize_governance_destination_item_id(scope_type, policy.get("item_id")) - if group_id: - scoped_group_patterns.setdefault(group_id, []).append(pattern) - else: - patterns.append(pattern) + group_ids_for_policy = ensure_user_group_ids() if _policy_has_group_principal_rules(policy) else set() + group_id, pattern = _normalize_governance_destination_item_id(scope_type, policy.get("item_id")) + + if _governance_item_policy_denies_user(policy, normalized_user_id, group_ids_for_policy): + if group_id: + denied_scoped_group_patterns.setdefault(group_id, []).append(pattern) + else: + denied_patterns.append(pattern) + continue + + if not _governance_item_policy_applies_to_user(policy, normalized_user_id, group_ids_for_policy): continue - group_id, pattern = _normalize_governance_destination_item_id(scope_type, policy.get("item_id")) if group_id: scoped_group_patterns.setdefault(group_id, []).append(pattern) else: @@ -297,6 +331,15 @@ def _append_governance_destination_patterns(policy_config, user_id=""): policy_config["group_patterns"].get(group_id, []), group_patterns, ) + policy_config["denied_scope_patterns"][scope_type] = _merge_unique_patterns( + policy_config["denied_scope_patterns"].get(scope_type, []), + denied_patterns, + ) + for group_id, group_patterns in denied_scoped_group_patterns.items(): + policy_config["denied_group_patterns"][group_id] = _merge_unique_patterns( + policy_config["denied_group_patterns"].get(group_id, []), + group_patterns, + ) return policy_config @@ -373,6 +416,12 @@ def get_mcp_destination_policy_config(settings=None, user_id=""): str(group_id): _coerce_pattern_list(patterns) for group_id, patterns in scoped_group_patterns.items() }, + "denied_scope_patterns": { + MCP_DESTINATION_SCOPE_PERSONAL: [], + MCP_DESTINATION_SCOPE_GROUP: [], + MCP_DESTINATION_SCOPE_GLOBAL: [], + }, + "denied_group_patterns": {}, } if not policy_config.get("enabled"): return policy_config @@ -563,6 +612,15 @@ def _allowed_patterns_for_scope(policy_config, scope_type, scope_id): return patterns +def _denied_patterns_for_scope(policy_config, scope_type, scope_id): + normalized_scope = normalize_mcp_destination_scope(scope_type) + patterns = [] + patterns.extend((policy_config.get("denied_scope_patterns") or {}).get(normalized_scope) or []) + if normalized_scope == MCP_DESTINATION_SCOPE_GROUP and scope_id: + patterns.extend((policy_config.get("denied_group_patterns") or {}).get(str(scope_id)) or []) + return patterns + + def infer_mcp_destination_scope(manifest, fallback_scope=MCP_DESTINATION_SCOPE_PERSONAL): """Infer an action scope from a stored MCP manifest when a caller does not pass one.""" if not isinstance(manifest, dict): @@ -621,6 +679,18 @@ def evaluate_mcp_destination_policy(manifest, scope_type=None, scope_id="", poli "scope_id": normalized_scope_id, } + denied_patterns = _denied_patterns_for_scope(policy, normalized_scope, normalized_scope_id) + for pattern in denied_patterns: + if _pattern_matches_destination(pattern, descriptor): + return { + "allowed": False, + "reason": "matched_destination_block_policy", + "descriptor": descriptor, + "matched_pattern": pattern, + "scope_type": normalized_scope, + "scope_id": normalized_scope_id, + } + allowed_patterns = _allowed_patterns_for_scope(policy, normalized_scope, normalized_scope_id) for pattern in allowed_patterns: if _pattern_matches_destination(pattern, descriptor): diff --git a/application/single_app/functions_mcp_server_governance.py b/application/single_app/functions_mcp_server_governance.py index eb216bdc0..0fc9f55a4 100644 --- a/application/single_app/functions_mcp_server_governance.py +++ b/application/single_app/functions_mcp_server_governance.py @@ -6,6 +6,8 @@ INBOUND_MCP_SYSTEM_SOURCE_POLICY_ID, get_explicit_item_policies, get_user_governance_group_ids, + policy_allows_principal, + policy_denies_principal, ) @@ -49,30 +51,8 @@ def _normalize_policy_value(value): return str(value or "").strip().lower() -def _normalize_policy_values(values): - if not isinstance(values, (list, tuple, set)): - return set() - return { - str(value or "").strip() - for value in values - if str(value or "").strip() - } - - def _policy_matches_principal(policy, user_id, group_ids): - if bool((policy or {}).get("allow_all", True)): - return True - - allowed_users = _normalize_policy_values((policy or {}).get("allowed_users", [])) - allowed_groups = _normalize_policy_values((policy or {}).get("allowed_groups", [])) - if not allowed_users and not allowed_groups: - return False - - normalized_user_id = str(user_id or "").strip() - if normalized_user_id and normalized_user_id in allowed_users: - return True - - return bool(set(group_ids or set()).intersection(allowed_groups)) + return policy_allows_principal(policy or {}, user_id, group_ids) def _evaluate_explicit_policy_group(policy_checks, user_id, group_ids, error, reason_prefix, ignored_policy_ids=None): @@ -104,6 +84,13 @@ def _evaluate_explicit_policy_group(policy_checks, user_id, group_ids, error, re if policy_id in ignored_policy_ids: continue inspected_policy_count += 1 + if policy_denies_principal(policy, user_id, group_ids): + return InboundMcpGovernanceDecision( + allowed=False, + error=error, + reason=f"{reason_prefix} denied by explicit policy block list.", + policy_id=policy_id, + ) if not _policy_matches_principal(policy, user_id, group_ids): continue effect = _normalize_policy_value(policy.get("effect") or "allow") diff --git a/application/single_app/route_backend_governance.py b/application/single_app/route_backend_governance.py index 9dafe1b1c..fc524409a 100644 --- a/application/single_app/route_backend_governance.py +++ b/application/single_app/route_backend_governance.py @@ -9,8 +9,10 @@ DEFAULT_FEATURE_POLICIES, bootstrap_default_feature_policies, delete_item_policy, + list_item_policies_by_policy_id, list_feature_policies, list_item_policies, + retarget_item_policy, upsert_feature_policy, upsert_item_policy, ) @@ -32,6 +34,8 @@ def _sanitize_policy_payload(payload): "allow_all": True, "allowed_users": [], "allowed_groups": [], + "denied_users": [], + "denied_groups": [], "policy_id": "", "policy_name": "", "resource_label": "", @@ -45,10 +49,20 @@ def _sanitize_policy_payload(payload): if not isinstance(allowed_groups, list): allowed_groups = [] + denied_users = payload.get("denied_users", []) + if not isinstance(denied_users, list): + denied_users = [] + + denied_groups = payload.get("denied_groups", []) + if not isinstance(denied_groups, list): + denied_groups = [] + return { "allow_all": bool(payload.get("allow_all", True)), "allowed_users": allowed_users, "allowed_groups": allowed_groups, + "denied_users": denied_users, + "denied_groups": denied_groups, "policy_id": str(payload.get("policy_id") or "").strip(), "policy_name": str(payload.get("policy_name") or "").strip(), "resource_label": str(payload.get("resource_label") or "").strip(), @@ -64,6 +78,15 @@ def _sanitize_item_policy_request_payload(payload): return entity_type, item_id, _sanitize_policy_payload(payload) +def _normalize_original_item_policy_target(payload): + if not isinstance(payload, dict): + return "", "" + + original_entity_type = str(payload.get("original_entity_type") or "").strip().lower() + original_item_id = str(payload.get("original_item_id") or "").strip() + return original_entity_type, original_item_id + + def _normalize_review_pagination(args): try: page = int(str(args.get("page") or "1").strip()) @@ -90,9 +113,57 @@ def _build_item_policy_search_haystack(policy): 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), + " ".join(str(value or "") for value in policy.get("denied_users", []) if value), + " ".join(str(value or "") for value in policy.get("denied_groups", []) if value), ]).lower() +def _item_policy_target_conflicts( + entity_type: str, + item_id: str, + payload, + original_entity_type: str = "", + original_item_id: str = "", +) -> bool: + policy_id = str((payload or {}).get("policy_id") or "").strip() + if not policy_id: + return False + + normalized_entity_type = str(entity_type or "").strip().lower() + normalized_item_id = str(item_id or "").strip() + allowed_targets = {(normalized_entity_type, normalized_item_id)} + + normalized_original_entity_type = str(original_entity_type or "").strip().lower() + normalized_original_item_id = str(original_item_id or "").strip() + if normalized_original_entity_type and normalized_original_item_id: + allowed_targets.add((normalized_original_entity_type, normalized_original_item_id)) + + for existing_policy in list_item_policies_by_policy_id(policy_id): + existing_entity_type = str((existing_policy or {}).get("entity_type") or "").strip().lower() + existing_item_id = str((existing_policy or {}).get("item_id") or "").strip() + if (existing_entity_type, existing_item_id) not in allowed_targets: + return True + + return False + + +def _item_policy_target_changed( + entity_type: str, + item_id: str, + original_entity_type: str, + original_item_id: str, +) -> bool: + normalized_original_entity_type = str(original_entity_type or "").strip().lower() + normalized_original_item_id = str(original_item_id or "").strip() + if not normalized_original_entity_type or not normalized_original_item_id: + return False + + return ( + str(entity_type or "").strip().lower() != normalized_original_entity_type + or str(item_id or "").strip() != normalized_original_item_id + ) + + def register_route_backend_governance(bp): @bp.route('/api/admin/governance/policies', methods=['GET']) @swagger_route(security=get_auth_security()) @@ -218,20 +289,54 @@ def update_governance_item_policy_route(entity_type, item_id): 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 {}) + request_payload = request.get_json(silent=True) or {} + payload = _sanitize_policy_payload(request_payload) + original_entity_type, original_item_id = _normalize_original_item_policy_target(request_payload) + if _item_policy_target_conflicts( + normalized_entity_type, + normalized_item_id, + payload, + original_entity_type, + original_item_id, + ): + return jsonify({ + 'error': 'This item governance policy ID is already assigned to another delegated item.' + }), 409 + actor_user_id = str(get_current_user_id() or '').strip() actor_email = _normalize_actor_email() + target_changed = _item_policy_target_changed( + normalized_entity_type, + normalized_item_id, + original_entity_type, + original_item_id, + ) + if target_changed and not str(payload.get("policy_id") or "").strip(): + return jsonify({'error': 'policy_id is required to retarget an item governance policy.'}), 400 try: - 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, - ) + if target_changed: + updated = retarget_item_policy( + entity_type=normalized_entity_type, + item_id=normalized_item_id, + payload=payload, + original_entity_type=original_entity_type, + original_item_id=original_item_id, + actor_user_id=actor_user_id, + actor_email=actor_email, + ) + else: + 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, + ) except PermissionError: return jsonify({'error': 'You are not authorized to update this governance policy.'}), 403 + except ValueError: + return jsonify({'error': 'Original item governance policy not found.'}), 404 return jsonify({'policy': updated}), 200 @bp.route('/api/admin/governance/item-policies', methods=['POST']) @@ -239,24 +344,56 @@ def update_governance_item_policy_route(entity_type, item_id): @login_required @admin_required def upsert_governance_item_policy_json_route(): - normalized_entity_type, normalized_item_id, payload = _sanitize_item_policy_request_payload( - request.get_json(silent=True) or {} - ) + request_payload = request.get_json(silent=True) or {} + normalized_entity_type, normalized_item_id, payload = _sanitize_item_policy_request_payload(request_payload) if not normalized_entity_type or not normalized_item_id: return jsonify({'error': 'entity_type and item_id are required.'}), 400 + original_entity_type, original_item_id = _normalize_original_item_policy_target(request_payload) + if _item_policy_target_conflicts( + normalized_entity_type, + normalized_item_id, + payload, + original_entity_type, + original_item_id, + ): + return jsonify({ + 'error': 'This item governance policy ID is already assigned to another delegated item.' + }), 409 actor_user_id = str(get_current_user_id() or '').strip() actor_email = _normalize_actor_email() + target_changed = _item_policy_target_changed( + normalized_entity_type, + normalized_item_id, + original_entity_type, + original_item_id, + ) + if target_changed and not str(payload.get("policy_id") or "").strip(): + return jsonify({'error': 'policy_id is required to retarget an item governance policy.'}), 400 + try: - 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, - ) + if target_changed: + updated = retarget_item_policy( + entity_type=normalized_entity_type, + item_id=normalized_item_id, + payload=payload, + original_entity_type=original_entity_type, + original_item_id=original_item_id, + actor_user_id=actor_user_id, + actor_email=actor_email, + ) + else: + 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, + ) except PermissionError: return jsonify({'error': 'You are not authorized to update this governance policy.'}), 403 + except ValueError: + return jsonify({'error': 'Original item governance policy not found.'}), 404 return jsonify({'policy': updated}), 200 @bp.route('/api/admin/governance/item-policies/delete', methods=['POST']) diff --git a/application/single_app/static/js/admin/admin_governance.js b/application/single_app/static/js/admin/admin_governance.js index fc8b924ef..b5a2ae17a 100644 --- a/application/single_app/static/js/admin/admin_governance.js +++ b/application/single_app/static/js/admin/admin_governance.js @@ -160,6 +160,8 @@ let governanceAllowListEditorContext = null; let governanceItemPolicyDeleteModal = null; let governanceItemPolicyDeleteContext = null; let governanceItemPolicyEditorModal = null; +let governanceItemPolicyEditorContext = null; +let governanceItemPolicyUsersModal = null; let governancePolicyHelpModal = null; const governanceItemLookupState = { global_endpoint: [], @@ -273,6 +275,15 @@ function buildAllowListSummary(users, groups, allowAll = false) { return `${usersCount} user${usersCount === 1 ? '' : 's'}, ${groupsCount} group${groupsCount === 1 ? '' : 's'}`; } +function buildBlockListSummary(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 users or groups blocked'; + } + return `${usersCount} user${usersCount === 1 ? '' : 's'}, ${groupsCount} group${groupsCount === 1 ? '' : 's'} blocked`; +} + function getGovernanceUsersInputForFeatureRow(row) { return row?.querySelector('.governance-allowed-users') || null; } @@ -281,6 +292,14 @@ function getGovernanceGroupsInputForFeatureRow(row) { return row?.querySelector('.governance-allowed-groups') || null; } +function getGovernanceDeniedUsersInputForFeatureRow(row) { + return row?.querySelector('.governance-denied-users') || null; +} + +function getGovernanceDeniedGroupsInputForFeatureRow(row) { + return row?.querySelector('.governance-denied-groups') || null; +} + function getGovernanceFeatureAllowAllInput(row) { return row?.querySelector('.governance-allow-all') || null; } @@ -297,6 +316,14 @@ function getItemGroupsInput() { return document.getElementById('governance-item-groups'); } +function getItemDeniedUsersInput() { + return document.getElementById('governance-item-denied-users'); +} + +function getItemDeniedGroupsInput() { + return document.getElementById('governance-item-denied-groups'); +} + function getItemEntityTypeInput() { return document.getElementById('governance-item-entity-type'); } @@ -371,6 +398,7 @@ function buildGovernanceItemLookupOption(option) { const label = option.subtitle ? `${option.label} (${option.subtitle})` : option.label; const element = document.createElement('option'); element.value = option.value; + element.dataset.resourceLabel = option.label; element.textContent = label; return element; } @@ -592,6 +620,7 @@ function renderGovernanceItemLookupOptions(entityType, preferredValue = '') { } else { itemIdInput.value = ''; } + syncGovernanceItemResourceLabelFromSelection(); const hint = GOVERNANCE_ITEM_LOOKUP_HINTS[entityType] || 'Select a delegated item.'; if (options.length > 0) { @@ -602,10 +631,41 @@ function renderGovernanceItemLookupOptions(entityType, preferredValue = '') { } } +function isGovernanceItemPolicyEditMode() { + return Boolean(governanceItemPolicyEditorContext?.originalPolicyId); +} + +function applyGovernanceItemTargetEditState() { + const entityType = normalizeGovernanceItemEntityType(getItemEntityTypeInput()?.value || ''); + const usesCustomItemId = isGovernanceCustomItemIdEntityType(entityType); + const entityTypeInput = getItemEntityTypeInput(); + const itemIdInput = getItemIdInput(); + const itemIdCustomInput = getItemIdCustomInput(); + const filterInput = getItemLookupFilterInput(); + const refreshButton = document.getElementById('governance-item-id-refresh-btn'); + + if (entityTypeInput) { + entityTypeInput.disabled = false; + } + if (itemIdInput) { + itemIdInput.disabled = usesCustomItemId; + } + if (itemIdCustomInput) { + itemIdCustomInput.disabled = !usesCustomItemId; + } + if (filterInput) { + filterInput.disabled = usesCustomItemId; + } + if (refreshButton) { + refreshButton.disabled = usesCustomItemId; + } +} + async function refreshGovernanceItemLookup(entityType, forceReload = false, preferredValue = '') { const normalizedEntityType = normalizeGovernanceItemEntityType(entityType); if (isGovernanceCustomItemIdEntityType(normalizedEntityType)) { syncGovernanceItemIdMode(normalizedEntityType, preferredValue); + applyGovernanceItemTargetEditState(); return; } @@ -625,27 +685,37 @@ async function refreshGovernanceItemLookup(entityType, forceReload = false, pref if (refreshButton) { refreshButton.disabled = false; } + applyGovernanceItemTargetEditState(); } } function updateFeatureAllowListSummary(row) { const usersInput = getGovernanceUsersInputForFeatureRow(row); const groupsInput = getGovernanceGroupsInputForFeatureRow(row); + const deniedUsersInput = getGovernanceDeniedUsersInputForFeatureRow(row); + const deniedGroupsInput = getGovernanceDeniedGroupsInputForFeatureRow(row); const summaryEl = row?.querySelector('.governance-allowlist-summary'); - if (!usersInput || !groupsInput || !summaryEl) { + const blockSummaryEl = row?.querySelector('.governance-blocklist-summary'); + if (!usersInput || !groupsInput || !deniedUsersInput || !deniedGroupsInput || !summaryEl || !blockSummaryEl) { return; } const users = splitPrincipalList(usersInput.value); const groups = splitPrincipalList(groupsInput.value); + const deniedUsers = splitPrincipalList(deniedUsersInput.value); + const deniedGroups = splitPrincipalList(deniedGroupsInput.value); summaryEl.textContent = buildAllowListSummary(users, groups, getGovernanceFeatureAllowAllInput(row)?.checked); + blockSummaryEl.textContent = buildBlockListSummary(deniedUsers, deniedGroups); } function updateItemAllowListSummary() { const usersInput = getItemUsersInput(); const groupsInput = getItemGroupsInput(); + const deniedUsersInput = getItemDeniedUsersInput(); + const deniedGroupsInput = getItemDeniedGroupsInput(); const summaryInput = document.getElementById('governance-item-allowlist-summary'); - if (!usersInput || !groupsInput || !summaryInput) { + const blockSummaryInput = document.getElementById('governance-item-blocklist-summary'); + if (!usersInput || !groupsInput || !deniedUsersInput || !deniedGroupsInput || !summaryInput || !blockSummaryInput) { return; } @@ -654,6 +724,10 @@ function updateItemAllowListSummary() { splitPrincipalList(groupsInput.value), getItemAllowAllInput()?.checked, ); + blockSummaryInput.value = buildBlockListSummary( + splitPrincipalList(deniedUsersInput.value), + splitPrincipalList(deniedGroupsInput.value), + ); } function applyFeatureAllowAllUiState(row) { @@ -661,7 +735,9 @@ function applyFeatureAllowAllUiState(row) { const editButton = row?.querySelector('.governance-edit-feature-allowlist-btn'); const usersInput = getGovernanceUsersInputForFeatureRow(row); const groupsInput = getGovernanceGroupsInputForFeatureRow(row); - if (!allowAllInput || !usersInput || !groupsInput) { + const deniedUsersInput = getGovernanceDeniedUsersInputForFeatureRow(row); + const deniedGroupsInput = getGovernanceDeniedGroupsInputForFeatureRow(row); + if (!allowAllInput || !usersInput || !groupsInput || !deniedUsersInput || !deniedGroupsInput) { return; } @@ -682,7 +758,9 @@ function applyItemAllowAllUiState() { const allowedPrincipalsControls = document.getElementById('governance-item-allowed-principals-controls'); const usersInput = getItemUsersInput(); const groupsInput = getItemGroupsInput(); - if (!allowAllInput || !usersInput || !groupsInput) { + const deniedUsersInput = getItemDeniedUsersInput(); + const deniedGroupsInput = getItemDeniedGroupsInput(); + if (!allowAllInput || !usersInput || !groupsInput || !deniedUsersInput || !deniedGroupsInput) { return; } @@ -797,6 +875,8 @@ function syncGovernanceItemIdMode(entityType, preferredValue = '') { if (usesCustomItemId) { setGovernanceItemLookupStatus(GOVERNANCE_ITEM_LOOKUP_HINTS[normalizedEntityType] || 'Enter an MCP destination pattern.', 'muted'); } + syncGovernanceItemResourceLabelFromSelection(); + applyGovernanceItemTargetEditState(); } function mapGovernanceLevelToToastVariant(level = 'info') { @@ -957,40 +1037,58 @@ function buildFeaturePolicyRow(policy) { allowAll.checked = Boolean(policy.allow_all); allowAllCell.appendChild(allowAll); - const usersCell = document.createElement('td'); + const allowListCell = 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, allowAll.checked); - usersCell.appendChild(usersSummary); + allowListCell.appendChild(usersInput); - 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); + allowListCell.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); + const allowListSummary = document.createElement('div'); + allowListSummary.className = 'small text-body-secondary governance-allowlist-summary'; + allowListSummary.textContent = buildAllowListSummary(policy.allowed_users, policy.allowed_groups, allowAll.checked); + allowListCell.appendChild(allowListSummary); + + 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'; + allowListCell.appendChild(usersEditButton); + + const blockListCell = document.createElement('td'); + const deniedUsersInput = document.createElement('input'); + deniedUsersInput.type = 'text'; + deniedUsersInput.className = 'form-control form-control-sm governance-denied-users d-none'; + deniedUsersInput.value = joinPrincipalList(policy.denied_users); + blockListCell.appendChild(deniedUsersInput); + + const deniedGroupsInput = document.createElement('input'); + deniedGroupsInput.type = 'text'; + deniedGroupsInput.className = 'form-control form-control-sm governance-denied-groups d-none'; + deniedGroupsInput.value = joinPrincipalList(policy.denied_groups); + blockListCell.appendChild(deniedGroupsInput); + + const blockListSummary = document.createElement('div'); + blockListSummary.className = 'small text-body-secondary governance-blocklist-summary'; + blockListSummary.textContent = buildBlockListSummary(policy.denied_users, policy.denied_groups); + blockListCell.appendChild(blockListSummary); + + const blockEditButton = document.createElement('button'); + blockEditButton.type = 'button'; + blockEditButton.className = 'btn btn-sm btn-outline-danger mt-1 governance-edit-feature-blocklist-btn'; + blockEditButton.textContent = 'Edit Block List'; + blockListCell.appendChild(blockEditButton); row.appendChild(featureCell); row.appendChild(allowAllCell); - row.appendChild(usersCell); - row.appendChild(groupsCell); + row.appendChild(allowListCell); + row.appendChild(blockListCell); applyFeatureAllowAllUiState(row); syncGovernanceFeaturePolicyRowVisibility(row); @@ -1037,8 +1135,10 @@ async function saveFeaturePolicies() { const allowAllInput = row.querySelector('.governance-allow-all'); const usersInput = row.querySelector('.governance-allowed-users'); const groupsInput = row.querySelector('.governance-allowed-groups'); + const deniedUsersInput = row.querySelector('.governance-denied-users'); + const deniedGroupsInput = row.querySelector('.governance-denied-groups'); - if (!featureKey || !allowAllInput || !usersInput || !groupsInput) { + if (!featureKey || !allowAllInput || !usersInput || !groupsInput || !deniedUsersInput || !deniedGroupsInput) { continue; } @@ -1046,6 +1146,8 @@ async function saveFeaturePolicies() { allow_all: allowAllInput.checked, allowed_users: splitPrincipalList(usersInput.value), allowed_groups: splitPrincipalList(groupsInput.value), + denied_users: splitPrincipalList(deniedUsersInput.value), + denied_groups: splitPrincipalList(deniedGroupsInput.value), }; const response = await fetch(`/api/admin/governance/policies/${encodeURIComponent(featureKey)}`, { @@ -1066,20 +1168,107 @@ async function saveFeaturePolicies() { showGovernanceToast('Governance feature policies saved successfully.', 'success'); } +function buildGovernanceItemPolicyDetails(policy) { + const policyData = policy || {}; + const entityType = normalizeGovernanceItemEntityType(policyData.entity_type); + const itemId = String(policyData.item_id || ''); + const policyId = String(policyData.policy_id || ''); + const resourceLabel = String(policyData.resource_label || '').trim(); + const policyName = String(policyData.policy_name || '').trim() || buildDefaultItemPolicyName(entityType, itemId, resourceLabel); + return { + entity_type: entityType, + item_id: itemId, + policy_id: policyId, + policy_name: policyName, + resource_label: resourceLabel, + allow_all: Boolean(policyData.allow_all), + allowed_users: uniquePrincipalList(Array.isArray(policyData.allowed_users) ? policyData.allowed_users : []), + allowed_groups: uniquePrincipalList(Array.isArray(policyData.allowed_groups) ? policyData.allowed_groups : []), + denied_users: uniquePrincipalList(Array.isArray(policyData.denied_users) ? policyData.denied_users : []), + denied_groups: uniquePrincipalList(Array.isArray(policyData.denied_groups) ? policyData.denied_groups : []), + system_managed: Boolean(policyData.system_managed), + managed_reason: String(policyData.managed_reason || '').trim(), + }; +} + +function applyGovernanceItemPolicyActionDataset(button, policyDetails) { + button.dataset.entityType = policyDetails.entity_type; + button.dataset.itemId = policyDetails.item_id; + button.dataset.policyId = policyDetails.policy_id; + button.dataset.policyName = policyDetails.policy_name; + button.dataset.resourceLabel = policyDetails.resource_label; + button.dataset.allowAll = policyDetails.allow_all ? 'true' : 'false'; + button.dataset.allowedUsers = JSON.stringify(policyDetails.allowed_users); + button.dataset.allowedGroups = JSON.stringify(policyDetails.allowed_groups); + button.dataset.deniedUsers = JSON.stringify(policyDetails.denied_users); + button.dataset.deniedGroups = JSON.stringify(policyDetails.denied_groups); +} + +function readGovernanceItemPolicyActionDataset(button) { + return { + entity_type: button?.dataset?.entityType || '', + item_id: button?.dataset?.itemId || '', + policy_id: button?.dataset?.policyId || '', + policy_name: button?.dataset?.policyName || '', + resource_label: button?.dataset?.resourceLabel || '', + allow_all: button?.dataset?.allowAll === 'true', + allowed_users: parseGovernancePrincipalDataset(button?.dataset?.allowedUsers), + allowed_groups: parseGovernancePrincipalDataset(button?.dataset?.allowedGroups), + denied_users: parseGovernancePrincipalDataset(button?.dataset?.deniedUsers), + denied_groups: parseGovernancePrincipalDataset(button?.dataset?.deniedGroups), + }; +} + +function appendGovernanceItemPolicyNameSuffix(policyName, suffix) { + const baseName = String(policyName || '').trim() || 'Delegated Item Policy'; + const suffixLabel = String(suffix || '').trim(); + return suffixLabel ? `${baseName} (${suffixLabel})` : baseName; +} + +function cloneGovernanceItemPolicyForAction(policy, actionType) { + const policyDetails = buildGovernanceItemPolicyDetails(policy); + const inverse = actionType === 'inverse'; + return { + ...policyDetails, + policy_id: '', + policy_name: appendGovernanceItemPolicyNameSuffix(policyDetails.policy_name, inverse ? 'inverse' : 'duplicate'), + allowed_users: inverse ? [...policyDetails.denied_users] : [...policyDetails.allowed_users], + allowed_groups: inverse ? [...policyDetails.denied_groups] : [...policyDetails.allowed_groups], + denied_users: inverse ? [...policyDetails.allowed_users] : [...policyDetails.denied_users], + denied_groups: inverse ? [...policyDetails.allowed_groups] : [...policyDetails.denied_groups], + system_managed: false, + managed_reason: '', + }; +} + +function createGovernanceItemPolicyActionButton(policyDetails, options) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = options.className; + button.textContent = options.label; + if (options.title) { + button.title = options.title; + } + if (options.disabled) { + button.disabled = true; + } + applyGovernanceItemPolicyActionDataset(button, policyDetails); + return button; +} + function buildItemPolicyRow(policy) { const row = document.createElement('tr'); + const policyDetails = buildGovernanceItemPolicyDetails(policy); + const entityType = policyDetails.entity_type; + const itemId = policyDetails.item_id; + const policyId = policyDetails.policy_id; + const resourceLabel = policyDetails.resource_label; + const policyName = policyDetails.policy_name; + const allowAll = policyDetails.allow_all; + const systemManaged = policyDetails.system_managed; const policyCell = document.createElement('td'); const entityTypeCell = document.createElement('td'); - const entityType = normalizeGovernanceItemEntityType(policy.entity_type); - const itemId = String(policy.item_id || ''); - const policyId = String(policy.policy_id || ''); - const resourceLabel = String(policy.resource_label || '').trim(); - const policyName = String(policy.policy_name || '').trim() || buildDefaultItemPolicyName(entityType, itemId, resourceLabel); - 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 : []; - const systemManaged = Boolean(policy.system_managed); const policyNameEl = document.createElement('div'); policyNameEl.className = 'fw-semibold'; @@ -1091,7 +1280,7 @@ function buildItemPolicyRow(policy) { systemBadge.textContent = 'System-managed'; policyCell.appendChild(systemBadge); - const reason = String(policy.managed_reason || '').trim(); + const reason = policyDetails.managed_reason; if (reason) { const reasonElement = document.createElement('div'); reasonElement.className = 'small text-muted mt-1'; @@ -1113,52 +1302,48 @@ function buildItemPolicyRow(policy) { const allowAllCell = document.createElement('td'); allowAllCell.textContent = allowAll ? 'Yes' : 'No'; - const usersCell = document.createElement('td'); - renderGovernancePrincipalReviewCell(usersCell, 'users', allowedUsers); + const actionsCell = document.createElement('td'); + const actionsGroup = document.createElement('div'); + actionsGroup.className = 'd-flex flex-wrap gap-2'; - const groupsCell = document.createElement('td'); - renderGovernancePrincipalReviewCell(groupsCell, 'groups', allowedGroups); + actionsGroup.appendChild(createGovernanceItemPolicyActionButton(policyDetails, { + className: 'btn btn-sm btn-outline-secondary governance-show-item-policy-users-btn', + label: 'Show Users', + title: 'Show allowed and blocked users and groups for this policy.', + })); - 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.policyId = policyId; - editButton.dataset.policyName = policyName; - editButton.dataset.resourceLabel = resourceLabel; - editButton.dataset.allowAll = allowAll ? 'true' : 'false'; - editButton.dataset.allowedUsers = JSON.stringify(allowedUsers); - editButton.dataset.allowedGroups = JSON.stringify(allowedGroups); - editButton.disabled = systemManaged; - if (systemManaged) { - editButton.title = 'System-managed policies cannot be edited.'; - } - 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; - deleteButton.dataset.policyId = policyId; - deleteButton.dataset.policyName = policyName; - deleteButton.disabled = systemManaged; - if (systemManaged) { - deleteButton.title = 'System-managed policies cannot be deleted.'; - } - actionsCell.appendChild(deleteButton); + actionsGroup.appendChild(createGovernanceItemPolicyActionButton(policyDetails, { + className: 'btn btn-sm btn-outline-secondary governance-duplicate-item-policy-btn', + label: 'Duplicate', + title: 'Clone this policy with a new ID.', + })); + + actionsGroup.appendChild(createGovernanceItemPolicyActionButton(policyDetails, { + className: 'btn btn-sm btn-outline-secondary governance-inverse-item-policy-btn', + label: 'Inverse', + title: 'Clone this policy and swap allowed and blocked users and groups.', + })); + + actionsGroup.appendChild(createGovernanceItemPolicyActionButton(policyDetails, { + className: 'btn btn-sm btn-outline-primary governance-edit-item-policy-btn', + label: 'Edit', + title: systemManaged ? 'System-managed policies cannot be edited.' : '', + disabled: systemManaged, + })); + + const deleteButton = createGovernanceItemPolicyActionButton(policyDetails, { + className: 'btn btn-sm btn-outline-danger governance-delete-item-policy-btn', + label: 'Delete', + title: systemManaged ? 'System-managed policies cannot be deleted.' : '', + disabled: systemManaged, + }); + actionsGroup.appendChild(deleteButton); + actionsCell.appendChild(actionsGroup); row.appendChild(policyCell); row.appendChild(entityTypeCell); row.appendChild(itemIdCell); row.appendChild(allowAllCell); - row.appendChild(usersCell); - row.appendChild(groupsCell); row.appendChild(actionsCell); return row; @@ -1213,6 +1398,102 @@ function renderGovernancePrincipalReviewCell(cell, listType, ids, hydrateMissing } } +function ensureGovernanceItemPolicyUsersModal() { + let modalElement = document.getElementById('governance-item-policy-users-modal'); + if (!modalElement) { + const modalMarkup = ` + + `; + + const wrapper = document.createElement('div'); + // xss-check: ignore - modalMarkup is a static Bootstrap shell; untrusted values are populated with DOM APIs. + wrapper.innerHTML = modalMarkup.trim(); + modalElement = wrapper.firstElementChild; + document.body.appendChild(modalElement); + } + + if (!governanceItemPolicyUsersModal) { + governanceItemPolicyUsersModal = bootstrap.Modal.getOrCreateInstance(modalElement); + } + + return modalElement; +} + +function appendGovernanceItemPolicyUsersRow(tbody, label, listType, ids) { + const normalizedIds = uniquePrincipalList(ids); + const row = document.createElement('tr'); + + const labelCell = document.createElement('td'); + labelCell.className = 'fw-semibold'; + labelCell.textContent = label; + + const countCell = document.createElement('td'); + countCell.textContent = String(normalizedIds.length); + + const principalsCell = document.createElement('td'); + renderGovernancePrincipalReviewCell(principalsCell, listType, normalizedIds); + + row.appendChild(labelCell); + row.appendChild(countCell); + row.appendChild(principalsCell); + tbody.appendChild(row); +} + +function openGovernanceItemPolicyUsersModal(policy) { + const modalElement = ensureGovernanceItemPolicyUsersModal(); + const policyDetails = buildGovernanceItemPolicyDetails(policy); + const title = modalElement.querySelector('#governance-item-policy-users-title'); + const summary = modalElement.querySelector('#governance-item-policy-users-summary'); + const body = modalElement.querySelector('#governance-item-policy-users-body'); + + if (title) { + title.textContent = `Principals: ${policyDetails.policy_name}`; + } + if (summary) { + const entityLabel = buildItemPolicyEntityLabel(policyDetails.entity_type); + const itemLabel = policyDetails.resource_label || policyDetails.item_id || 'Unspecified item'; + summary.textContent = `${entityLabel} - ${itemLabel} - Allow All: ${policyDetails.allow_all ? 'Yes' : 'No'}`; + } + if (body) { + body.textContent = ''; + appendGovernanceItemPolicyUsersRow(body, 'Allow Users', 'users', policyDetails.allowed_users); + appendGovernanceItemPolicyUsersRow(body, 'Allow Groups', 'groups', policyDetails.allowed_groups); + appendGovernanceItemPolicyUsersRow(body, 'Block Users', 'users', policyDetails.denied_users); + appendGovernanceItemPolicyUsersRow(body, 'Block Groups', 'groups', policyDetails.denied_groups); + } + + governanceItemPolicyUsersModal?.show(); +} + function parseGovernancePrincipalDataset(value) { try { const parsed = JSON.parse(value || '[]'); @@ -1232,12 +1513,36 @@ function ensureGovernanceItemIdOption(itemIdInput, itemId, label = '') { if (!hasOption) { const option = document.createElement('option'); option.value = normalizedItemId; + option.dataset.resourceLabel = label || normalizedItemId; option.textContent = label ? `${label} (${truncateGovernanceId(normalizedItemId)})` : normalizedItemId; itemIdInput.appendChild(option); } itemIdInput.value = normalizedItemId; } +function syncGovernanceItemResourceLabelFromSelection() { + const entityType = normalizeGovernanceItemEntityType(getItemEntityTypeInput()?.value || ''); + const itemId = getCurrentGovernanceItemIdValue(); + const resourceLabelInput = getItemResourceLabelInput(); + if (!resourceLabelInput) { + return; + } + + if (!itemId) { + resourceLabelInput.value = ''; + return; + } + + if (isGovernanceCustomItemIdEntityType(entityType)) { + resourceLabelInput.value = itemId; + return; + } + + const itemIdInput = getItemIdInput(); + const selectedOption = itemIdInput?.selectedOptions?.[0]; + resourceLabelInput.value = String(selectedOption?.dataset?.resourceLabel || selectedOption?.textContent || itemId).trim(); +} + function resetGovernanceItemEditorSelectionViewState() { governanceItemEditorSelectionViewState.users.search = ''; governanceItemEditorSelectionViewState.users.page = 1; @@ -1462,18 +1767,29 @@ async function openGovernanceItemPolicyEditor(policy = null) { const allowAllInput = getItemAllowAllInput(); const usersInput = getItemUsersInput(); const groupsInput = getItemGroupsInput(); + const deniedUsersInput = getItemDeniedUsersInput(); + const deniedGroupsInput = getItemDeniedGroupsInput(); - if (!entityTypeInput || !itemIdInput || !allowAllInput || !usersInput || !groupsInput) { + if (!entityTypeInput || !itemIdInput || !allowAllInput || !usersInput || !groupsInput || !deniedUsersInput || !deniedGroupsInput) { return; } const entityType = normalizeGovernanceItemEntityType(policy?.entity_type) || 'global_agent'; const itemId = String(policy?.item_id || '').trim(); + const policyId = String(policy?.policy_id || '').trim(); const resourceLabel = String(policy?.resource_label || '').trim(); const policyName = String(policy?.policy_name || '').trim() || buildDefaultItemPolicyName(entityType, itemId, resourceLabel); + governanceItemPolicyEditorContext = policyId + ? { + originalEntityType: entityType, + originalItemId: itemId, + originalPolicyId: policyId, + } + : null; + if (title) { - title.textContent = policy?.policy_id ? 'Edit Delegated Item Policy' : 'New Delegated Item Policy'; + title.textContent = policyId ? 'Edit Delegated Item Policy' : 'New Delegated Item Policy'; } if (itemFilterInput) { itemFilterInput.value = ''; @@ -1481,7 +1797,7 @@ async function openGovernanceItemPolicyEditor(policy = null) { entityTypeInput.value = entityType; if (policyIdInput) { - policyIdInput.value = String(policy?.policy_id || '').trim(); + policyIdInput.value = policyId; } if (policyNameInput) { policyNameInput.value = policyName; @@ -1504,14 +1820,22 @@ async function openGovernanceItemPolicyEditor(policy = null) { await refreshGovernanceItemLookup(entityType, false, itemId); ensureGovernanceItemIdOption(itemIdInput, itemId, resourceLabel); } + applyGovernanceItemTargetEditState(); + if (policyId) { + setGovernanceItemLookupStatus('Editing an existing delegated item policy. Changing the target will move this policy to the selected delegated item when saved.', 'warning'); + } allowAllInput.checked = policy ? Boolean(policy.allow_all) : true; usersInput.value = joinPrincipalList(policy?.allowed_users || []); groupsInput.value = joinPrincipalList(policy?.allowed_groups || []); + deniedUsersInput.value = joinPrincipalList(policy?.denied_users || []); + deniedGroupsInput.value = joinPrincipalList(policy?.denied_groups || []); applyItemAllowAllUiState(); governanceItemPolicyEditorModal?.show(); - if (isGovernanceCustomItemIdEntityType(entityType)) { + if (policyId) { + policyNameInput?.focus(); + } else if (isGovernanceCustomItemIdEntityType(entityType)) { itemIdCustomInput?.focus(); } else { itemIdInput.focus(); @@ -1541,6 +1865,8 @@ async function openGovernanceDelegatedItemEditorFromResource(options = {}) { allow_all: true, allowed_users: [], allowed_groups: [], + denied_users: [], + denied_groups: [], }); } @@ -1561,6 +1887,8 @@ async function openGovernanceMcpDestinationPolicyEditor(entityType) { allow_all: true, allowed_users: [], allowed_groups: [], + denied_users: [], + denied_groups: [], }); } @@ -1583,6 +1911,8 @@ async function openGovernanceInboundMcpPolicyEditor(options = {}) { allow_all: false, allowed_users: [], allowed_groups: [], + denied_users: [], + denied_groups: [], }); } @@ -1678,12 +2008,12 @@ function ensureGovernanceItemPolicyEditorModal() { @@ -300,7 +300,7 @@

- The user is blocked. Feature policy is evaluated first. Item-level whitelists only narrow or organize access after the user passes the feature policy, so they cannot grant access to a feature the user is not allowed to use. + The user is blocked. Feature policy is evaluated first. Item-level allow lists only narrow or organize access after the user passes the feature policy, so they cannot grant access to a feature the user is not allowed to use.
diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 43a00f0ed..9827b8f3f 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -2468,15 +2468,15 @@
Feature Policies
-

Set allow-all or explicit allowlists for each governed feature.

+

Set allow-all, explicit allow lists, and block lists for each governed feature. Block lists override allow settings.

- - + + @@ -2494,7 +2494,7 @@
Delegated Item Policies

Manage delegated governance for configured global resources and action type entitlements that admins assign to specific users or groups.

@@ -2540,8 +2540,6 @@
Delegated Item PoliciesEntity Type
- - diff --git a/docs/explanation/features/GOVERNANCE_OPTIONS_PLAN.md b/docs/explanation/features/GOVERNANCE_OPTIONS_PLAN.md new file mode 100644 index 000000000..5baa88c7f --- /dev/null +++ b/docs/explanation/features/GOVERNANCE_OPTIONS_PLAN.md @@ -0,0 +1,410 @@ +# Governance Options Implementation Plan + +Planning baseline version: **0.250.185** + +## Goal + +Add governance options that align with the existing SimpleChat governance model for: + +1. Plugin/action types: who can create or use each plugin family. +2. Workflows: who can create or manage personal and group workflows. +3. Content Safety: who can bypass checks or receive a different ruleset. +4. Endpoint/resource access separation: how admins can keep mutually exclusive user cohorts on the right APIM quota-backed endpoints without maintaining large positive allowlists. + +## Current Governance Model + +SimpleChat already has a reusable governance layer in `functions_governance.py`: + +- Feature policies are keyed by settings toggles such as `governance_user_actions`. +- Item policies are keyed by entity type and item id, such as `global_action` or `global_endpoint`. +- Policies use `allow_all`, `allowed_users`, and `allowed_groups`. +- Empty allowlists with `allow_all = false` intentionally deny all access. +- Policies do not currently support deny/block lists, so "everyone except this cohort" requires disabling `allow_all` and maintaining the complete allowed population. +- User group membership and public workspace membership are usable as governance cohorts. +- Policy decisions are cached per request and process with cache invalidation after policy updates. +- Admin policy management already exists in `route_backend_governance.py` and `admin_governance.js`. + +This means new governance options should extend the existing feature-policy and item-policy model instead of introducing a separate access-control system. + +## Existing Coverage and Gaps + +### Plugin/action type governance + +Status: **Partially implemented already.** + +Current support: + +- `personal_action_type`, `group_action_type`, and `global_action_type` item policies already exist. +- `ensure_action_type_access()` applies plugin/action type policies. +- Personal and group action save paths enforce action type access. +- Plugin type discovery filters types with `is_action_type_access_allowed()`. +- Semantic Kernel runtime loading filters personal, group, and global action manifests through governed helpers. +- Known action type aliases include SQL, OpenAPI, MCP, Microsoft Graph, Databricks, Snowflake, Tableau, Chart, Azure Maps, Blob Storage, and Document Search. + +Gaps to close: + +- Admin UI language mostly says "Action Type"; the requested feature uses "Plugin Type." We should clarify labels/help text as "Action/Plugin Type" without changing stored entity names. +- The lookup list depends on known aliases. Custom or future plugin types can be governed by raw normalized type id, but they may not appear in the UI lookup until the registry discovers them. +- Creation and runtime use are intentionally tied together for personal/group action types. If admins need separate "can create" vs "can use" semantics later, that should be a separate design because it changes current behavior. + +Recommended approach: + +1. Keep existing entity types and helpers. +2. Audit all plugin type sources so the UI can list both built-in aliases and discovered custom plugin types. +3. Update admin labels/help text to make the relationship between "actions" and "plugins" explicit. +4. Add regression coverage for an unknown/custom type, proving that a policy for the normalized type id governs it. + +### Workflow creation governance + +Status: **Not centralized in governance yet.** + +Current support: + +- Personal workflows are controlled by `allow_user_workflows`. +- Personal workflow access can require the `WorkflowUser` app role through `require_member_of_workflow_user`. +- Group workflows are controlled by `allow_group_workflows`. +- Group workflows can be restricted to assigned groups through `require_group_assignment_for_group_workflows` and `group_workflow_allowed_group_ids`. +- Group workflow management currently requires group Owner/Admin roles through `get_group_workflow_management_roles()`. +- Routes use `@workflow_user_required`, group role checks, and `@enabled_required(...)`. + +Gap: + +- There is no governance feature policy for "who can create or manage workflows" using the same admin governance UI as endpoints, agents, and actions. + +Recommended approach: + +1. Add feature policy keys: + - `governance_user_workflows` + - `governance_group_workflows` +2. Add settings defaults and Admin Settings toggles: + - `governance_user_workflows`: defaults to `false`. + - `governance_group_workflows`: defaults to `false`. + - Toggles should only be active when the corresponding workflow feature is enabled. +3. Enforce these policies only on authoring and management operations at first: + - Personal workflow create/update/delete. + - Personal workflow instruction drafting. + - Group workflow create/update/delete. + - Group workflow instruction drafting. +4. Do not initially block read/run/history of existing workflows unless explicitly requested. That preserves existing scheduled workflow behavior and avoids accidentally disabling operational workflows. +5. Keep existing checks as prerequisites: + - Feature enabled. + - `WorkflowUser` app role for personal workflows when configured. + - Group Owner/Admin role for group workflow management. + - Group workflow assignment rules. +6. Layer `ensure_governance_access(...)` after existing feature/role checks and before save/delete/draft work. + +Future optional extension: + +- Add item policy entity types `personal_workflow` and `group_workflow` if admins later need per-workflow run/read delegation controls. This is not required for the initial "who can create workflows" ask. + +### Content Safety governance + +Status: **Requested by existing issue #1048, not implemented in central governance yet.** + +Existing issue: + +- #1048: + +Current support: + +- Global Content Safety enablement uses `enable_content_safety`. +- Chat processing calls Azure Content Safety in both non-streaming and streaming paths. +- The current block rule is hard-coded in chat routes: + - block if max severity is greater than or equal to 4 + - block if any blocklist match exists +- Blocked prompts are saved to the safety container and returned as a safety-role message. +- Safety violation admin visibility is controlled separately by `require_member_of_safety_violation_admin`. + +Gaps: + +- No user/group governance policy can bypass Content Safety checks. +- No user/group governance policy can select a less strict or more strict ruleset. +- The blocking logic is duplicated in streaming and non-streaming chat paths. +- The hard-coded severity threshold is not modeled as an admin-managed ruleset. + +Recommended approach: + +1. Centralize evaluation in `functions_content_safety.py`: + - Add a helper that resolves the effective Content Safety decision for a user. + - Add a helper that evaluates Azure Content Safety responses against a ruleset. + - Reuse the helper from both chat paths to keep behavior consistent. +2. Add feature policy key: + - `governance_content_safety_bypass` +3. Add item policy entity type: + - `content_safety_ruleset` +4. Add settings defaults: + - `governance_content_safety_bypass`: defaults to `false`. + - `governance_content_safety_rulesets`: defaults to `false`. + - `content_safety_rulesets`: include a default ruleset that exactly matches current behavior. +5. Bypass behavior: + - Only evaluated when global Content Safety is enabled. + - If governance is enabled and the user passes `governance_content_safety_bypass`, skip the Content Safety call. + - Log the bypass with a safe static logging tag and metadata, without logging prompt content. +6. Ruleset behavior: + - Default ruleset preserves current behavior: severity >= 4 or any blocklist match blocks. + - Admins can define named rulesets with category thresholds and blocklist behavior. + - Policies on `content_safety_ruleset` determine who can receive a ruleset. + - If multiple rulesets match, use a deterministic precedence: + 1. explicit user allowlist match + 2. group/workspace cohort match + 3. default ruleset + - If no governed ruleset applies, use the default ruleset. +7. Keep bypass separate from rulesets: + - Bypass is a high-risk entitlement and should be obvious in the UI. + - Rulesets still call Content Safety but adjust enforcement thresholds. + +### Endpoint/resource access separation and block lists + +Status: **Not implemented in central governance yet.** + +Customer scenario: + +- Admins have multiple model endpoints backed by APIM token usage policies. +- One endpoint has a lower token threshold and another has a higher token threshold. +- High-threshold users should access the high-threshold endpoint. +- Low-threshold users should access the low-threshold endpoint. +- The current model makes the high endpoint easy: set `allow_all = false` and allow the high-access group. +- The current model makes the low endpoint harder at scale: leaving `allow_all = true` also lets the high group use the low endpoint, but setting `allow_all = false` forces admins to maintain a large allowlist that changes whenever normal users onboard. + +Administrator-friendly options: + +1. **Policy block lists / deny lists** + - Extend every feature and item policy with: + - `denied_users` + - `denied_groups` + - Deny/block entries override `allow_all`, `allowed_users`, and `allowed_groups`. + - For the APIM threshold scenario: + - High endpoint: `allow_all = false`, `allowed_groups = [HighAccessGroup]`. + - Low endpoint: `allow_all = true`, `denied_groups = [HighAccessGroup]`. + - This is the smallest change that directly solves the onboarding pain because new ordinary users automatically keep low-endpoint access, while high users are excluded from the low endpoint by one cohort entry. + - Limitation: if there are many low-tier resources, admins may still need to add the same high-access block group to multiple policies unless policy templates or access tiers are added later. + +2. **Endpoint access tiers / policy sets** + - Add a higher-level admin concept such as "Endpoint Access Tier" or "Policy Set". + - Admins define named tiers such as `Low APIM Threshold` and `High APIM Threshold`. + - Endpoints are assigned to a tier. + - Users/groups are assigned to allowed tiers, and optionally to excluded tiers. + - The governance layer expands the tier decision into endpoint access decisions. + - For the APIM threshold scenario: + - High users are assigned to `High APIM Threshold`. + - Low endpoint belongs to `Low APIM Threshold`. + - High endpoint belongs to `High APIM Threshold`. + - The tier can be configured as mutually exclusive so high users do not inherit low-tier endpoint access. + - This is more administrator-friendly for large endpoint fleets because the same tier rule can govern many endpoints. + - Limitation: this is a larger product feature than simple block lists and needs careful UI language so admins understand whether tiers are additive or mutually exclusive. + +3. **Default endpoint routing only** + - Instead of adding deny semantics, admins could assign a default/preferred endpoint per cohort and hide endpoint selection from users. + - This can reduce accidental use of the wrong APIM quota bucket. + - It does not fully solve access control if users or agents can still reference another endpoint through other paths. + - This should be considered a usability enhancement, not a substitute for governance enforcement. + +Recommended approach: + +1. Implement generic policy block lists first because they align with the existing governance model and solve the specific onboarding problem with minimal new concepts. +2. In the admin UI, call them **Block List** or **Deny List** and explicitly state that block entries win over allow entries. +3. Add a follow-up design for reusable endpoint access tiers/policy sets if customers have many endpoints or repeated low/high quota classes. +4. Keep tiering as an optional abstraction over the same feature/item policy engine, not a separate access-control system. + +## Proposed Data and Setting Additions + +### Policy state fields + +Add optional fields to normalized feature and item policy documents: + +- `denied_users`: list of user ids blocked by the policy. +- `denied_groups`: list of group/cohort ids blocked by the policy. + +Decision semantics: + +1. If the relevant governance feature toggle is disabled, preserve current behavior and do not evaluate policy lists. +2. If the user id or any user group/cohort appears in a deny/block list, deny access. +3. Otherwise, evaluate the existing `allow_all`, `allowed_users`, and `allowed_groups` behavior. +4. If the same user or group is present in both allow and block lists, the block list wins. + +### Feature policy keys + +Add to `DEFAULT_FEATURE_POLICIES`: + +- `governance_user_workflows` +- `governance_group_workflows` +- `governance_content_safety_bypass` +- `governance_content_safety_rulesets` + +### Item policy entity types + +Add to `DEFAULT_ITEM_POLICY_ENTITY_TYPES`: + +- `content_safety_ruleset` + +Optional future item policy entity types: + +- `personal_workflow` +- `group_workflow` +- `endpoint_access_tier` or `governance_policy_set` if reusable endpoint tiering is added after generic block lists. + +### Settings defaults + +Add to `functions_settings.py`: + +- `governance_user_workflows`: `false` +- `governance_group_workflows`: `false` +- `governance_content_safety_bypass`: `false` +- `governance_content_safety_rulesets`: `false` +- `content_safety_rulesets`: list with a default ruleset mirroring current behavior + +## Admin UI and API Plan + +1. Reuse existing governance APIs for feature and item policies. +2. Extend the governance policy editor so feature and item policies can edit both allow lists and block lists. +3. Add policy summaries that show: + - All users allowed except blocked users/groups. + - Explicit users/groups allowed. + - Explicit users/groups blocked. +4. Add labels to `GOVERNANCE_FEATURE_LABELS`: + - Personal Workflow Authoring + - Group Workflow Authoring + - Content Safety Bypass + - Content Safety Rulesets +5. Add `content_safety_ruleset` to `GOVERNANCE_ITEM_ENTITY_LABELS`. +6. Add lookup support for configured rulesets. +7. Add Admin Settings toggles in the Governance section, gated by the corresponding primary feature settings. +8. Add a Content Safety ruleset editor under the Safety tab or Governance tab. +9. Update the governance help modal with: + - Workflow authoring boundary. + - Bypass warning. + - Ruleset precedence. + - Clarification that plugin type governance is action/plugin family governance. + - Block-list precedence and endpoint quota-tier examples. + +## Backend Enforcement Plan + +### Workflows + +Use `ensure_governance_access()` in `route_backend_workflows.py` after existing feature/app-role/group-role checks: + +- `save_user_workflow()` +- `delete_user_workflow()` +- `draft_workflow_instructions()` for personal workflow context +- `save_group_workflow_route()` +- `delete_group_workflow_route()` +- `draft_workflow_instructions()` for group workflow context + +Do not change scheduled execution until an explicit run/read governance scope is designed. + +### Content Safety + +Move duplicated Content Safety evaluation out of `route_backend_chats.py` and into `functions_content_safety.py`. + +New helper responsibilities: + +- Determine whether Content Safety is enabled. +- Determine whether the user has bypass governance. +- Resolve the effective ruleset. +- Build `AnalyzeTextOptions`. +- Evaluate category severities and blocklist matches. +- Return a normalized decision object used by both streaming and non-streaming chat paths. + +### Plugin/action types + +Keep existing enforcement in: + +- `functions_personal_actions.py` +- `functions_group_actions.py` +- `route_backend_plugins.py` +- `semantic_kernel_loader.py` +- `functions_agent_catalog.py` + +Add only gap-filling changes: + +- UI label/help text updates. +- Custom/unknown type lookup discovery. +- Regression coverage. + +### Block list enforcement + +Update the central policy evaluator so every existing governance surface receives consistent deny/block semantics: + +- Feature policies. +- Item policies such as `global_endpoint`, `global_action`, `personal_action_type`, `group_action_type`, and `global_action_type`. +- MCP destination/source policy helpers that currently duplicate principal matching. +- Any future workflow and Content Safety policies added by this plan. + +The evaluator should return or expose enough information for logs/audit events to distinguish "not allowed" from "explicitly blocked" without exposing sensitive prompt or request content. + +## Validation Plan + +Add or update functional tests: + +- `functional_tests/test_governance_enforcement_logic.py` + - Block-list deny precedence for users and groups. + - `allow_all = true` plus `denied_groups` blocks only that cohort. + - Overlapping allow and deny entries deny access. + - Existing allowlist-only behavior remains unchanged when deny lists are empty. + - Endpoint quota-tier scenario: high group can access high endpoint and is blocked from low endpoint without enumerating every low user. + - New workflow feature policy cases. + - New Content Safety bypass and ruleset policy cases. + - Custom plugin/action type policy case. +- `functional_tests/test_governance_route_and_wiring_coverage.py` + - New settings keys, UI labels, and route enforcement markers. +- A focused Content Safety regression test: + - Bypass user skips safety call. + - Non-bypass user still calls safety. + - Default ruleset matches existing severity >= 4 behavior. + - Alternate ruleset changes the decision deterministically. +- A focused workflow governance test: + - Personal workflow creation denied by feature policy. + - Group workflow creation denied by feature policy after role/group validation. + - Existing read/run behavior remains unchanged unless deliberately added. + +Run targeted validation: + +- New and changed functional tests. +- Route policy trio if routes change. +- `python -m py_compile` for changed Python files. +- `node --check` for changed JavaScript files. +- XSS and broken-access-control scanners for changed app files. +- `git diff --check`. + +## Rollout Plan + +1. Phase 1: Generic policy block lists + - Add deny/block fields to feature and item policy normalization. + - Apply deny-overrides-allow semantics centrally. + - Update admin API/UI and tests. + - Use the APIM low/high endpoint scenario as the primary validation case. +2. Phase 2: Plugin/action type governance cleanup + - Clarify UI labels/help. + - Add custom type lookup or tests. + - No behavior change for existing policies. +3. Phase 3: Workflow authoring governance + - Add feature keys, toggles, enforcement, tests. + - Defaults off to preserve current behavior. +4. Phase 4: Content Safety bypass governance + - Add bypass feature key and enforcement. + - Centralize duplicated Content Safety logic. + - Log bypass events safely. +5. Phase 5: Content Safety rulesets + - Add ruleset data model, UI, item policies, and precedence. + - Keep default ruleset identical to current behavior. +6. Phase 6: Optional endpoint access tiers / policy sets + - Add only if repeated low/high quota class management becomes cumbersome with per-policy block lists. + - Build on the same block-list-aware governance evaluator. +7. Phase 7: Documentation and release notes + - Update governance docs and release notes after implementation. + +## Open Questions + +1. Should workflow governance cover only create/update/delete, or also run/read/history? +2. Should Content Safety bypass be restricted to admins configuring specific users/groups, or should group/workspace cohorts be allowed? +3. Should alternate Content Safety rulesets support only thresholds/blocklists first, or also separate endpoint/APIM configurations? +4. Should plugin/action type governance stay as one create/use entitlement, or should a later phase separate "can create" from "can use"? +5. Should deny/block lists apply only when the corresponding governance toggle is enabled? Recommended: yes, to preserve existing toggle semantics. +6. Should admins be prevented from saving the same principal in both allow and block lists, or should the backend simply make block wins? Recommended: warn in UI, enforce block wins in backend. +7. Should endpoint access tiers be added immediately, or should we first validate whether generic block lists are enough for APIM low/high threshold deployments? + +## Related Issues + +- #1048: Add governance options for Content Safety. +- No exact open issue was found for workflow creation governance during triage. +- Plugin/action type governance is already present; a follow-up issue is only needed if we want UI wording or custom type discovery tracked separately. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index a2993b7f2..84ae66d20 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,52 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/). +### **(v0.250.208)** + +#### User Interface Enhancements + +* **Governance Policy Copy and Principal Review Actions** + * Added Duplicate and Inverse actions for delegated item governance policies so admins can quickly clone a policy or create an allow/block-list-swapped version before saving it as a new policy. + * Added a Show Users modal and removed the allowed/blocked user and group columns from the main delegated policy table, keeping the table easier to scan while preserving principal detail access. + * (Ref: #1252, `admin_governance.js`, `admin_settings.html`, governance delegated item policies) + +### **(v0.250.207)** + +#### Bug Fixes + +* **Intentional Governance Item Policy Retargeting** + * Updated delegated item policy edits so admins can intentionally move an existing policy to a different delegated item without creating a duplicate policy document. + * The admin UI keeps the policy ID stable, warns that changing the target will move the policy, and the backend saves the new target while deleting the original source document when original target metadata is supplied. + * Also prevents ambiguous policy-ID reuse and keeps feature-policy saves and item-policy deletes out of the retarget conflict path. + * (Ref: #1252, `functions_governance.py`, `route_backend_governance.py`, `admin_governance.js`) + +### **(v0.250.206)** + +#### Bug Fixes + +* **Governance Item Policy Retarget Protection** + * Fixed delegated item policy edits so changing the selected target no longer creates a duplicate policy document for the new item while leaving the old policy behind. + * Locks the target controls during existing policy edits and rejects conflicting backend saves when an existing policy ID is reused for a different delegated item. + * (Ref: #1252, `admin_governance.js`, `route_backend_governance.py`, `functions_governance.py`) + +### **(v0.250.205)** + +#### Bug Fixes + +* **Governance Block List Modal Handoff** + * Fixed the delegated item block-list editor opening behind the item policy editor by hiding the parent modal before opening the shared principal editor, then restoring the item editor after the principal editor closes. + * Keeps Bootstrap modal focus, backdrop, and scroll handling consistent by ensuring only one governance modal is visible at a time. + * (Ref: #1252, `admin_governance.js`, `test_admin_governance_tab.py`) + +### **(v0.250.204)** + +#### New Features + +* **Governance Policy Block Lists** + * Added admin-managed block lists for feature and delegated item governance policies so specific users or groups can be denied even when allow-all or allow-list rules would otherwise grant access. + * Enables administrator-friendly APIM quota-tier separation, such as allowing a high-threshold group to a high endpoint while blocking that group from a default low-threshold endpoint without maintaining a large low-user allow list. + * (Ref: #1252, `functions_governance.py`, `route_backend_governance.py`, `admin_governance.js`, MCP governance) + ### **(v0.250.203)** #### New Features diff --git a/functional_tests/test_governance_activity_logging.py b/functional_tests/test_governance_activity_logging.py index 1debb4a70..2737d2944 100644 --- a/functional_tests/test_governance_activity_logging.py +++ b/functional_tests/test_governance_activity_logging.py @@ -2,8 +2,8 @@ #!/usr/bin/env python3 """ Functional test for governance activity logging and policy mutation behavior. -Version: 0.241.010 -Implemented in: 0.241.010 +Version: 0.250.204 +Implemented in: 0.241.010; 0.250.204 This test ensures that governance changes create activity log records with activity_type/type set to governance and include detailed before/after fields. @@ -11,6 +11,7 @@ import os import sys +import types CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -18,6 +19,42 @@ if SINGLE_APP_DIR not in sys.path: sys.path.append(SINGLE_APP_DIR) + +class _TestContainer: + def read_item(self, item, partition_key): + raise KeyError(item) + + def query_items(self, *args, **kwargs): + return [] + + def upsert_item(self, body): + return body + + def delete_item(self, item, partition_key): + return None + + def create_item(self, body): + return body + + +config_stub = types.ModuleType("config") +config_stub.cosmos_activity_logs_container = _TestContainer() +config_stub.cosmos_governance_item_policies_container = _TestContainer() +config_stub.cosmos_governance_policies_container = _TestContainer() +sys.modules.setdefault("config", config_stub) + +functions_settings_stub = types.ModuleType("functions_settings") +functions_settings_stub.get_settings = lambda: {} +sys.modules.setdefault("functions_settings", functions_settings_stub) + +functions_group_stub = types.ModuleType("functions_group") +functions_group_stub.get_user_groups = lambda _user_id: [] +sys.modules.setdefault("functions_group", functions_group_stub) + +functions_public_workspaces_stub = types.ModuleType("functions_public_workspaces") +functions_public_workspaces_stub.get_user_public_workspaces = lambda _user_id: [] +sys.modules.setdefault("functions_public_workspaces", functions_public_workspaces_stub) + import functions_activity_logging as activity_logging import functions_governance as governance @@ -101,6 +138,8 @@ def _capture_log(**kwargs): "allow_all": False, "allowed_users": ["user-1", "user-2"], "allowed_groups": ["group-1"], + "denied_users": ["blocked-user-1"], + "denied_groups": ["blocked-group-1"], }, actor_user_id="admin-xyz", actor_email="admin@example.com", @@ -109,6 +148,8 @@ def _capture_log(**kwargs): assert updated.get("allow_all") is False, "allow_all was not updated" assert sorted(updated.get("allowed_users", [])) == ["user-1", "user-2"], "allowed_users mismatch" assert updated.get("allowed_groups") == ["group-1"], "allowed_groups mismatch" + assert updated.get("denied_users") == ["blocked-user-1"], "denied_users mismatch" + assert updated.get("denied_groups") == ["blocked-group-1"], "denied_groups mismatch" assert len(captured_logs) == 1, "Expected one governance log event" details = captured_logs[0].get("change_details") or {} @@ -116,6 +157,8 @@ def _capture_log(**kwargs): assert details.get("allow_all", {}).get("after") is False, "diff.after allow_all mismatch" assert details.get("users_added") == ["user-1", "user-2"], "users_added mismatch" assert details.get("groups_added") == ["group-1"], "groups_added mismatch" + assert details.get("denied_users_added") == ["blocked-user-1"], "denied_users_added mismatch" + assert details.get("denied_groups_added") == ["blocked-group-1"], "denied_groups_added mismatch" print("PASS: governance feature policy emits detailed diff data") return True diff --git a/functional_tests/test_governance_enforcement_logic.py b/functional_tests/test_governance_enforcement_logic.py index 2fad39cd0..8f7c88686 100644 --- a/functional_tests/test_governance_enforcement_logic.py +++ b/functional_tests/test_governance_enforcement_logic.py @@ -2,8 +2,8 @@ #!/usr/bin/env python3 """ Functional test for governance enforcement logic. -Version: 0.242.072 -Implemented in: 0.241.010; updated in 0.242.022; 0.242.063; 0.242.064; 0.242.065; 0.242.066 +Version: 0.250.207 +Implemented in: 0.241.010; updated in 0.242.022; 0.242.063; 0.242.064; 0.242.065; 0.242.066; 0.250.204; 0.250.206; 0.250.207 This test ensures ensure_governance_access correctly allows and denies access based on feature toggles, feature policies, item policies, and caller groups. @@ -11,6 +11,7 @@ import os import sys +import types CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -18,6 +19,39 @@ if SINGLE_APP_DIR not in sys.path: sys.path.append(SINGLE_APP_DIR) + +class _TestContainer: + def read_item(self, item, partition_key): + raise KeyError(item) + + def query_items(self, *args, **kwargs): + return [] + + def upsert_item(self, body): + return body + + def delete_item(self, item, partition_key): + return None + + +config_stub = types.ModuleType("config") +config_stub.cosmos_governance_item_policies_container = _TestContainer() +config_stub.cosmos_governance_policies_container = _TestContainer() +config_stub.cosmos_activity_logs_container = _TestContainer() +sys.modules.setdefault("config", config_stub) + +functions_settings_stub = types.ModuleType("functions_settings") +functions_settings_stub.get_settings = lambda: {} +sys.modules.setdefault("functions_settings", functions_settings_stub) + +functions_group_stub = types.ModuleType("functions_group") +functions_group_stub.get_user_groups = lambda _user_id: [] +sys.modules.setdefault("functions_group", functions_group_stub) + +functions_public_workspaces_stub = types.ModuleType("functions_public_workspaces") +functions_public_workspaces_stub.get_user_public_workspaces = lambda _user_id: [] +sys.modules.setdefault("functions_public_workspaces", functions_public_workspaces_stub) + import functions_governance as governance @@ -168,6 +202,119 @@ def test_empty_allowlist_denies_when_allow_all_disabled(): governance.get_user_governance_group_ids = original_get_user_governance_group_ids +def test_block_lists_override_allow_policies_for_endpoint_quota_classes(): + print("Testing governance block lists for APIM endpoint quota class separation...") + + original_get_settings = governance.get_settings + original_get_feature_policy = governance.get_feature_policy + original_get_item_policies = governance.get_item_policies + original_get_user_governance_group_ids = governance.get_user_governance_group_ids + + def _endpoint_policies(_entity_type, item_id): + if item_id == "high-threshold-endpoint": + return [ + { + "allow_all": False, + "allowed_users": [], + "allowed_groups": ["high-quota-users"], + "denied_users": [], + "denied_groups": [], + } + ] + if item_id == "low-threshold-endpoint": + return [ + { + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + "denied_users": [], + "denied_groups": ["high-quota-users"], + } + ] + return [{"allow_all": True, "allowed_users": [], "allowed_groups": [], "denied_users": [], "denied_groups": []}] + + try: + governance.get_settings = lambda: {"governance_global_endpoints": True} + governance.get_feature_policy = lambda _feature_key: { + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + "denied_users": [], + "denied_groups": [], + } + governance.get_item_policies = _endpoint_policies + governance.get_user_governance_group_ids = lambda user_id: ( + {"high-quota-users"} if user_id == "high-user" else {"default-users"} + ) + + governance.ensure_governance_access( + "governance_global_endpoints", + "high-user", + item_entity_type="global_endpoint", + item_id="high-threshold-endpoint", + ) + governance.ensure_governance_access( + "governance_global_endpoints", + "default-user", + item_entity_type="global_endpoint", + item_id="low-threshold-endpoint", + ) + + high_user_blocked_from_low = False + try: + governance.ensure_governance_access( + "governance_global_endpoints", + "high-user", + item_entity_type="global_endpoint", + item_id="low-threshold-endpoint", + ) + except PermissionError: + high_user_blocked_from_low = True + assert high_user_blocked_from_low, "Expected high-quota group to be blocked from low-threshold endpoint" + + default_user_blocked_from_high = False + try: + governance.ensure_governance_access( + "governance_global_endpoints", + "default-user", + item_entity_type="global_endpoint", + item_id="high-threshold-endpoint", + ) + except PermissionError: + default_user_blocked_from_high = True + assert default_user_blocked_from_high, "Expected default user to remain blocked from high-threshold endpoint" + + governance.get_item_policies = lambda _entity_type, _item_id: [ + { + "allow_all": False, + "allowed_users": ["overlap-user"], + "allowed_groups": [], + "denied_users": ["overlap-user"], + "denied_groups": [], + } + ] + governance.get_user_governance_group_ids = lambda _user_id: set() + overlap_blocked = False + try: + governance.ensure_governance_access( + "governance_global_endpoints", + "overlap-user", + item_entity_type="global_endpoint", + item_id="overlap-endpoint", + ) + except PermissionError: + overlap_blocked = True + assert overlap_blocked, "Expected deny list to win when the same user is allowed and blocked" + + print("PASS: block lists separate low/high endpoint quota classes without large allowlists") + return True + finally: + governance.get_settings = original_get_settings + governance.get_feature_policy = original_get_feature_policy + governance.get_item_policies = original_get_item_policies + governance.get_user_governance_group_ids = original_get_user_governance_group_ids + + def test_personal_action_entry_points_enforce_governance(): print("Testing action type governance entry point coverage...") @@ -220,7 +367,7 @@ def test_personal_action_entry_points_enforce_governance(): "Personal Action Type", "Group Action Type", "Global Action Type", - "Action type policies grant create/use entitlement", + "action type entitlements that admins assign to specific users or groups", ]: assert marker in admin_template_content, f"Missing action type governance template marker: {marker}" @@ -317,13 +464,159 @@ def explicit_policies(entity_type, item_id): governance.get_user_governance_group_ids = original_get_user_governance_group_ids +def test_item_policy_policy_id_lookup_preserves_existing_targets(): + print("Testing item policy lookup by policy ID for retarget protection...") + + class _PolicyIdLookupContainer: + def query_items(self, query, parameters, enable_cross_partition_query): + assert "c.policy_id = @policy_id" in query + assert enable_cross_partition_query is True + policy_id = next(parameter["value"] for parameter in parameters if parameter["name"] == "@policy_id") + if policy_id != "policy-existing": + return [] + return [ + { + "id": "item:global_agent:agent-original:policy-existing", + "policy_id": "policy-existing", + "policy_name": "Original Agent Policy", + "entity_type": "global_agent", + "item_id": "agent-original", + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + "denied_users": [], + "denied_groups": [], + } + ] + + original_container = governance.cosmos_governance_item_policies_container + try: + governance.cosmos_governance_item_policies_container = _PolicyIdLookupContainer() + matching_policies = governance.list_item_policies_by_policy_id("policy-existing") + assert len(matching_policies) == 1 + assert matching_policies[0]["entity_type"] == "global_agent" + assert matching_policies[0]["item_id"] == "agent-original" + assert governance.list_item_policies_by_policy_id("missing-policy") == [] + print("PASS: item policy lookup by policy ID returns the original target") + return True + finally: + governance.cosmos_governance_item_policies_container = original_container + + +def test_item_policy_retarget_moves_existing_policy_document(): + print("Testing item policy retargeting moves the existing policy document...") + + source_id = "item:global_agent:agent-original:policy-existing" + target_id = "item:global_agent:agent-new:policy-existing" + + class _RetargetContainer: + def __init__(self): + self.items = { + source_id: { + "id": source_id, + "policy_id": "policy-existing", + "policy_name": "Original Agent Policy", + "resource_label": "Original Agent", + "entity_type": "global_agent", + "item_id": "agent-original", + "allow_all": False, + "allowed_users": ["user-a"], + "allowed_groups": [], + "denied_users": [], + "denied_groups": [], + "system_managed": False, + } + } + self.deleted_items = [] + + def read_item(self, item, partition_key): + if item not in self.items: + raise KeyError(item) + return dict(self.items[item]) + + def query_items(self, query, parameters, enable_cross_partition_query): + assert enable_cross_partition_query is True + parameter_values = { + parameter["name"]: parameter["value"] + for parameter in parameters + } + if "c.policy_id = @policy_id" in query: + return [ + dict(item) + for item in self.items.values() + if item.get("policy_id") == parameter_values.get("@policy_id") + ] + if "c.entity_type = @entity_type AND c.item_id = @item_id" in query: + return [ + dict(item) + for item in self.items.values() + if item.get("entity_type") == parameter_values.get("@entity_type") + and item.get("item_id") == parameter_values.get("@item_id") + ] + return [] + + def upsert_item(self, body): + self.items[body["id"]] = dict(body) + return dict(body) + + def delete_item(self, item, partition_key): + if item not in self.items: + raise KeyError(item) + self.deleted_items.append(item) + del self.items[item] + + original_container = governance.cosmos_governance_item_policies_container + original_log_governance_change = governance.log_governance_change + logged_actions = [] + container = _RetargetContainer() + try: + governance.cosmos_governance_item_policies_container = container + governance.log_governance_change = lambda **kwargs: logged_actions.append(kwargs.get("action")) + + updated = governance.retarget_item_policy( + entity_type="global_agent", + item_id="agent-new", + payload={ + "policy_id": "policy-existing", + "policy_name": "Moved Agent Policy", + "resource_label": "New Agent", + "allow_all": False, + "allowed_users": ["user-b"], + "allowed_groups": [], + "denied_users": [], + "denied_groups": ["group-denied"], + }, + original_entity_type="global_agent", + original_item_id="agent-original", + actor_user_id="admin-user", + actor_email="admin@example.com", + ) + + assert updated["id"] == target_id + assert updated["item_id"] == "agent-new" + assert updated["allowed_users"] == ["user-b"] + assert updated["denied_groups"] == ["group-denied"] + assert source_id not in container.items + assert target_id in container.items + assert container.deleted_items == [source_id] + assert "item_policy_retarget" in logged_actions + print("PASS: item policy retargeting moves the existing document") + return True + finally: + governance.cosmos_governance_item_policies_container = original_container + governance.log_governance_change = original_log_governance_change + + if __name__ == "__main__": tests = [ test_ensure_governance_access_allows_when_feature_toggle_disabled, test_ensure_governance_access_enforces_feature_and_item_policies, test_empty_allowlist_denies_when_allow_all_disabled, + test_block_lists_override_allow_policies_for_endpoint_quota_classes, test_personal_action_entry_points_enforce_governance, test_action_type_policies_grant_specific_action_families, + test_item_policy_policy_id_lookup_preserves_existing_targets, + test_item_policy_retarget_moves_existing_policy_document, ] results = [] diff --git a/functional_tests/test_governance_route_and_wiring_coverage.py b/functional_tests/test_governance_route_and_wiring_coverage.py index 87b1d86a6..c784ba466 100644 --- a/functional_tests/test_governance_route_and_wiring_coverage.py +++ b/functional_tests/test_governance_route_and_wiring_coverage.py @@ -2,8 +2,8 @@ #!/usr/bin/env python3 """ Functional test for governance route and enforcement wiring coverage. -Version: 0.242.019 -Implemented in: 0.242.011; 0.242.012; 0.242.013; 0.242.014; 0.242.018; 0.242.019 +Version: 0.250.208 +Implemented in: 0.242.011; 0.242.012; 0.242.013; 0.242.014; 0.242.018; 0.242.019; 0.250.204; 0.250.205; 0.250.206; 0.250.207; 0.250.208 This test ensures governance routes are registered and guarded, and that updated backend modules include governance enforcement hooks for endpoint, @@ -35,7 +35,7 @@ def test_governance_route_registration_and_guards(): assert "from route_backend_governance import register_route_backend_governance" in app_content, ( "Expected app.py to import governance route registration" ) - assert "register_route_backend_governance(app)" in app_content, ( + assert "register_route_blueprint('backend_governance', register_route_backend_governance" in app_content, ( "Expected governance routes to be registered in app.py" ) @@ -141,7 +141,7 @@ def test_governance_enforcement_hooks_across_changed_routes(): "governance-info-guide-btn", "data-bs-target=\"#governanceInfoModal\"", "{% include '_governance_info.html' %}", - "Delegated item policies are OR combined whitelists", + "Delegated item policies are OR combined allow lists with optional block lists", "btn btn-outline-info btn-sm ms-2 governance-primary-link", ]: assert marker in admin_settings_template_content, f"Missing governance UI marker in admin_settings.html: {marker}" @@ -205,14 +205,42 @@ def test_governance_enforcement_hooks_across_changed_routes(): "openGovernanceDelegatedItemEditor", "governance-item-policy-name", "governance-item-policy-id", + "governanceItemPolicyEditorContext", + "applyGovernanceItemTargetEditState", + "Changing the target will move this policy to the selected delegated item when saved", + "The policy ID cannot be changed while moving or editing an existing delegated item policy", "deleteGovernanceItemPolicyFromContext", "renderGovernancePrincipalReviewCell", "wireGovernanceItemReviewHandlers", + "returnToModalElement.addEventListener('hidden.bs.modal', showAllowListEditor, { once: true })", + "allowListModalElement?.addEventListener('hidden.bs.modal', restoreReturnModal, { once: true })", + "returnToModal.hide()", + "governance-show-item-policy-users-btn", + "governance-duplicate-item-policy-btn", + "governance-inverse-item-policy-btn", + "cloneGovernanceItemPolicyForAction", + "openGovernanceItemPolicyUsersModal", + "A new policy ID will be assigned when saved", + "Allowed and blocked users/groups were swapped", ]: assert marker in admin_governance_js_content or marker in admin_settings_template_content or marker in frontend_chats_content or marker in agents_content, ( f"Missing governance UI visibility marker: {marker}" ) + item_policy_table_header_section = admin_settings_template_content.split( + 'id="governance-item-policies-table"', + 1, + )[1].split('id="governance-item-policies-review-body"', 1)[0] + for removed_header in [ + "", + "", + "", + "", + ]: + assert removed_header not in item_policy_table_header_section, ( + f"Delegated item policy table should move {removed_header} into the Show Users modal" + ) + assert '
/'", 1)[0] + assert "_item_policy_target_conflicts(" not in item_policy_delete_section, ( + "Delegated item policy deletes must not run save-time retarget conflict checks" + ) + model_endpoint_js_content = _read("application", "single_app", "static", "js", "admin", "admin_model_endpoints.js") admin_agents_js_content = _read("application", "single_app", "static", "js", "admin", "admin_agents.js") admin_plugins_js_content = _read("application", "single_app", "static", "js", "admin", "admin_plugins.js") diff --git a/functional_tests/test_inbound_mcp_governance_and_tools.py b/functional_tests/test_inbound_mcp_governance_and_tools.py index e7a8f05fb..ff277bca4 100644 --- a/functional_tests/test_inbound_mcp_governance_and_tools.py +++ b/functional_tests/test_inbound_mcp_governance_and_tools.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for inbound MCP governance and first tool contracts. -Version: 0.250.100 +Version: 0.250.204 Implemented in: 0.250.070 Simplified personal access/source governance implemented in: 0.250.080 Single inbound MCP access policy implemented in: 0.250.081 @@ -15,6 +15,7 @@ Inbound MCP source-only governance implemented in: 0.250.092 Personal workflow listing and clear workflow-id guidance implemented in: 0.250.094 Inbound MCP enterprise readiness hardening implemented in: 0.250.096 +Inbound MCP policy block lists implemented in: 0.250.204 This test ensures inbound MCP uses explicit inbound MCP source governance, exposes only implemented tools through JSON-RPC, and binds personal tools to @@ -48,6 +49,9 @@ def test_governance_policy_dimensions(): assert "get_explicit_item_policies(entity_type, item_id)" in source assert "has no explicit inbound MCP policy" in source assert "effect == \"deny\"" in source + assert "policy_denies_principal(policy, user_id, group_ids)" in source + assert "policy_allows_principal(policy or {}, user_id, group_ids)" in source + assert "denied by explicit policy block list" in source assert "mcp_identity_type_not_allowed" in source assert "mcp_delegated_user_required" in source assert '"mcp_access_not_allowed"' not in source diff --git a/functional_tests/test_mcp_destination_governance_and_preconfigurations.py b/functional_tests/test_mcp_destination_governance_and_preconfigurations.py index 692f307b1..77fc97fc7 100644 --- a/functional_tests/test_mcp_destination_governance_and_preconfigurations.py +++ b/functional_tests/test_mcp_destination_governance_and_preconfigurations.py @@ -2,8 +2,8 @@ #!/usr/bin/env python3 """ Functional test for outbound MCP destination governance and preconfigurations. -Version: 0.250.100 -Implemented in: 0.250.064 +Version: 0.250.204 +Implemented in: 0.250.064; 0.250.204 This test ensures MCP destination allowlisting and server preconfiguration catalog loading work without exposing secret-bearing defaults. @@ -279,6 +279,38 @@ def fake_list_governance_item_policies(entity_type): user_id="user-2", ) assert denied_decision["allowed"] is False + + policies_by_entity_type["mcp_personal_destination"] = [ + { + "item_id": "*", + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + "denied_users": ["user-1"], + "denied_groups": [], + }, + { + "item_id": "preconfiguration:github", + "allow_all": False, + "allowed_users": ["user-1"], + "allowed_groups": [], + "denied_users": [], + "denied_groups": [], + }, + ] + blocked_policy_config = mcp_destinations.get_mcp_destination_policy_config( + {"enable_mcp_destination_governance": True}, + user_id="user-1", + ) + blocked_github_decision = mcp_destinations.evaluate_mcp_destination_policy( + _mcp_manifest("https://api.githubcopilot.com/mcp/", "github"), + scope_type=MCP_DESTINATION_SCOPE_PERSONAL, + scope_id="user-1", + policy_config=blocked_policy_config, + user_id="user-1", + ) + assert blocked_github_decision["allowed"] is False + assert blocked_github_decision["reason"] == "matched_destination_block_policy" finally: mcp_destinations._list_governance_item_policies = original_list mcp_destinations._get_governance_group_ids_for_user = original_groups diff --git a/ui_tests/test_admin_governance_tab.py b/ui_tests/test_admin_governance_tab.py index 1d1a7a94e..82db3a3ff 100644 --- a/ui_tests/test_admin_governance_tab.py +++ b/ui_tests/test_admin_governance_tab.py @@ -2,8 +2,8 @@ """ UI test for admin governance tab rendering and API wiring. -Version: 0.242.019 -Implemented in: 0.241.009; 0.241.025; 0.242.012; 0.242.013; 0.242.014; 0.242.018; 0.242.019 +Version: 0.250.208 +Implemented in: 0.241.009; 0.241.025; 0.242.012; 0.242.013; 0.242.014; 0.242.018; 0.242.019; 0.250.204; 0.250.205; 0.250.206; 0.250.207; 0.250.208 This test ensures the Governance tab is present in admin settings, the sidebar navigation exposes the same destination, feature policy fetch/save @@ -46,12 +46,16 @@ def test_admin_governance_tab_and_api_wiring(): "allow_all": True, "allowed_users": [], "allowed_groups": [], + "denied_users": [], + "denied_groups": ["blocked-high-quota-group"], }, { "feature_key": "governance_global_actions_usage", "allow_all": False, "allowed_users": ["entra-user-123"], "allowed_groups": ["workspace-group-abc"], + "denied_users": ["blocked-user-789"], + "denied_groups": [], }, ], "feature_keys": [ @@ -65,7 +69,7 @@ def fulfill_feature_get(route): route.fulfill(status=200, content_type="application/json", body=json.dumps(feature_payload)) def fulfill_feature_put(route): - feature_put_requests.append(route.request.url) + feature_put_requests.append(route.request.post_data_json) route.fulfill(status=200, content_type="application/json", body='{"policy": {"ok": true}}') def fulfill_item_review_get(route): @@ -84,6 +88,8 @@ def fulfill_item_review_get(route): "allow_all": False, "allowed_users": ["entra-user-123"], "allowed_groups": ["workspace-group-abc"], + "denied_users": ["blocked-user-789"], + "denied_groups": ["blocked-group-xyz"], } ], "pagination": { @@ -100,7 +106,11 @@ def fulfill_item_review_get(route): ) def fulfill_item_put(route): - item_put_requests.append(route.request.url) + item_put_requests.append({ + "url": route.request.url, + "method": route.request.method, + "body": route.request.post_data_json, + }) route.fulfill(status=200, content_type="application/json", body='{"policy": {"ok": true}}') def fulfill_empty_user_search(route): @@ -108,16 +118,18 @@ def fulfill_empty_user_search(route): def fulfill_user_info(route): user_info_requests.append(route.request.url) + user_id = route.request.url.rstrip("/").rsplit("/", 1)[-1] + is_known_user = user_id == "entra-user-123" route.fulfill( status=200, content_type="application/json", body=json.dumps({ - "id": "entra-user-123", - "user_id": "entra-user-123", - "displayName": "Paul Lizer", - "display_name": "Paul Lizer", - "email": "paullizer@swiftiesandbox1.onmicrosoft.us", - "userPrincipalName": "paullizer@swiftiesandbox1.onmicrosoft.us", + "id": user_id, + "user_id": user_id, + "displayName": "Paul Lizer" if is_known_user else user_id, + "display_name": "Paul Lizer" if is_known_user else user_id, + "email": "paullizer@swiftiesandbox1.onmicrosoft.us" if is_known_user else f"{user_id}@example.invalid", + "userPrincipalName": "paullizer@swiftiesandbox1.onmicrosoft.us" if is_known_user else f"{user_id}@example.invalid", }), ) @@ -169,9 +181,9 @@ def fulfill_admin_plugins_lookup(route): page.route("**/api/admin/governance/policies", fulfill_feature_get) page.route("**/api/admin/governance/policies/*", fulfill_feature_put) page.route("**/api/admin/governance/item-policies/review**", fulfill_item_review_get) - page.route("**/api/admin/governance/item-policies/*/*", fulfill_item_put) + page.route("**/api/admin/governance/item-policies", fulfill_item_put) page.route("**/api/userSearch**", fulfill_empty_user_search) - page.route("**/api/user/info/entra-user-123", fulfill_user_info) + page.route("**/api/user/info/*", fulfill_user_info) page.route("**/api/groups/discover**", fulfill_empty_group_discovery) page.route("**/api/admin/agents", fulfill_admin_agents_lookup) page.route("**/api/admin/plugins", fulfill_admin_plugins_lookup) @@ -192,11 +204,67 @@ def fulfill_admin_plugins_lookup(route): page.wait_for_selector("#governanceInfoModal.show", state="hidden") page.wait_for_selector("#governance-item-policies-review-body tr") + item_policy_header = page.locator("#governance-item-policies-table thead").inner_text() + assert "Allowed Users" not in item_policy_header + assert "Allowed Groups" not in item_policy_header + assert "Blocked Users" not in item_policy_header + assert "Blocked Groups" not in item_policy_header + + page.locator(".governance-show-item-policy-users-btn").first.click() + page.wait_for_selector("#governance-item-policy-users-modal.show") + principal_modal_text = page.locator("#governance-item-policy-users-modal").inner_text() + assert "Allow Users" in principal_modal_text + assert "Allow Groups" in principal_modal_text + assert "Block Users" in principal_modal_text + assert "Block Groups" in principal_modal_text page.get_by_text("Paul Lizer (paullizer@swiftiesandbox1.onmicrosoft.us)").wait_for() + page.locator("#governance-item-policy-users-modal .btn-close").click() + page.wait_for_selector("#governance-item-policy-users-modal.show", state="hidden") + + page.locator(".governance-duplicate-item-policy-btn").first.click() + page.wait_for_selector("#governance-item-policy-editor-modal.show") + assert page.locator("#governance-item-policy-name").input_value() == "Endpoint Pilot Users (duplicate)" + assert page.locator("#governance-item-policy-id").input_value() == "" + assert page.locator("#governance-item-users").input_value() == "entra-user-123" + assert page.locator("#governance-item-groups").input_value() == "workspace-group-abc" + assert page.locator("#governance-item-denied-users").input_value() == "blocked-user-789" + assert page.locator("#governance-item-denied-groups").input_value() == "blocked-group-xyz" + assert "A new policy ID will be assigned" in page.locator("#governance-item-editor-status").inner_text() + page.locator("#governance-item-policy-editor-modal .btn-close").click() + page.wait_for_selector("#governance-item-policy-editor-modal.show", state="hidden") + + page.locator(".governance-inverse-item-policy-btn").first.click() + page.wait_for_selector("#governance-item-policy-editor-modal.show") + assert page.locator("#governance-item-policy-name").input_value() == "Endpoint Pilot Users (inverse)" + assert page.locator("#governance-item-policy-id").input_value() == "" + assert page.locator("#governance-item-users").input_value() == "blocked-user-789" + assert page.locator("#governance-item-groups").input_value() == "blocked-group-xyz" + assert page.locator("#governance-item-denied-users").input_value() == "entra-user-123" + assert page.locator("#governance-item-denied-groups").input_value() == "workspace-group-abc" + assert not page.locator("#governance-item-allow-all").is_checked() + assert "Allowed and blocked users/groups were swapped" in page.locator("#governance-item-editor-status").inner_text() + page.locator("#governance-item-policy-editor-modal .btn-close").click() + page.wait_for_selector("#governance-item-policy-editor-modal.show", state="hidden") page.get_by_role("button", name="Save Feature Policies").click() page.wait_for_timeout(300) + page.locator(".governance-edit-item-policy-btn").first.click() + page.wait_for_selector("#governance-item-policy-editor-modal.show") + assert not page.locator("#governance-item-entity-type").is_disabled(), ( + "Existing item policy edits should allow intentional retargeting" + ) + assert not page.locator("#governance-item-id").is_disabled(), ( + "Existing item policy edits should allow selecting a new delegated item" + ) + assert "Changing the target will move this policy" in page.locator("#governance-item-id-status").inner_text() + page.locator("#governance-item-entity-type").select_option("global_agent") + page.wait_for_selector("#governance-item-id option[value='global-agent-2']") + page.locator("#governance-item-id").select_option("global-agent-2") + page.get_by_role("button", name="Save Item Policy").click() + page.wait_for_selector("#governance-item-policy-editor-modal.show", state="hidden") + page.wait_for_timeout(300) + page.get_by_role("button", name="New Policy").click() page.wait_for_selector("#governance-item-policy-editor-modal.show") page.wait_for_selector("#governance-item-id option[value='global-agent-2']") @@ -216,6 +284,18 @@ def fulfill_admin_plugins_lookup(route): page.locator("#governance-item-selected-group-search").fill("workspace-group") assert "workspace-group-abc" in page.locator("#governance-item-selected-groups").inner_text() + page.locator("#governance-item-edit-blocklist-btn").click() + page.wait_for_selector("#governanceAllowListEditorModal.show") + page.wait_for_selector("#governance-item-policy-editor-modal.show", state="hidden") + page.locator("#governance-allowlist-csv-target").select_option("groups") + page.locator("#governance-allowlist-csv-mode").select_option("merge") + page.locator("#governance-allowlist-csv-input").fill("blocked-group-xyz") + page.locator("#governance-allowlist-csv-apply-btn").click() + page.get_by_role("button", name="Apply to Policy").click() + page.wait_for_selector("#governanceAllowListEditorModal.show", state="hidden") + page.wait_for_selector("#governance-item-policy-editor-modal.show") + assert "1 group blocked" in page.locator("#governance-item-blocklist-summary").input_value() + page.get_by_role("button", name="Save Item Policy").click() page.wait_for_selector("#governance-item-policy-editor-modal.show", state="hidden") page.wait_for_timeout(300) @@ -226,6 +306,28 @@ def fulfill_admin_plugins_lookup(route): assert feature_put_requests, "Expected governance feature policies PUT request" assert item_put_requests, "Expected governance item policy PUT request" assert agent_lookup_requests, "Expected global agent lookup GET request" + assert any("denied_groups" in request for request in feature_put_requests), ( + "Expected feature policy save payloads to include denied groups" + ) + edited_policy_request = next( + request for request in item_put_requests + if request["body"].get("policy_id") == "policy-1" + ) + assert edited_policy_request["body"].get("original_entity_type") == "global_endpoint", ( + "Expected existing policy edits to retain the original entity type" + ) + assert edited_policy_request["body"].get("original_item_id") == "endpoint-1", ( + "Expected existing policy edits to retain the original item target" + ) + assert edited_policy_request["body"].get("entity_type") == "global_agent", ( + "Expected existing policy edits to allow intentional entity retargeting" + ) + assert edited_policy_request["body"].get("item_id") == "global-agent-2", ( + "Expected existing policy edits to send the selected retarget item" + ) + assert item_put_requests[-1]["body"].get("denied_groups") == ["blocked-group-xyz"], ( + "Expected item policy save payload to include block-list groups" + ) finally: context.close() browser.close()
Feature Allow AllAllowed Users (Entra IDs)Allowed Groups (Workspace IDs)Allow ListBlock List
Item Allow AllAllowed UsersAllowed Groups Actions
Allowed UsersAllowed GroupsBlocked UsersBlocked Groups