From 75b3c508550fc42d1b003a9fcd1e3d3eb396fef8 Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Thu, 16 Jul 2026 23:14:03 -0700 Subject: [PATCH] feat(security_integration): implement custom OAuth integration (OAUTH_CLIENT = CUSTOM) Add SnowflakeCustomOAuthSecurityIntegration so custom OAuth clients (e.g. Claude.ai's Snowflake MCP connector) can be managed declaratively instead of via manual SQL. Property changes always apply via ALTER SECURITY INTEGRATION - never CREATE OR REPLACE, which would rotate the Snowflake-issued client_id/client_secret and break live clients. The immutable oauth_client_type is marked triggers_replacement so changing it fails at plan time instead of emitting an ALTER Snowflake rejects. Fetch normalizes DESC output for drift-free plans: role lists are case-canonicalized and sorted on both sides, always-blocked admin roles (shared ALWAYS_BLOCKED_OAUTH_ROLES constant) are stripped, and empty values map to None. oauth_alternate_redirect_uris is create-only (not returned by DESC as far as we can confirm), documented as such. Closes #29 --- docs/SUMMARY.md | 1 + ...flake_custom_oauth_security_integration.md | 81 +++++++ mkdocs.yml | 1 + snowcap/builtins.py | 11 + snowcap/data_provider.py | 35 ++-- snowcap/resources/__init__.py | 2 + snowcap/resources/security_integration.py | 198 +++++++++++++++++- ...ake_custom_oauth_security_integration.json | 18 ++ .../data_provider/test_fetch_resource.py | 23 ++ tests/test_data_provider.py | 123 +++++++++++ tests/test_lifecycle.py | 120 +++++++++++ 11 files changed, 598 insertions(+), 15 deletions(-) create mode 100644 docs/resources/snowflake_custom_oauth_security_integration.md create mode 100644 tests/fixtures/json/snowflake_custom_oauth_security_integration.json diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 05dc8d8..2c9fb2b 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -58,6 +58,7 @@ * [Service](resources/service.md) * [SessionPolicy](resources/session_policy.md) * [Share](resources/share.md) +* [SnowflakeCustomOAuthSecurityIntegration](resources/snowflake_custom_oauth_security_integration.md) * [SnowflakePartnerOAuthSecurityIntegration](resources/snowflake_partner_oauth_security_integration.md) * [SnowservicesOAuthSecurityIntegration](resources/snowservices_oauth_security_integration.md) * [StageStream](resources/stage_stream.md) diff --git a/docs/resources/snowflake_custom_oauth_security_integration.md b/docs/resources/snowflake_custom_oauth_security_integration.md new file mode 100644 index 0000000..0d3a58a --- /dev/null +++ b/docs/resources/snowflake_custom_oauth_security_integration.md @@ -0,0 +1,81 @@ +--- +description: >- + A Snowflake OAuth security integration for a custom OAuth client, with credentials generated and managed by Snowflake. +--- + +# SnowflakeCustomOAuthSecurityIntegration + +[Snowflake Documentation](https://docs.snowflake.com/en/sql-reference/sql/create-security-integration-oauth-snowflake) | Snowcap CLI label: `snowflake_custom_oauth_security_integration` + +A security integration in Snowflake for a custom OAuth client that Snowflake itself +issues and manages. Unlike the partner integrations, Snowflake generates the OAuth +client_id and client_secret for you; retrieve them with +SYSTEM$SHOW_OAUTH_CLIENT_SECRETS, they cannot be set through this resource. +ACCOUNTADMIN, ORGADMIN, GLOBALORGADMIN, and SECURITYADMIN are always blocked by +Snowflake and must not be listed in blocked_roles_list. +Every property except oauth_client_type is applied with ALTER SECURITY INTEGRATION +and never recreates the integration. oauth_client_type cannot be changed after +creation: snowcap fails the plan rather than recreating the integration, because +recreation would rotate the Snowflake-issued client_id/client_secret and break live +OAuth clients. To change it, recreate the integration manually and migrate clients. +oauth_alternate_redirect_uris is CREATE-ONLY in snowcap: it is not fetchable, so +editing it in YAML after creation produces no diff and no error. Changes must be +applied out-of-band with +ALTER SECURITY INTEGRATION SET OAUTH_ALTERNATE_REDIRECT_URIS = (...). + + +## Examples + +### YAML + +```yaml +security_integrations: + - name: claude_mcp_oauth + enabled: true + oauth_client_type: CONFIDENTIAL + oauth_redirect_uri: https://claude.ai/api/mcp/auth_callback + oauth_issue_refresh_tokens: true + oauth_refresh_token_validity: 7776000 + oauth_use_secondary_roles: NONE + oauth_enforce_pkce: true + blocked_roles_list: + - SYSADMIN + comment: OAuth client for the Claude MCP connector +``` + + +### Python + +```python +claude_mcp_oauth = SnowflakeCustomOAuthSecurityIntegration( + name="claude_mcp_oauth", + enabled=True, + oauth_client_type="CONFIDENTIAL", + oauth_redirect_uri="https://claude.ai/api/mcp/auth_callback", + oauth_issue_refresh_tokens=True, + oauth_refresh_token_validity=7776000, + oauth_use_secondary_roles="NONE", + oauth_enforce_pkce=True, + blocked_roles_list=["SYSADMIN"], + comment="OAuth client for the Claude MCP connector" +) +``` + + +## Fields + +* `name` (string, required) - The name of the security integration. +* `enabled` (bool) - Specifies if the security integration is enabled. Defaults to True. +* `oauth_client_type` (string or OAuthClientType, required) - The type of OAuth client. Supported values are 'CONFIDENTIAL' and 'PUBLIC'. Cannot be changed after creation. +* `oauth_redirect_uri` (string, required) - The redirect URI the client uses to complete the OAuth flow. +* `oauth_alternate_redirect_uris` (list) - Additional allowed redirect URIs, set at creation only. +* `oauth_issue_refresh_tokens` (bool) - Indicates if refresh tokens should be issued. Defaults to True. +* `oauth_refresh_token_validity` (int) - The validity period of the refresh token in seconds. Defaults to 7776000. +* `oauth_use_secondary_roles` (string or OAuthUseSecondaryRoles) - Whether secondary roles are activated for OAuth sessions. Supported values are 'IMPLICIT' and 'NONE'. Defaults to 'NONE'. +* `oauth_enforce_pkce` (bool) - Requires clients to use PKCE during the OAuth flow. Defaults to False. +* `network_policy` (string) - The network policy enforced for requests made with this integration's tokens. +* `pre_authorized_roles_list` (list) - Roles granted access without displaying a consent screen to the user. +* `blocked_roles_list` (list) - Roles that are not allowed to use this integration. +* `comment` (string) - A comment about the security integration. + + diff --git a/mkdocs.yml b/mkdocs.yml index 446e21e..2385f78 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -164,6 +164,7 @@ nav: - ExternalAccessIntegration: resources/external_access_integration.md - Security: - APIAuthenticationSecurityIntegration: resources/api_authentication_security_integration.md + - SnowflakeCustomOAuthSecurityIntegration: resources/snowflake_custom_oauth_security_integration.md - SnowflakePartnerOAuthSecurityIntegration: resources/snowflake_partner_oauth_security_integration.md - SnowservicesOAuthSecurityIntegration: resources/snowservices_oauth_security_integration.md - Storage: diff --git a/snowcap/builtins.py b/snowcap/builtins.py index 385dc0c..62c10e8 100644 --- a/snowcap/builtins.py +++ b/snowcap/builtins.py @@ -24,3 +24,14 @@ SYSTEM_SECURITY_INTEGRATIONS = [ "APPLICA", ] + +# Snowflake always blocks these roles from a CUSTOM OAuth integration, regardless of +# what's in BLOCKED_ROLES_LIST, and echoes them back in DESC. Shared by the spec (which +# rejects them in blocked_roles_list) and the fetch layer (which strips them from state +# fetched from Snowflake), so both sides agree on what "always blocked" means. +ALWAYS_BLOCKED_OAUTH_ROLES = [ + "ACCOUNTADMIN", + "ORGADMIN", + "GLOBALORGADMIN", + "SECURITYADMIN", +] diff --git a/snowcap/data_provider.py b/snowcap/data_provider.py index f556b0e..70b671e 100644 --- a/snowcap/data_provider.py +++ b/snowcap/data_provider.py @@ -12,6 +12,7 @@ from snowflake.connector.errors import ProgrammingError from .builtins import ( + ALWAYS_BLOCKED_OAUTH_ROLES, SYSTEM_DATABASES, SYSTEM_ROLES, SYSTEM_SECURITY_INTEGRATIONS, @@ -2546,20 +2547,30 @@ def fetch_security_integration(session: SnowflakeConnection, fqn: FQN): "enabled": data["enabled"] == "true", "owner": owner, } + elif oauth_client == "CUSTOM": + pre_authorized_roles_list = properties.get("pre_authorized_roles_list") or None + if pre_authorized_roles_list: + pre_authorized_roles_list = sorted(pre_authorized_roles_list) + blocked_roles_list = set(properties.get("blocked_roles_list") or []) - set(ALWAYS_BLOCKED_OAUTH_ROLES) + return { + "name": _quote_snowflake_identifier(data["name"]), + "type": type_, + "oauth_client": oauth_client, + "enabled": data["enabled"] == "true", + "oauth_client_type": properties.get("oauth_client_type"), + "oauth_redirect_uri": properties.get("oauth_redirect_uri"), + "oauth_issue_refresh_tokens": properties.get("oauth_issue_refresh_tokens"), + "oauth_refresh_token_validity": int(properties["oauth_refresh_token_validity"]), + "oauth_use_secondary_roles": properties.get("oauth_use_secondary_roles"), + "oauth_enforce_pkce": properties.get("oauth_enforce_pkce"), + "network_policy": properties.get("network_policy"), + "pre_authorized_roles_list": pre_authorized_roles_list, + "blocked_roles_list": sorted(blocked_roles_list) or None, + "comment": data["comment"] or None, + "owner": owner, + } raise Exception(f"Unsupported security integration type {data['type']}") - # return { - # "name": _quote_snowflake_identifier(data["name"]), - # "type": type_, - # "enabled": data["enabled"] == "true", - # "oauth_client": oauth_client, - # # "oauth_client_secret": None, - # # "oauth_redirect_uri": None, - # "oauth_issue_refresh_tokens": properties["oauth_issue_refresh_tokens"] == "true", - # "oauth_refresh_token_validity": properties["oauth_refresh_token_validity"], - # "comment": data["comment"] or None, - # } - def fetch_sequence(session: SnowflakeConnection, fqn: FQN): show_result = execute(session, f"SHOW SEQUENCES LIKE '{fqn.name}' IN SCHEMA {fqn.database}.{fqn.schema}") diff --git a/snowcap/resources/__init__.py b/snowcap/resources/__init__.py index 2406891..54370e6 100644 --- a/snowcap/resources/__init__.py +++ b/snowcap/resources/__init__.py @@ -52,6 +52,7 @@ from .secret import GenericSecret, OAuthSecret, PasswordSecret from .security_integration import ( APIAuthenticationSecurityIntegration, + SnowflakeCustomOAuthSecurityIntegration, SnowflakePartnerOAuthSecurityIntegration, SnowservicesOAuthSecurityIntegration, ) @@ -139,6 +140,7 @@ "Sequence", "Service", "Share", + "SnowflakeCustomOAuthSecurityIntegration", "SnowflakeIcebergTable", "SnowflakePartnerOAuthSecurityIntegration", "SnowservicesOAuthSecurityIntegration", diff --git a/snowcap/resources/security_integration.py b/snowcap/resources/security_integration.py index 315dce7..46fe5ed 100644 --- a/snowcap/resources/security_integration.py +++ b/snowcap/resources/security_integration.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field +from ..builtins import ALWAYS_BLOCKED_OAUTH_ROLES from ..enums import ParseableEnum, ResourceType from ..props import ( BoolProp, @@ -15,6 +16,14 @@ from .role import Role +def _canonicalize_role_name(name: str) -> str: + """Snowflake echoes role names back in DESC uppercased, unless the role was + created with a quoted, case-sensitive identifier. Normalize the same way so a + manifest role name compares equal to what fetch returns.""" + resource_name = ResourceName(name) + return resource_name._name if resource_name._quoted else resource_name._name.upper() + + class SecurityIntegrationType(ParseableEnum): API_AUTHENTICATION = "API_AUTHENTICATION" EXTERNAL_OAUTH = "EXTERNAL_OAUTH" @@ -31,6 +40,16 @@ class OAuthClient(ParseableEnum): TABLEAU_SERVER = "TABLEAU_SERVER" +class OAuthClientType(ParseableEnum): + CONFIDENTIAL = "CONFIDENTIAL" + PUBLIC = "PUBLIC" + + +class OAuthUseSecondaryRoles(ParseableEnum): + IMPLICIT = "IMPLICIT" + NONE = "NONE" + + @dataclass(unsafe_hash=True) class _SnowflakePartnerOAuthSecurityIntegration(ResourceSpec): name: ResourceName @@ -339,6 +358,181 @@ def __init__( ) +@dataclass(unsafe_hash=True) +class _SnowflakeCustomOAuthSecurityIntegration(ResourceSpec): + name: ResourceName + type: SecurityIntegrationType = SecurityIntegrationType.OAUTH + enabled: bool = True + oauth_client: OAuthClient = OAuthClient.CUSTOM + oauth_client_type: OAuthClientType = field(default=None, metadata={"triggers_replacement": True}) + oauth_redirect_uri: str = None + oauth_alternate_redirect_uris: list[str] = field(default=None, metadata={"fetchable": False}) + oauth_issue_refresh_tokens: bool = True + oauth_refresh_token_validity: int = 7776000 + oauth_use_secondary_roles: OAuthUseSecondaryRoles = OAuthUseSecondaryRoles.NONE + oauth_enforce_pkce: bool = False + network_policy: str = None + pre_authorized_roles_list: list[str] = None + blocked_roles_list: list[str] = None + comment: str = None + owner: Role = "ACCOUNTADMIN" + + def __post_init__(self): + super().__post_init__() + if self.oauth_client_type is None: + raise ValueError("oauth_client_type must be set") + if self.oauth_redirect_uri is None: + raise ValueError("oauth_redirect_uri must be set") + if self.pre_authorized_roles_list is not None: + self.pre_authorized_roles_list = sorted( + _canonicalize_role_name(role) for role in self.pre_authorized_roles_list + ) + if self.blocked_roles_list is not None: + self.blocked_roles_list = sorted(_canonicalize_role_name(role) for role in self.blocked_roles_list) + always_blocked = set(self.blocked_roles_list) & set(ALWAYS_BLOCKED_OAUTH_ROLES) + if always_blocked: + raise ValueError( + f"blocked_roles_list must not include roles Snowflake always blocks: {sorted(always_blocked)}" + ) + + +class SnowflakeCustomOAuthSecurityIntegration(NamedResource, Resource): + """ + Description: + A security integration in Snowflake for a custom OAuth client that Snowflake itself + issues and manages. Unlike the partner integrations, Snowflake generates the OAuth + client_id and client_secret for you; retrieve them with + SYSTEM$SHOW_OAUTH_CLIENT_SECRETS, they cannot be set through this resource. + + ACCOUNTADMIN, ORGADMIN, GLOBALORGADMIN, and SECURITYADMIN are always blocked by + Snowflake and must not be listed in blocked_roles_list. + + Every property except oauth_client_type is applied with ALTER SECURITY INTEGRATION + and never recreates the integration. oauth_client_type cannot be changed after + creation: snowcap fails the plan rather than recreating the integration, because + recreation would rotate the Snowflake-issued client_id/client_secret and break live + OAuth clients. To change it, recreate the integration manually and migrate clients. + + oauth_alternate_redirect_uris is CREATE-ONLY in snowcap: it is not fetchable, so + editing it in YAML after creation produces no diff and no error. Changes must be + applied out-of-band with + ALTER SECURITY INTEGRATION SET OAUTH_ALTERNATE_REDIRECT_URIS = (...). + + Snowflake Docs: + https://docs.snowflake.com/en/sql-reference/sql/create-security-integration-oauth-snowflake + + Fields: + name (string, required): The name of the security integration. + enabled (bool): Specifies if the security integration is enabled. Defaults to True. + oauth_client_type (string or OAuthClientType, required): The type of OAuth client. Supported values are 'CONFIDENTIAL' and 'PUBLIC'. Cannot be changed after creation. + oauth_redirect_uri (string, required): The redirect URI the client uses to complete the OAuth flow. + oauth_alternate_redirect_uris (list): Additional allowed redirect URIs, set at creation only. + oauth_issue_refresh_tokens (bool): Indicates if refresh tokens should be issued. Defaults to True. + oauth_refresh_token_validity (int): The validity period of the refresh token in seconds. Defaults to 7776000. + oauth_use_secondary_roles (string or OAuthUseSecondaryRoles): Whether secondary roles are activated for OAuth sessions. Supported values are 'IMPLICIT' and 'NONE'. Defaults to 'NONE'. + oauth_enforce_pkce (bool): Requires clients to use PKCE during the OAuth flow. Defaults to False. + network_policy (string): The network policy enforced for requests made with this integration's tokens. + pre_authorized_roles_list (list): Roles granted access without displaying a consent screen to the user. + blocked_roles_list (list): Roles that are not allowed to use this integration. + comment (string): A comment about the security integration. + + Python: + + ```python + claude_mcp_oauth = SnowflakeCustomOAuthSecurityIntegration( + name="claude_mcp_oauth", + enabled=True, + oauth_client_type="CONFIDENTIAL", + oauth_redirect_uri="https://claude.ai/api/mcp/auth_callback", + oauth_issue_refresh_tokens=True, + oauth_refresh_token_validity=7776000, + oauth_use_secondary_roles="NONE", + oauth_enforce_pkce=True, + blocked_roles_list=["SYSADMIN"], + comment="OAuth client for the Claude MCP connector" + ) + ``` + + Yaml: + + ```yaml + security_integrations: + - name: claude_mcp_oauth + enabled: true + oauth_client_type: CONFIDENTIAL + oauth_redirect_uri: https://claude.ai/api/mcp/auth_callback + oauth_issue_refresh_tokens: true + oauth_refresh_token_validity: 7776000 + oauth_use_secondary_roles: NONE + oauth_enforce_pkce: true + blocked_roles_list: + - SYSADMIN + comment: OAuth client for the Claude MCP connector + ``` + """ + + resource_type = ResourceType.SECURITY_INTEGRATION + props = Props( + type=EnumProp("type", [SecurityIntegrationType.OAUTH]), + enabled=BoolProp("enabled"), + oauth_client=EnumProp("oauth_client", [OAuthClient.CUSTOM]), + oauth_client_type=EnumProp( + "oauth_client_type", [OAuthClientType.CONFIDENTIAL, OAuthClientType.PUBLIC], quoted=True + ), + oauth_redirect_uri=StringProp("oauth_redirect_uri"), + oauth_alternate_redirect_uris=StringListProp("oauth_alternate_redirect_uris", parens=True), + oauth_issue_refresh_tokens=BoolProp("oauth_issue_refresh_tokens"), + oauth_refresh_token_validity=IntProp("oauth_refresh_token_validity"), + oauth_use_secondary_roles=EnumProp( + "oauth_use_secondary_roles", [OAuthUseSecondaryRoles.IMPLICIT, OAuthUseSecondaryRoles.NONE] + ), + oauth_enforce_pkce=BoolProp("oauth_enforce_pkce"), + network_policy=StringProp("network_policy"), + pre_authorized_roles_list=StringListProp("pre_authorized_roles_list", parens=True), + blocked_roles_list=StringListProp("blocked_roles_list", parens=True), + comment=StringProp("comment"), + ) + scope = AccountScope() + spec = _SnowflakeCustomOAuthSecurityIntegration + + def __init__( + self, + name: str, + enabled: bool = True, + oauth_client_type: OAuthClientType = None, + oauth_redirect_uri: str = None, + oauth_alternate_redirect_uris: list[str] = None, + oauth_issue_refresh_tokens: bool = True, + oauth_refresh_token_validity: int = 7776000, + oauth_use_secondary_roles: OAuthUseSecondaryRoles = OAuthUseSecondaryRoles.NONE, + oauth_enforce_pkce: bool = False, + network_policy: str = None, + pre_authorized_roles_list: list[str] = None, + blocked_roles_list: list[str] = None, + comment: str = None, + **kwargs, + ): + kwargs.pop("type", None) + kwargs.pop("owner", None) + kwargs.pop("oauth_client", None) + super().__init__(name, **kwargs) + self._data: _SnowflakeCustomOAuthSecurityIntegration = _SnowflakeCustomOAuthSecurityIntegration( + name=self._name, + enabled=enabled, + oauth_client_type=oauth_client_type, + oauth_redirect_uri=oauth_redirect_uri, + oauth_alternate_redirect_uris=oauth_alternate_redirect_uris, + oauth_issue_refresh_tokens=oauth_issue_refresh_tokens, + oauth_refresh_token_validity=oauth_refresh_token_validity, + oauth_use_secondary_roles=oauth_use_secondary_roles, + oauth_enforce_pkce=oauth_enforce_pkce, + network_policy=network_policy, + pre_authorized_roles_list=pre_authorized_roles_list, + blocked_roles_list=blocked_roles_list, + comment=comment, + ) + + def _resolver(data: dict): security_integration_type = SecurityIntegrationType(data["type"]) if security_integration_type == SecurityIntegrationType.API_AUTHENTICATION: @@ -352,9 +546,7 @@ def _resolver(data: dict): ]: return SnowflakePartnerOAuthSecurityIntegration elif oauth_client == OAuthClient.CUSTOM: - # return SnowflakeCustomOAuthSecurityIntegration - # return None - raise NotImplementedError + return SnowflakeCustomOAuthSecurityIntegration elif oauth_client == OAuthClient.SNOWSERVICES_INGRESS: return SnowservicesOAuthSecurityIntegration return None diff --git a/tests/fixtures/json/snowflake_custom_oauth_security_integration.json b/tests/fixtures/json/snowflake_custom_oauth_security_integration.json new file mode 100644 index 0000000..266ce8c --- /dev/null +++ b/tests/fixtures/json/snowflake_custom_oauth_security_integration.json @@ -0,0 +1,18 @@ +{ + "name": "TEST_CUSTOM_OAUTH_INTEGRATION", + "type": "OAUTH", + "enabled": true, + "oauth_client": "CUSTOM", + "oauth_client_type": "CONFIDENTIAL", + "oauth_redirect_uri": "https://example.com/oauth/callback", + "oauth_alternate_redirect_uris": ["https://example.com/oauth/callback2"], + "oauth_issue_refresh_tokens": true, + "oauth_refresh_token_validity": 7776000, + "oauth_use_secondary_roles": "NONE", + "oauth_enforce_pkce": true, + "network_policy": null, + "pre_authorized_roles_list": null, + "blocked_roles_list": null, + "owner": "ACCOUNTADMIN", + "comment": "Test custom OAuth security integration" +} diff --git a/tests/integration/data_provider/test_fetch_resource.py b/tests/integration/data_provider/test_fetch_resource.py index 7199d2a..976d274 100644 --- a/tests/integration/data_provider/test_fetch_resource.py +++ b/tests/integration/data_provider/test_fetch_resource.py @@ -412,6 +412,29 @@ def test_fetch_api_authentication_security_integration(cursor, suffix, marked_fo assert result == data +def test_fetch_snowflake_custom_oauth_security_integration(cursor, suffix, marked_for_cleanup): + security_integration = res.SnowflakeCustomOAuthSecurityIntegration( + name=f"CUSTOM_OAUTH_SECURITY_INTEGRATION_{suffix}", + oauth_client_type="CONFIDENTIAL", + oauth_redirect_uri="https://example.com/oauth/callback", + oauth_enforce_pkce=True, + oauth_issue_refresh_tokens=True, + oauth_refresh_token_validity=86400, + oauth_use_secondary_roles="IMPLICIT", + pre_authorized_roles_list=["PUBLIC"], + comment="Test custom OAuth security integration", + enabled=True, + ) + create(cursor, security_integration) + marked_for_cleanup.append(security_integration) + + result = safe_fetch(cursor, security_integration.urn) + assert result is not None + result = clean_resource_data(res.SnowflakeCustomOAuthSecurityIntegration.spec, result) + data = clean_resource_data(res.SnowflakeCustomOAuthSecurityIntegration.spec, security_integration.to_dict()) + assert result == data + + def test_fetch_table_stream(cursor, suffix, marked_for_cleanup): stream = res.TableStream( name=f"SOME_TABLE_STREAM_{suffix}", diff --git a/tests/test_data_provider.py b/tests/test_data_provider.py index 2368656..164f7ba 100644 --- a/tests/test_data_provider.py +++ b/tests/test_data_provider.py @@ -33,6 +33,7 @@ remove_none_values, # Dispatcher functions fetch_resource, + fetch_security_integration, fetch_warehouse, list_resource, list_account_scoped_resource, @@ -737,6 +738,128 @@ def test_preserves_snowpark_memory_constraint(self, mock_show_resources): assert result["resource_constraint"] == "MEMORY_16X_X86" +def _security_integration_show_row(**overrides): + row = { + "name": "CUSTOM_OAUTH", + "type": "OAUTH - CUSTOM", + "enabled": "true", + "comment": "", + } + row.update(overrides) + return row + + +def _custom_oauth_desc_rows(blocked_roles_list="[]", pre_authorized_roles_list="[]"): + def row(property, value, property_type): + return {"property": property, "property_value": value, "property_type": property_type} + + return [ + row("OAUTH_CLIENT_TYPE", "CONFIDENTIAL", "String"), + row("OAUTH_REDIRECT_URI", "https://example.com/callback", "String"), + row("OAUTH_ISSUE_REFRESH_TOKENS", "true", "Boolean"), + row("OAUTH_REFRESH_TOKEN_VALIDITY", "7776000", "Long"), + row("OAUTH_USE_SECONDARY_ROLES", "NONE", "String"), + row("OAUTH_ENFORCE_PKCE", "false", "Boolean"), + row("NETWORK_POLICY", "", "String"), + row("PRE_AUTHORIZED_ROLES_LIST", pre_authorized_roles_list, "List"), + row("BLOCKED_ROLES_LIST", blocked_roles_list, "List"), + ] + + +class TestFetchSecurityIntegration: + """Tests for fetch_security_integration's OAUTH branch.""" + + def _mock_execute(self, desc_rows, show_row=None): + show_row = show_row or _security_integration_show_row() + + def execute(session, sql, cacheable=False): + if sql.startswith("SHOW SECURITY INTEGRATIONS"): + return [show_row] + return desc_rows + + return execute + + @patch("snowcap.data_provider._fetch_owner") + @patch("snowcap.data_provider.execute") + def test_custom_oauth_admin_only_blocked_roles_normalizes_to_none(self, mock_execute, mock_fetch_owner): + mock_fetch_owner.return_value = "ACCOUNTADMIN" + mock_execute.side_effect = self._mock_execute( + _custom_oauth_desc_rows(blocked_roles_list="[ACCOUNTADMIN, SECURITYADMIN]") + ) + + result = fetch_security_integration(MagicMock(), FQN(name=ResourceName("CUSTOM_OAUTH"))) + + assert result["blocked_roles_list"] is None + + @patch("snowcap.data_provider._fetch_owner") + @patch("snowcap.data_provider.execute") + def test_custom_oauth_blocked_roles_strips_admin_roles(self, mock_execute, mock_fetch_owner): + mock_fetch_owner.return_value = "ACCOUNTADMIN" + mock_execute.side_effect = self._mock_execute( + _custom_oauth_desc_rows(blocked_roles_list="[ACCOUNTADMIN, SECURITYADMIN, SYSADMIN]") + ) + + result = fetch_security_integration(MagicMock(), FQN(name=ResourceName("CUSTOM_OAUTH"))) + + assert result["blocked_roles_list"] == ["SYSADMIN"] + + @patch("snowcap.data_provider._fetch_owner") + @patch("snowcap.data_provider.execute") + def test_custom_oauth_blocked_roles_sorted_regardless_of_desc_order(self, mock_execute, mock_fetch_owner): + mock_fetch_owner.return_value = "ACCOUNTADMIN" + mock_execute.side_effect = self._mock_execute( + _custom_oauth_desc_rows(blocked_roles_list="[SECURITYADMIN, SYSADMIN, ACCOUNTADMIN, ANALYST]") + ) + + result = fetch_security_integration(MagicMock(), FQN(name=ResourceName("CUSTOM_OAUTH"))) + + assert result["blocked_roles_list"] == ["ANALYST", "SYSADMIN"] + + @patch("snowcap.data_provider._fetch_owner") + @patch("snowcap.data_provider.execute") + def test_custom_oauth_empty_pre_authorized_roles_normalizes_to_none(self, mock_execute, mock_fetch_owner): + mock_fetch_owner.return_value = "ACCOUNTADMIN" + mock_execute.side_effect = self._mock_execute(_custom_oauth_desc_rows()) + + result = fetch_security_integration(MagicMock(), FQN(name=ResourceName("CUSTOM_OAUTH"))) + + assert result["pre_authorized_roles_list"] is None + assert result["oauth_client"] == "CUSTOM" + assert result["oauth_refresh_token_validity"] == 7776000 + assert result["network_policy"] is None + + @patch("snowcap.data_provider._fetch_owner") + @patch("snowcap.data_provider.execute") + def test_snowservices_ingress_still_fetches_as_before(self, mock_execute, mock_fetch_owner): + mock_fetch_owner.return_value = "ACCOUNTADMIN" + mock_execute.side_effect = self._mock_execute( + desc_rows=[], + show_row=_security_integration_show_row(name="SNOWSERVICES", type="OAUTH - SNOWSERVICES_INGRESS"), + ) + + result = fetch_security_integration(MagicMock(), FQN(name=ResourceName("SNOWSERVICES"))) + + assert result == { + "name": "SNOWSERVICES", + "type": "OAUTH", + "oauth_client": "SNOWSERVICES_INGRESS", + "enabled": True, + "owner": "ACCOUNTADMIN", + } + + @patch("snowcap.data_provider._fetch_owner") + @patch("snowcap.data_provider.execute") + def test_unsupported_oauth_client_raises(self, mock_execute, mock_fetch_owner): + mock_fetch_owner.return_value = "ACCOUNTADMIN" + mock_execute.side_effect = self._mock_execute( + desc_rows=[], + show_row=_security_integration_show_row(name="TABLEAU", type="OAUTH - TABLEAU_DESKTOP"), + ) + + with pytest.raises(Exception, match="Unsupported security integration type"): + fetch_security_integration(MagicMock(), FQN(name=ResourceName("TABLEAU"))) + + class TestListResource: """Tests for list_resource dispatcher function.""" diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index ae84cb8..72ed22e 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -1385,3 +1385,123 @@ def test_update_dispatched_via_update_resource(self): assert "?" not in result, f"dispatcher produced malformed SQL: {result!r}" assert "UNSET MASKING POLICY" in result + + +# ============================================================================ +# Test SnowflakeCustomOAuthSecurityIntegration lifecycle +# ============================================================================ + + +class TestCreateSnowflakeCustomOAuthSecurityIntegration: + """create_sql, the OAUTH/CUSTOM resolver, and field validation for SnowflakeCustomOAuthSecurityIntegration.""" + + def test_resolver_returns_custom_class(self): + """The OAUTH/CUSTOM resolver branch must no longer raise NotImplementedError.""" + resource_cls = res.Resource.resolve_resource_cls( + ResourceType.SECURITY_INTEGRATION, {"type": "OAUTH", "oauth_client": "CUSTOM"} + ) + assert resource_cls is res.SnowflakeCustomOAuthSecurityIntegration + + def test_missing_oauth_client_type_raises(self): + """oauth_client_type is required at CREATE.""" + with pytest.raises(ValueError): + res.SnowflakeCustomOAuthSecurityIntegration( + name="TEST_INTEGRATION", oauth_redirect_uri="https://example.com/cb" + ) + + def test_missing_oauth_redirect_uri_raises(self): + """oauth_redirect_uri is required at CREATE.""" + with pytest.raises(ValueError): + res.SnowflakeCustomOAuthSecurityIntegration(name="TEST_INTEGRATION", oauth_client_type="CONFIDENTIAL") + + def test_string_and_enum_oauth_client_type_produce_same_dict(self): + """oauth_client_type accepts either the raw string or the enum member.""" + from snowcap.resources.security_integration import OAuthClientType, OAuthUseSecondaryRoles + + by_string = res.SnowflakeCustomOAuthSecurityIntegration( + name="TEST_INTEGRATION", oauth_client_type="CONFIDENTIAL", oauth_redirect_uri="https://example.com/cb" + ) + by_enum = res.SnowflakeCustomOAuthSecurityIntegration( + name="TEST_INTEGRATION", + oauth_client_type=OAuthClientType.CONFIDENTIAL, + oauth_redirect_uri="https://example.com/cb", + ) + assert by_string.to_dict() == by_enum.to_dict() + assert by_string._data.oauth_use_secondary_roles == OAuthUseSecondaryRoles.NONE + + def test_role_lists_are_sorted(self): + """pre_authorized_roles_list and blocked_roles_list must be canonicalized to sorted order.""" + integration = res.SnowflakeCustomOAuthSecurityIntegration( + name="TEST_INTEGRATION", + oauth_client_type="CONFIDENTIAL", + oauth_redirect_uri="https://example.com/cb", + pre_authorized_roles_list=["SYSADMIN", "ANALYST"], + blocked_roles_list=["SYSADMIN", "ANALYST"], + ) + data = integration.to_dict() + assert data["pre_authorized_roles_list"] == ["ANALYST", "SYSADMIN"] + assert data["blocked_roles_list"] == ["ANALYST", "SYSADMIN"] + + def test_role_lists_are_case_normalized(self): + """Unquoted role names are uppercased so a lowercase manifest doesn't drift against DESC.""" + integration = res.SnowflakeCustomOAuthSecurityIntegration( + name="TEST_INTEGRATION", + oauth_client_type="CONFIDENTIAL", + oauth_redirect_uri="https://example.com/cb", + blocked_roles_list=["sysadmin"], + ) + assert integration.to_dict()["blocked_roles_list"] == ["SYSADMIN"] + + def test_blocked_roles_list_rejects_always_blocked_roles(self): + """ACCOUNTADMIN/ORGADMIN/GLOBALORGADMIN/SECURITYADMIN are always blocked by Snowflake + and must not be listed, or every plan/apply would see a perpetual delta.""" + with pytest.raises(ValueError): + res.SnowflakeCustomOAuthSecurityIntegration( + name="TEST_INTEGRATION", + oauth_client_type="CONFIDENTIAL", + oauth_redirect_uri="https://example.com/cb", + blocked_roles_list=["ACCOUNTADMIN", "SYSADMIN"], + ) + + def test_create_sql(self): + """CREATE SECURITY INTEGRATION renders every field, with a quoted client type and parenthesized quoted lists.""" + integration = res.SnowflakeCustomOAuthSecurityIntegration( + name="CLAUDE_MCP_OAUTH", + enabled=True, + oauth_client_type="CONFIDENTIAL", + oauth_redirect_uri="https://example.com/cb", + oauth_alternate_redirect_uris=["https://example.com/cb2"], + oauth_issue_refresh_tokens=True, + oauth_refresh_token_validity=7776000, + oauth_use_secondary_roles="NONE", + oauth_enforce_pkce=True, + blocked_roles_list=["SYSADMIN", "ANALYST"], + comment="test comment", + ) + sql = integration.create_sql() + assert sql == ( + "CREATE SECURITY INTEGRATION CLAUDE_MCP_OAUTH type = OAUTH ENABLED = TRUE " + "oauth_client = CUSTOM oauth_client_type = 'CONFIDENTIAL' " + "OAUTH_REDIRECT_URI = $$https://example.com/cb$$ " + "OAUTH_ALTERNATE_REDIRECT_URIS = ($$https://example.com/cb2$$) " + "OAUTH_ISSUE_REFRESH_TOKENS = TRUE OAUTH_REFRESH_TOKEN_VALIDITY = 7776000 " + "oauth_use_secondary_roles = NONE OAUTH_ENFORCE_PKCE = TRUE " + "BLOCKED_ROLES_LIST = ($$ANALYST$$, $$SYSADMIN$$) COMMENT = $$test comment$$" + ) + + +class TestUpdateSnowflakeCustomOAuthSecurityIntegration: + """update_set_sql and replacement/fetchable metadata for SnowflakeCustomOAuthSecurityIntegration.""" + + def test_metadata(self): + """oauth_client_type triggers replacement; oauth_alternate_redirect_uris is create-only.""" + spec = res.SnowflakeCustomOAuthSecurityIntegration.spec + assert spec.get_metadata("oauth_client_type").triggers_replacement is True + assert spec.get_metadata("oauth_alternate_redirect_uris").fetchable is False + + def test_update_set_sql(self): + """A single-field delta renders as ALTER SECURITY INTEGRATION ... SET, never CREATE OR REPLACE.""" + urn = make_urn(ResourceType.SECURITY_INTEGRATION, "CLAUDE_MCP_OAUTH") + data = {"oauth_refresh_token_validity": 86400} + result = update_resource(urn, data, res.SnowflakeCustomOAuthSecurityIntegration.props) + assert result == "ALTER SECURITY INTEGRATION CLAUDE_MCP_OAUTH SET OAUTH_REFRESH_TOKEN_VALIDITY = 86400"