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() {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.
| Feature | Allow All | -Allowed Users (Entra IDs) | -Allowed Groups (Workspace IDs) | +Allow List | +Block List | Item | Allow All | -Allowed Users | -Allowed Groups | Actions | 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:Allowed Users | ", + "Allowed Groups | ", + "Blocked Users | ", + "Blocked Groups | ", + ]: + 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 '
|---|