From f48b69f681bc20a2d00438932a426846d1c9ef26 Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Wed, 30 Sep 2026 17:29:10 -0700 Subject: [PATCH 1/4] fix(lifecycle): transfer ownership with the object type GRANT OWNERSHIP accepts GRANT OWNERSHIP rejects integration subtype names (SECURITY INTEGRATION, STORAGE INTEGRATION, ...), MATERIALIZED VIEW and HYBRID TABLE. Snowflake names them INTEGRATION, VIEW and TABLE. A plan that transferred ownership of any of these failed at apply with a SQL compilation error, for example: GRANT OWNERSHIP ON SECURITY INTEGRATION X TO ROLE ACCOUNTADMIN COPY CURRENT GRANTS. https://docs.snowflake.com/en/sql-reference/sql/grant-ownership --- snowcap/lifecycle.py | 17 ++++++++++++++++- tests/test_lifecycle.py | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/snowcap/lifecycle.py b/snowcap/lifecycle.py index 701f4f5..8070b3b 100644 --- a/snowcap/lifecycle.py +++ b/snowcap/lifecycle.py @@ -868,6 +868,21 @@ def transfer_resource( ) +# GRANT OWNERSHIP names these resource types by a broader object type, and rejects +# their own names. Every integration subtype is also named INTEGRATION. +# https://docs.snowflake.com/en/sql-reference/sql/grant-ownership +_OWNERSHIP_OBJECT_TYPES = { + ResourceType.MATERIALIZED_VIEW: "VIEW", + ResourceType.HYBRID_TABLE: "TABLE", +} + + +def _ownership_object_type(resource_type: ResourceType) -> str: + if "INTEGRATION" in str(resource_type): + return "INTEGRATION" + return _OWNERSHIP_OBJECT_TYPES.get(resource_type, str(resource_type)) + + def transfer__default( urn: URN, owner: str, @@ -877,7 +892,7 @@ def transfer__default( ) -> str: return tidy_sql( "GRANT OWNERSHIP ON", - urn.resource_type, + _ownership_object_type(urn.resource_type), urn.fqn, "TO", owner_resource_type, diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 1d08006..89534f7 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -1316,6 +1316,30 @@ def test_transfer_default(self): # ResourceType.DATABASE_ROLE renders as "DATABASE ROLE" assert "TO DATABASE ROLE NEW_OWNER" in result + @pytest.mark.parametrize( + "resource_type,schema,object_type", + [ + (ResourceType.API_INTEGRATION, None, "INTEGRATION"), + (ResourceType.CATALOG_INTEGRATION, None, "INTEGRATION"), + (ResourceType.EXTERNAL_ACCESS_INTEGRATION, None, "INTEGRATION"), + (ResourceType.NOTIFICATION_INTEGRATION, None, "INTEGRATION"), + (ResourceType.SECURITY_INTEGRATION, None, "INTEGRATION"), + (ResourceType.STORAGE_INTEGRATION, None, "INTEGRATION"), + (ResourceType.MATERIALIZED_VIEW, "MY_SCHEMA", "VIEW"), + (ResourceType.HYBRID_TABLE, "MY_SCHEMA", "TABLE"), + ], + ) + def test_transfer_uses_snowflake_ownership_object_type(self, resource_type, schema, object_type): + """GRANT OWNERSHIP names some resource types by a broader object type. + + https://docs.snowflake.com/en/sql-reference/sql/grant-ownership + """ + database = "MY_DB" if schema else None + urn = make_urn(resource_type, "MY_OBJECT", database=database, schema=schema) + result = transfer_resource(urn, "NEW_OWNER", ResourceType.ROLE, copy_current_grants=True) + assert result.startswith(f"GRANT OWNERSHIP ON {object_type} ") + assert "MY_OBJECT TO ROLE NEW_OWNER COPY CURRENT GRANTS" in result + # ============================================================================ # Test tag masking policy reference functions From b0ddb015304be6b8aef5b7c6cff13861f2a4fdde Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Thu, 1 Oct 2026 12:06:33 -0700 Subject: [PATCH 2/4] fix(lifecycle): transfer external function ownership as FUNCTION GRANT OWNERSHIP has no EXTERNAL FUNCTION object type, so a transfer away from the default SYSADMIN owner failed with a syntax error. External functions are granted as FUNCTION. --- snowcap/lifecycle.py | 8 +++++--- tests/test_lifecycle.py | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/snowcap/lifecycle.py b/snowcap/lifecycle.py index 8070b3b..a4f03a4 100644 --- a/snowcap/lifecycle.py +++ b/snowcap/lifecycle.py @@ -868,12 +868,14 @@ def transfer_resource( ) -# GRANT OWNERSHIP names these resource types by a broader object type, and rejects -# their own names. Every integration subtype is also named INTEGRATION. +# GRANT OWNERSHIP names these resource types by a broader object type. Snowflake +# rejects EXTERNAL FUNCTION and the integration subtype names, and its usage notes +# say to use VIEW for materialized views and TABLE for hybrid tables. # https://docs.snowflake.com/en/sql-reference/sql/grant-ownership _OWNERSHIP_OBJECT_TYPES = { - ResourceType.MATERIALIZED_VIEW: "VIEW", + ResourceType.EXTERNAL_FUNCTION: "FUNCTION", ResourceType.HYBRID_TABLE: "TABLE", + ResourceType.MATERIALIZED_VIEW: "VIEW", } diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 89534f7..e1eab64 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -1325,8 +1325,9 @@ def test_transfer_default(self): (ResourceType.NOTIFICATION_INTEGRATION, None, "INTEGRATION"), (ResourceType.SECURITY_INTEGRATION, None, "INTEGRATION"), (ResourceType.STORAGE_INTEGRATION, None, "INTEGRATION"), - (ResourceType.MATERIALIZED_VIEW, "MY_SCHEMA", "VIEW"), + (ResourceType.EXTERNAL_FUNCTION, "MY_SCHEMA", "FUNCTION"), (ResourceType.HYBRID_TABLE, "MY_SCHEMA", "TABLE"), + (ResourceType.MATERIALIZED_VIEW, "MY_SCHEMA", "VIEW"), ], ) def test_transfer_uses_snowflake_ownership_object_type(self, resource_type, schema, object_type): From dd3c5914e666c93dc446e1c01e7d6e7d65425085 Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Thu, 1 Oct 2026 12:08:08 -0700 Subject: [PATCH 3/4] test(lifecycle): check that types near the mapped ones keep their own ownership names --- tests/test_lifecycle.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index e1eab64..c4bcb26 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -1328,6 +1328,10 @@ def test_transfer_default(self): (ResourceType.EXTERNAL_FUNCTION, "MY_SCHEMA", "FUNCTION"), (ResourceType.HYBRID_TABLE, "MY_SCHEMA", "TABLE"), (ResourceType.MATERIALIZED_VIEW, "MY_SCHEMA", "VIEW"), + # Types close to the mapped ones keep their own names. + (ResourceType.DYNAMIC_TABLE, "MY_SCHEMA", "DYNAMIC TABLE"), + (ResourceType.ICEBERG_TABLE, "MY_SCHEMA", "ICEBERG TABLE"), + (ResourceType.VIEW, "MY_SCHEMA", "VIEW"), ], ) def test_transfer_uses_snowflake_ownership_object_type(self, resource_type, schema, object_type): From db26cd05a566deded672afcbe027d6331c4a4467 Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Wed, 7 Oct 2026 14:01:06 -0700 Subject: [PATCH 4/4] refactor(enums): share one integration type check Five places tested for integration types with their own copy of "INTEGRATION" in str(...): three in create_grant, one in the ownership transfer, and one in the Grant owner default. If one copy changed, the others could drift without a failing test. They now call resource_type_is_integration in snowcap/enums.py, next to resource_type_is_grant. Behavior does not change. --- snowcap/enums.py | 5 +++++ snowcap/lifecycle.py | 10 +++++----- snowcap/resources/grant.py | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/snowcap/enums.py b/snowcap/enums.py index c05b885..ccd9500 100644 --- a/snowcap/enums.py +++ b/snowcap/enums.py @@ -397,6 +397,11 @@ def resource_type_is_grant(resource_type: ResourceType) -> bool: ) +def resource_type_is_integration(resource_type: str) -> bool: + """Return True for INTEGRATION and every integration subtype, such as SECURITY INTEGRATION.""" + return "INTEGRATION" in str(resource_type) + + # Object types whose body or schedule runs with the privileges of their owner rather than # the caller. Snowflake requires stricter authorization to transfer ownership of these: # the receiving role must be in the caller's active role hierarchy, or the caller must hold diff --git a/snowcap/lifecycle.py b/snowcap/lifecycle.py index a4f03a4..3d4ff68 100644 --- a/snowcap/lifecycle.py +++ b/snowcap/lifecycle.py @@ -4,7 +4,7 @@ from inflection import pluralize from .builder import tidy_sql -from .enums import GrantType, ResourceType +from .enums import GrantType, ResourceType, resource_type_is_integration from .identifiers import FQN, URN from .props import BoolProp, IntProp, Props, StringProp from .resource_name import ResourceName @@ -174,7 +174,7 @@ def _grant_container_sql(data: dict) -> str: def create_grant(urn: URN, data: dict, props: Props, if_not_exists: bool): on_type = data["on_type"] - if "INTEGRATION" in str(on_type): + if resource_type_is_integration(on_type): on_type = "INTEGRATION" elif on_type == "ACCOUNT": on_type = "" @@ -193,7 +193,7 @@ def create_grant(urn: URN, data: dict, props: Props, if_not_exists: bool): ) if data["grant_type"] == GrantType.FUTURE: items_type = data["items_type"] - if "INTEGRATION" in items_type: + if resource_type_is_integration(items_type): items_type = "INTEGRATION" return tidy_sql( "GRANT", @@ -210,7 +210,7 @@ def create_grant(urn: URN, data: dict, props: Props, if_not_exists: bool): ) elif data["grant_type"] == GrantType.ALL: items_type = data["items_type"] - if "INTEGRATION" in items_type: + if resource_type_is_integration(items_type): items_type = "INTEGRATION" return tidy_sql( "GRANT", @@ -880,7 +880,7 @@ def transfer_resource( def _ownership_object_type(resource_type: ResourceType) -> str: - if "INTEGRATION" in str(resource_type): + if resource_type_is_integration(resource_type): return "INTEGRATION" return _OWNERSHIP_OBJECT_TYPES.get(resource_type, str(resource_type)) diff --git a/snowcap/resources/grant.py b/snowcap/resources/grant.py index ef8bafc..8c16985 100644 --- a/snowcap/resources/grant.py +++ b/snowcap/resources/grant.py @@ -10,6 +10,7 @@ GrantType, ParseableEnum, ResourceType, + resource_type_is_integration, ) from ..identifiers import ( FQN, @@ -480,7 +481,7 @@ def __init__( # Hacky fix if on_type == ResourceType.SCHEMA and on.upper().startswith("SNOWFLAKE"): owner = "ACCOUNTADMIN" - elif "INTEGRATION" in str(on_type): + elif resource_type_is_integration(on_type): owner = "ACCOUNTADMIN" else: owner = "SYSADMIN"