Skip to content

Commit 0c37d84

Browse files
committed
Added additionall Error Messaging for Lake Formation and AccessDenied
exceptions. New helped method to get if a FG is lake formation governed. Also added testing for these features. --- X-AI-Prompt: Add error checking in feature_group_manager.py to differentiate whether a Glue permissions error (AccessDeniedException) during iceberg properties operations is related to Lake Formation governance or regular IAM. Check the feature group's describe response for LakeFormationConfig before the call, and surface a targeted error message accordingly. X-AI-Tool: Kiro CLI sisyphus
1 parent 58da758 commit 0c37d84

2 files changed

Lines changed: 229 additions & 1 deletion

File tree

sagemaker-mlops/src/sagemaker/mlops/feature_store/feature_group_manager.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from sagemaker.core.s3.utils import parse_s3_url
2626
from sagemaker.core.common_utils import aws_partition
2727
from boto3 import Session
28+
from botocore.exceptions import ClientError
2829
from pyiceberg.catalog import load_catalog
2930
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
3031
from sagemaker.mlops.feature_store.feature_utils import _APPROVED_ICEBERG_PROPERTIES
@@ -76,6 +77,16 @@ class FeatureGroupManager(FeatureGroup):
7677
if FeatureGroup.__doc__ and __doc__:
7778
__doc__ = FeatureGroup.__doc__
7879

80+
def _has_lake_formation_config(self) -> bool:
81+
"""Check if this feature group was created with Lake Formation governance.
82+
83+
Note: Returns False if the object was not hydrated via get()/describe
84+
(i.e., constructed directly with just a name). In that case, the caller
85+
falls back to the generic IAM error message, which is still valid advice.
86+
"""
87+
lf_config = getattr(self, "lake_formation_config", None)
88+
return lf_config is not None and lf_config != Unassigned()
89+
7990
def _validate_table_ownership(self, table, database_name: str, table_name: str):
8091
"""Validate that the Iceberg table belongs to this feature group by checking S3 location."""
8192
table_location = table.metadata.location if table.metadata else None
@@ -174,9 +185,26 @@ def _get_iceberg_properties(
174185
"properties": dict(table.properties),
175186
}
176187

188+
except ClientError as e:
189+
if e.response["Error"]["Code"] == "AccessDeniedException":
190+
if self._has_lake_formation_config():
191+
raise PermissionError(
192+
f"Access denied reading Iceberg properties for '{self.feature_group_name}'. "
193+
f"This feature group uses Lake Formation governance — ensure you have "
194+
f"SELECT and DESCRIBE permissions on the table in Lake Formation, "
195+
f"in addition to IAM permissions."
196+
) from e
197+
raise PermissionError(
198+
f"Access denied reading Iceberg properties for '{self.feature_group_name}'. "
199+
f"Ensure your role has glue:GetTable permission "
200+
f"on the feature group's Glue table."
201+
) from e
202+
raise RuntimeError(
203+
f"Failed to get Iceberg properties for '{self.feature_group_name}': {e}"
204+
) from e
177205
except Exception as e:
178206
raise RuntimeError(
179-
f"Failed to get Iceberg properties for {self.feature_group_name}: {e}"
207+
f"Failed to get Iceberg properties for '{self.feature_group_name}': {e}"
180208
) from e
181209

182210
@retry(
@@ -268,6 +296,27 @@ def _update_iceberg_properties(
268296
"properties_updated": iceberg_properties.properties,
269297
}
270298

299+
except ClientError as e:
300+
if e.response["Error"]["Code"] == "AccessDeniedException":
301+
if self._has_lake_formation_config():
302+
raise PermissionError(
303+
f"Access denied updating Iceberg properties for '{self.feature_group_name}'. "
304+
f"This feature group uses Lake Formation governance — ensure you have "
305+
f"ALTER permission on the table in Lake Formation, "
306+
f"in addition to IAM permissions."
307+
) from e
308+
raise PermissionError(
309+
f"Access denied updating Iceberg properties for '{self.feature_group_name}'. "
310+
f"Ensure your role has glue:UpdateTable permission "
311+
f"on the feature group's Glue table."
312+
) from e
313+
logger.error(
314+
f"Failed to update Iceberg properties for feature group "
315+
f"'{self.feature_group_name}'. Attempted changes: {changed}. Error: {e}"
316+
)
317+
raise RuntimeError(
318+
f"Failed to update Iceberg properties for '{self.feature_group_name}': {e}"
319+
) from e
271320
except Exception as e:
272321
logger.error(
273322
f"Failed to update Iceberg properties for feature group "

sagemaker-mlops/tests/unit/sagemaker/mlops/feature_store/test_iceberg_properties.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,3 +993,182 @@ def test_passes_session_and_region_to_get_iceberg_properties(self, mock_get_clie
993993
)
994994

995995
mock_get_iceberg.assert_called_once_with(session=mock_session, region="us-east-1")
996+
997+
998+
class TestHasLakeFormationConfig:
999+
"""Tests for _has_lake_formation_config method."""
1000+
1001+
def test_returns_false_when_attribute_missing(self):
1002+
"""Test returns False when lake_formation_config attribute does not exist."""
1003+
fg = MagicMock(spec=FeatureGroupManager)
1004+
fg._has_lake_formation_config = FeatureGroupManager._has_lake_formation_config.__get__(fg)
1005+
# MagicMock(spec=...) won't have lake_formation_config since it's not on the class
1006+
del fg.lake_formation_config
1007+
assert fg._has_lake_formation_config() is False
1008+
1009+
def test_returns_false_when_none(self):
1010+
"""Test returns False when lake_formation_config is None."""
1011+
fg = MagicMock(spec=FeatureGroupManager)
1012+
fg._has_lake_formation_config = FeatureGroupManager._has_lake_formation_config.__get__(fg)
1013+
fg.lake_formation_config = None
1014+
assert fg._has_lake_formation_config() is False
1015+
1016+
def test_returns_false_when_unassigned(self):
1017+
"""Test returns False when lake_formation_config is Unassigned()."""
1018+
from sagemaker.core.shapes import Unassigned
1019+
1020+
fg = MagicMock(spec=FeatureGroupManager)
1021+
fg._has_lake_formation_config = FeatureGroupManager._has_lake_formation_config.__get__(fg)
1022+
fg.lake_formation_config = Unassigned()
1023+
assert fg._has_lake_formation_config() is False
1024+
1025+
def test_returns_true_when_config_present(self):
1026+
"""Test returns True when lake_formation_config has a real value."""
1027+
fg = MagicMock(spec=FeatureGroupManager)
1028+
fg._has_lake_formation_config = FeatureGroupManager._has_lake_formation_config.__get__(fg)
1029+
fg.lake_formation_config = MagicMock()
1030+
assert fg._has_lake_formation_config() is True
1031+
1032+
1033+
class TestGetIcebergPropertiesAccessDenied:
1034+
"""Tests for AccessDeniedException handling in _get_iceberg_properties."""
1035+
1036+
def setup_method(self):
1037+
from sagemaker.core.shapes import OfflineStoreConfig, S3StorageConfig, DataCatalogConfig
1038+
1039+
self.fg = MagicMock(spec=FeatureGroupManager)
1040+
self.fg._get_iceberg_properties = FeatureGroupManager._get_iceberg_properties.__get__(self.fg)
1041+
self.fg.feature_group_name = "test-fg"
1042+
self.fg.offline_store_config = OfflineStoreConfig(
1043+
s3_storage_config=S3StorageConfig(s3_uri="s3://test-bucket/path"),
1044+
data_catalog_config=DataCatalogConfig(
1045+
catalog="AwsDataCatalog", database="test_db", table_name="test_table"
1046+
),
1047+
table_format="Iceberg",
1048+
)
1049+
1050+
def _make_client_error(self, code):
1051+
from botocore.exceptions import ClientError
1052+
return ClientError({"Error": {"Code": code, "Message": "denied"}}, "GetTable")
1053+
1054+
@patch("sagemaker.mlops.feature_store.feature_group_manager.load_catalog")
1055+
def test_access_denied_with_lake_formation_raises_permission_error(self, mock_load_catalog):
1056+
"""Test PermissionError with LF message when AccessDenied and LF config present."""
1057+
mock_catalog = MagicMock()
1058+
mock_catalog.load_table.side_effect = self._make_client_error("AccessDeniedException")
1059+
mock_load_catalog.return_value = mock_catalog
1060+
self.fg._has_lake_formation_config.return_value = True
1061+
1062+
with pytest.raises(PermissionError, match="Lake Formation governance"):
1063+
self.fg._get_iceberg_properties(session=MagicMock())
1064+
1065+
@patch("sagemaker.mlops.feature_store.feature_group_manager.load_catalog")
1066+
def test_access_denied_without_lake_formation_raises_permission_error(self, mock_load_catalog):
1067+
"""Test PermissionError with IAM message when AccessDenied and no LF config."""
1068+
mock_catalog = MagicMock()
1069+
mock_catalog.load_table.side_effect = self._make_client_error("AccessDeniedException")
1070+
mock_load_catalog.return_value = mock_catalog
1071+
self.fg._has_lake_formation_config.return_value = False
1072+
1073+
with pytest.raises(PermissionError, match="glue:GetTable"):
1074+
self.fg._get_iceberg_properties(session=MagicMock())
1075+
1076+
@patch("sagemaker.mlops.feature_store.feature_group_manager.load_catalog")
1077+
def test_non_access_denied_client_error_raises_runtime_error(self, mock_load_catalog):
1078+
"""Test RuntimeError for non-AccessDenied ClientError."""
1079+
mock_catalog = MagicMock()
1080+
mock_catalog.load_table.side_effect = self._make_client_error("EntityNotFoundException")
1081+
mock_load_catalog.return_value = mock_catalog
1082+
1083+
with pytest.raises(RuntimeError, match="Failed to get Iceberg properties"):
1084+
self.fg._get_iceberg_properties(session=MagicMock())
1085+
1086+
@patch("sagemaker.mlops.feature_store.feature_group_manager.load_catalog")
1087+
def test_access_denied_permission_error_chains_original(self, mock_load_catalog):
1088+
"""Test that PermissionError chains the original ClientError via __cause__."""
1089+
original = self._make_client_error("AccessDeniedException")
1090+
mock_catalog = MagicMock()
1091+
mock_catalog.load_table.side_effect = original
1092+
mock_load_catalog.return_value = mock_catalog
1093+
self.fg._has_lake_formation_config.return_value = False
1094+
1095+
with pytest.raises(PermissionError) as exc_info:
1096+
self.fg._get_iceberg_properties(session=MagicMock())
1097+
assert exc_info.value.__cause__ is original
1098+
1099+
1100+
class TestUpdateIcebergPropertiesAccessDenied:
1101+
"""Tests for AccessDeniedException handling in _update_iceberg_properties."""
1102+
1103+
def setup_method(self):
1104+
self.fg = MagicMock(spec=FeatureGroupManager)
1105+
self.fg._update_iceberg_properties = FeatureGroupManager._update_iceberg_properties.__get__(self.fg)
1106+
self.fg.feature_group_name = "test-fg"
1107+
1108+
def _make_client_error(self, code):
1109+
from botocore.exceptions import ClientError
1110+
return ClientError({"Error": {"Code": code, "Message": "denied"}}, "UpdateTable")
1111+
1112+
def _setup_get_result(self):
1113+
mock_table = MagicMock()
1114+
mock_table.transaction.return_value.__enter__ = MagicMock(return_value=MagicMock())
1115+
mock_table.transaction.return_value.__exit__ = MagicMock(return_value=False)
1116+
self.fg._get_iceberg_properties.return_value = {
1117+
"database_name": "test_db",
1118+
"table_name": "test_table",
1119+
"table": mock_table,
1120+
"properties": {},
1121+
}
1122+
return mock_table
1123+
1124+
def test_access_denied_with_lake_formation_raises_permission_error(self):
1125+
"""Test PermissionError with LF message when AccessDenied and LF config present."""
1126+
mock_table = self._setup_get_result()
1127+
mock_table.transaction().__enter__().set_properties.side_effect = self._make_client_error("AccessDeniedException")
1128+
self.fg._has_lake_formation_config.return_value = True
1129+
1130+
props = IcebergProperties(properties={"write.target-file-size-bytes": "536870912"})
1131+
with pytest.raises(PermissionError, match="Lake Formation governance"):
1132+
self.fg._update_iceberg_properties(iceberg_properties=props)
1133+
1134+
def test_access_denied_without_lake_formation_raises_permission_error(self):
1135+
"""Test PermissionError with IAM message when AccessDenied and no LF config."""
1136+
mock_table = self._setup_get_result()
1137+
mock_table.transaction().__enter__().set_properties.side_effect = self._make_client_error("AccessDeniedException")
1138+
self.fg._has_lake_formation_config.return_value = False
1139+
1140+
props = IcebergProperties(properties={"write.target-file-size-bytes": "536870912"})
1141+
with pytest.raises(PermissionError, match="glue:UpdateTable"):
1142+
self.fg._update_iceberg_properties(iceberg_properties=props)
1143+
1144+
def test_non_access_denied_client_error_raises_runtime_error(self):
1145+
"""Test RuntimeError for non-AccessDenied ClientError."""
1146+
mock_table = self._setup_get_result()
1147+
mock_table.transaction().__enter__().set_properties.side_effect = self._make_client_error("InternalServiceException")
1148+
self.fg._has_lake_formation_config.return_value = False
1149+
1150+
props = IcebergProperties(properties={"write.target-file-size-bytes": "536870912"})
1151+
with pytest.raises(RuntimeError, match="Failed to update Iceberg properties"):
1152+
self.fg._update_iceberg_properties(iceberg_properties=props)
1153+
1154+
def test_access_denied_permission_error_chains_original(self):
1155+
"""Test that PermissionError chains the original ClientError via __cause__."""
1156+
mock_table = self._setup_get_result()
1157+
original = self._make_client_error("AccessDeniedException")
1158+
mock_table.transaction().__enter__().set_properties.side_effect = original
1159+
self.fg._has_lake_formation_config.return_value = True
1160+
1161+
props = IcebergProperties(properties={"write.target-file-size-bytes": "536870912"})
1162+
with pytest.raises(PermissionError) as exc_info:
1163+
self.fg._update_iceberg_properties(iceberg_properties=props)
1164+
assert exc_info.value.__cause__ is original
1165+
1166+
def test_access_denied_lf_message_mentions_alter(self):
1167+
"""Test that LF error message mentions ALTER permission."""
1168+
mock_table = self._setup_get_result()
1169+
mock_table.transaction().__enter__().set_properties.side_effect = self._make_client_error("AccessDeniedException")
1170+
self.fg._has_lake_formation_config.return_value = True
1171+
1172+
props = IcebergProperties(properties={"write.target-file-size-bytes": "536870912"})
1173+
with pytest.raises(PermissionError, match="ALTER permission"):
1174+
self.fg._update_iceberg_properties(iceberg_properties=props)

0 commit comments

Comments
 (0)