diff --git a/.changes/unreleased/Features-20241012-115830.yaml b/.changes/unreleased/Features-20241012-115830.yaml new file mode 100644 index 000000000..d068f94d3 --- /dev/null +++ b/.changes/unreleased/Features-20241012-115830.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Support granting of Database Roles +time: 2024-10-12T11:58:30.84775252+01:00 +custom: + Author: seediang + Issue: "1206" diff --git a/dbt/adapters/snowflake/impl.py b/dbt/adapters/snowflake/impl.py index 89c21f531..fa86de3e0 100644 --- a/dbt/adapters/snowflake/impl.py +++ b/dbt/adapters/snowflake/impl.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Mapping, Any, Optional, List, Union, Dict, FrozenSet, Tuple, TYPE_CHECKING + from dbt.adapters.base.impl import AdapterConfig, ConstraintSupport from dbt.adapters.base.meta import available from dbt.adapters.capability import CapabilityDict, CapabilitySupport, Support, Capability @@ -314,21 +315,120 @@ def quote_seed_column(self, column: str, quote_config: Optional[bool]) -> str: else: return column + GrantsDict = Dict[str, Dict[str, Any]] + @available - def standardize_grants_dict(self, grants_table: "agate.Table") -> dict: + def standardize_grants_dict(self, grants_table: "agate.Table") -> GrantsDict: grants_dict: Dict[str, Any] = {} + self.AdapterSpecificConfigs + # granted_to maps to different object types like + # role, database_role and share + # create nest dictionaries [granted_to].[privilege][object_name] for row in grants_table: grantee = row["grantee_name"] granted_to = row["granted_to"] privilege = row["privilege"] - if privilege != "OWNERSHIP" and granted_to not in ["SHARE", "DATABASE_ROLE"]: - if privilege in grants_dict.keys(): - grants_dict[privilege].append(grantee) - else: - grants_dict.update({privilege: [grantee]}) + if privilege != "OWNERSHIP": + role_type_dict = grants_dict.setdefault(granted_to.lower(), {}) + privilege_dict = role_type_dict.setdefault(privilege.lower(), []) + privilege_dict.append(grantee) + return grants_dict + @available + def standardize_grant_config(self, grant_config: dict) -> GrantsDict: + """ + Given a grants configuration object of either + Dict[str, Any] or Dict[str, [Dict[str, List[str] ] ]]] + + Converts to Dict[str, Any] to [Dict["role", List[str]] ]]] + + This enables use to handle new and older style grants + configurations. + """ + grant_config_std: Dict[str, Any] = {} + self.AdapterSpecificConfigs + + for grant_config_privilege, privilege_collection in grant_config.items(): + # loop through the role entries and handle mapping, list & string entries + for privilege_item in privilege_collection: + # Assume old style list grants map to role + if not isinstance(privilege_item, dict): + privilege_item = {"role": privilege_item} + + for grantee_type, grantees in privilege_item.items(): + if grantees: + # -- Make sure object_type is in grant_config_by_type + grantee_type_privileges: Dict[str, Any] = grant_config_std.setdefault( + grantee_type.lower(), {} + ) + privilege_list = grantee_type_privileges.setdefault( + grant_config_privilege.lower(), [] + ) + + # -- convert string to array to make code simpler --#} + if isinstance(grantees, str): + grantees = [grantees] + + for grantee in grantees: + # -- Only add the item if not already in the list --#} + if grantee not in privilege_list: + privilege_list.append(grantee) + + return grant_config_std + + @available + def diff_of_grants(self, grants_a: GrantsDict, grants_b: GrantsDict) -> GrantsDict: + """ + Given two dictionaries of type Dict[str, Dict[str, List[str]]]: + grants_a = {'key_x': {'key_a': ['VALUE_1', 'VALUE_2']}, 'KEY_Y': {'key_b': ['value_2']}} + grants_b = {'KEY_x': {'key_a': ['VALUE_1']}, 'key_y': {'key_b': ['value_3']}} + Return the same dictionary representation of dict_a MINUS dict_b, + performing a case-insensitive comparison between the strings in each. + All keys returned will be in the original case of dict_a. + returns {"key_x": {'key_a': ['VALUE_2']},"KEY_Y":{"key_b": ["value_2"]}} + """ + + def diff_of_two_dicts(dict_a: dict, dict_b: dict) -> dict: + """ + Given two dictionaries of type Dict[str, List[str]]: + dict_a = {'key_x': ['value_1', 'VALUE_2'], 'KEY_Y': ['value_3']} + dict_b = {'key_x': ['value_1'], 'key_z': ['value_4']} + Return the same dictionary representation of dict_a MINUS dict_b, + performing a case-insensitive comparison between the strings in each. + All keys returned will be in the original case of dict_a. + returns {'key_x': ['VALUE_2'], 'KEY_Y': ['value_3']} + """ + + dict_diff = {} + dict_b_lowered = {k.casefold(): [x.casefold() for x in v] for k, v in dict_b.items()} + for k in dict_a: + if k.casefold() in dict_b_lowered.keys(): + diff = [] + for v in dict_a[k]: + if v.casefold() not in dict_b_lowered[k.casefold()]: + diff.append(v) + if diff: + dict_diff.update({k: diff}) + else: + dict_diff.update({k: dict_a[k]}) + return dict_diff + + grants_diff: Dict[str, Any] = {} + grants_b_lowered = { + k.casefold(): {x.casefold(): y for x, y in v.items()} for k, v in grants_b.items() + } + for k in grants_a: + if k.casefold() in grants_b_lowered.keys(): + diff = diff_of_two_dicts(grants_a[k], grants_b_lowered[k.casefold()]) + if diff: + grants_diff.update({k: diff}) + else: + grants_diff.update({k: grants_a[k]}) + + return grants_diff + def timestamp_add_sql(self, add_to: str, number: int = 1, interval: str = "hour") -> str: return f"DATEADD({interval}, {number}, {add_to})" diff --git a/dbt/include/snowflake/macros/apply_grants.sql b/dbt/include/snowflake/macros/apply_grants.sql index 72bea5c1b..41ed66be5 100644 --- a/dbt/include/snowflake/macros/apply_grants.sql +++ b/dbt/include/snowflake/macros/apply_grants.sql @@ -6,3 +6,97 @@ {%- macro snowflake__support_multiple_grantees_per_dcl_statement() -%} {{ return(False) }} {%- endmacro -%} + +{# + -- Create versions of get_grant_sql and get_revoke_sql that support an additional + -- object_type parameter +#} + +{% macro get_grant_sql_by_type(relation, privilege, object_type, grantees) %} + {{ return(adapter.dispatch('get_grant_sql_by_type', 'dbt-snowflake')(relation, privilege, object_type, grantees)) }} +{% endmacro %} + +{%- macro snowflake__get_grant_sql_by_type(relation, privilege, object_type, grantees) -%} + grant {{ privilege }} on {{ relation.render() }} to {{object_type | replace('_', ' ')}} {{ grantees | join(', ') }} +{%- endmacro -%} + +{% macro get_revoke_sql_by_type(relation, privilege, object_type, grantees) %} + {{ return(adapter.dispatch('get_revoke_sql_by_type', 'dbt-snowflake')(relation, privilege, object_type, grantees)) }} +{% endmacro %} + +{%- macro snowflake__get_revoke_sql_by_type(relation, privilege, object_type, grantees) -%} + revoke {{ privilege }} on {{ relation.render() }} from {{object_type | replace('_', ' ')}} {{ grantees | join(', ') }} +{%- endmacro -%} + + +{% macro get_dcl_statement_list_by_type(relation, grant_config_by_type, get_dcl_macro) %} + {{ return(adapter.dispatch('get_dcl_statement_list_by_type', 'dbt-snowflake')(relation, grant_config_by_type, get_dcl_macro)) }} +{% endmacro %} + + +{%- macro snowflake__get_dcl_statement_list_by_type(relation, grant_config_by_type, get_dcl_macro) -%} + {# + -- Unpack grant_config into specific privileges and the set of users who need them granted/revoked. + -- Depending on whether this database supports multiple grantees per statement, pass in the list of + -- all grantees per privilege, or (if not) template one statement per privilege-grantee pair. + -- `get_dcl_macro` will be either `get_grant_sql_by_type` or `get_revoke_sql_by_type` + -- + -- grant_config_by_type should be in the following format { grantee_type: { privilege: [grantee] } } + #} + {%- set dcl_statements = [] -%} + {%- for object_type, config in grant_config_by_type.items() %} + {%- for privilege, grantees in config.items() %} + {%- if support_multiple_grantees_per_dcl_statement() and grantees -%} + {%- set dcl = get_dcl_macro(relation, privilege, object_type, grantees) -%} + {%- do dcl_statements.append(dcl) -%} + {%- else -%} + {%- for grantee in grantees -%} + {% set dcl = get_dcl_macro(relation, privilege, object_type, [grantee]) %} + {%- do dcl_statements.append(dcl) -%} + {% endfor -%} + {%- endif -%} + {%- endfor -%} + {%- endfor -%} + {{ return(dcl_statements) }} +{%- endmacro %} + +{% macro snowflake__apply_grants(relation, grant_config, should_revoke=True) %} + {#-- If grant_config is {} or None, this is a no-op --#} + {% if grant_config %} + + {{ log('grant_config: ' ~ grant_config) }} + {#-- Check if we have defined new role type or are using default style --#} + {% set desired_grants_dict = adapter.standardize_grant_config(grant_config) %} + {{ log('desired_grants_dict: ' ~ desired_grants_dict) }} + + + {% if should_revoke %} + {#-- We think previous grants may have carried over --#} + {#-- Show current grants and calculate diffs --#} + {% set current_grants_table = run_query(get_show_grant_sql(relation)) %} + {% set current_grants_dict = adapter.standardize_grants_dict(current_grants_table) %} + + {% set needs_granting = adapter.diff_of_grants(desired_grants_dict, current_grants_dict) %} + {% set needs_revoking = adapter.diff_of_grants(current_grants_dict, desired_grants_dict) %} + + {% if not (needs_granting or needs_revoking) %} + {{ log('On ' ~ relation.render() ~': All grants are in place, no revocation or granting needed.')}} + {% endif %} + {% else %} + {#-- We don't think there's any chance of previous grants having carried over. --#} + {#-- Jump straight to granting what the user has configured. --#} + {% set needs_revoking = {} %} + {% set needs_granting = desired_grants_dict %} + {% endif %} + + {% if needs_granting or needs_revoking %} + {% set revoke_statement_list = get_dcl_statement_list_by_type(relation, needs_revoking, snowflake__get_revoke_sql_by_type) %} + {% set grant_statement_list = get_dcl_statement_list_by_type(relation, needs_granting, snowflake__get_grant_sql_by_type) %} + {% set dcl_statement_list = revoke_statement_list + grant_statement_list %} + + {% if dcl_statement_list %} + {{ call_dcl_statements(dcl_statement_list) }} + {% endif %} + {% endif %} + {% endif %} +{% endmacro %} diff --git a/tests/functional/adapter/grants/__init__.py b/tests/functional/adapter/grants/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/functional/adapter/grants/base_grants.py b/tests/functional/adapter/grants/base_grants.py new file mode 100644 index 000000000..9b112d4d1 --- /dev/null +++ b/tests/functional/adapter/grants/base_grants.py @@ -0,0 +1,67 @@ +import os +import pytest +import snowflake.connector +from dbt.tests.adapter.grants.base_grants import BaseGrants as OrigBaseGrants + + +class BaseGrantsSnowflakePatch: + """ + Overides the adapter BaseGrants to use new adapter functions + for grants. + """ + + def assert_expected_grants_match_actual(self, project, relation_name, expected_grants): + adapter = project.adapter + actual_grants = self.get_grants_on_relation(project, relation_name) + expected_grants_std = adapter.standardize_grant_config(expected_grants) + + # need a case-insensitive comparison -- this would not be true for all adapters + # so just a simple "assert expected == actual_grants" won't work + diff_a = adapter.diff_of_grants(actual_grants, expected_grants_std) + diff_b = adapter.diff_of_grants(expected_grants_std, actual_grants) + assert diff_a == diff_b == {} + + +class BaseGrantsSnowflake(BaseGrantsSnowflakePatch, OrigBaseGrants): + @pytest.fixture(scope="session", autouse=True) + def ensure_database_roles(project): + """ + We need to create database roles since test framework does not + have default database roles to work with. This has been patched + in with ta session scoped fixture and custom connection. + """ + con = snowflake.connector.connect( + user=os.getenv("SNOWFLAKE_TEST_USER"), + password=os.getenv("SNOWFLAKE_TEST_PASSWORD"), + account=os.getenv("SNOWFLAKE_TEST_ACCOUNT"), + warehouse=os.getenv("SNOWFLAKE_TEST_WAREHOUSE"), + database=os.getenv("SNOWFLAKE_TEST_DATABASE"), + ) + + number_of_roles = 3 + + for index in range(1, number_of_roles + 1): + con.execute_string(f"CREATE DATABASE ROLE IF NOT EXISTS test_database_role_{index}") + + yield + + for index in range(1, number_of_roles + 1): + con.execute_string(f"DROP DATABASE ROLE test_database_role_{index}") + + +class BaseCopyGrantsSnowflake: + # Try every test case without copy_grants enabled (default), + # and with copy_grants enabled (this base class) + @pytest.fixture(scope="class") + def project_config_update(self): + return { + "models": { + "+copy_grants": True, + }, + "seeds": { + "+copy_grants": True, + }, + "snapshots": { + "+copy_grants": True, + }, + } diff --git a/tests/functional/adapter/grants/base_incremental_grants.py b/tests/functional/adapter/grants/base_incremental_grants.py new file mode 100644 index 000000000..61d84053f --- /dev/null +++ b/tests/functional/adapter/grants/base_incremental_grants.py @@ -0,0 +1,121 @@ +import pytest + +from .base_grants import BaseGrantsSnowflake +from dbt.tests.util import ( + get_connection, + get_manifest, + relation_from_name, + run_dbt, + run_dbt_and_capture, + write_file, +) + + +my_incremental_model_sql = """ + select 1 as fun +""" + +schema_yml = { + "incremental_model_schema_yml": """ +version: 2 +models: + - name: my_incremental_model + config: + materialized: incremental + grants: + select: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_incremental_model_schema_yml": """ +version: 2 +models: + - name: my_incremental_model + config: + materialized: incremental + grants: + select: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", +} + + +class BaseIncrementalGrantsSnowflake(BaseGrantsSnowflake): + @pytest.fixture(scope="class") + def schema_yml(self): + return schema_yml + + @pytest.fixture(scope="class") + def models(self, schema_yml): + updated_schema = self.interpolate_name_overrides( + schema_yml["incremental_model_schema_yml"] + ) + return { + "my_incremental_model.sql": my_incremental_model_sql, + "schema.yml": updated_schema, + } + + def get_model(self, model_id, project): + manifest = get_manifest(project.project_root) + return manifest.nodes[model_id] + + def test_incremental_grants(self, project, get_test_users, schema_yml): + # we want the test to fail, not silently skip + test_users = get_test_users + select_privilege_name = self.privilege_grantee_name_overrides()["select"] + assert len(test_users) == 3 + + # Incremental materialization, single select grant + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + model = self.get_model("model.test.my_incremental_model", project) + assert model.config.materialized == "incremental" + self.assert_expected_grants_match_actual( + project, "my_incremental_model", model.config.grants + ) + + # Incremental materialization, run again without changes + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + assert "revoke " not in log_output + assert "grant " not in log_output # with space to disambiguate from 'show grants' + model = self.get_model("model.test.my_incremental_model", project) + self.assert_expected_grants_match_actual( + project, "my_incremental_model", model.config.grants + ) + + # Incremental materialization, change select grant user + updated_yaml = self.interpolate_name_overrides( + schema_yml["user2_incremental_model_schema_yml"] + ) + write_file(updated_yaml, project.project_root, "models", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + assert "revoke " in log_output + model = self.get_model("model.test.my_incremental_model", project) + assert model.config.materialized == "incremental" + self.assert_expected_grants_match_actual( + project, "my_incremental_model", model.config.grants + ) + + # Incremental materialization, same config, now with --full-refresh + run_dbt(["--debug", "run", "--full-refresh"]) + assert len(results) == 1 + model = self.get_model("model.test.my_incremental_model", project) + # whether grants or revokes happened will vary by adapter + self.assert_expected_grants_match_actual( + project, "my_incremental_model", model.config.grants + ) + + # Now drop the schema (with the table in it) + adapter = project.adapter + relation = relation_from_name(adapter, "my_incremental_model") + with get_connection(adapter): + adapter.drop_schema(relation) + + # Incremental materialization, same config, rebuild now that table is missing + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + assert "grant " in log_output + assert "revoke " not in log_output + model = self.get_model("model.test.my_incremental_model", project) + self.assert_expected_grants_match_actual( + project, "my_incremental_model", model.config.grants + ) diff --git a/tests/functional/adapter/grants/base_invalid_grants.py b/tests/functional/adapter/grants/base_invalid_grants.py new file mode 100644 index 000000000..9895069cc --- /dev/null +++ b/tests/functional/adapter/grants/base_invalid_grants.py @@ -0,0 +1,70 @@ +import pytest +from .base_grants import BaseGrantsSnowflake +from dbt.tests.util import run_dbt_and_capture, write_file + + +my_invalid_model_sql = """ + select 1 as fun +""" +schema_yml = { + "invalid_user_table_model_schema_yml": """ +version: 2 +models: + - name: my_invalid_model + config: + materialized: table + grants: + select: ['invalid_user'] +""", + "invalid_privilege_table_model_schema_yml": """ +version: 2 +models: + - name: my_invalid_model + config: + materialized: table + grants: + fake_privilege: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", +} + + +class BaseInvalidGrantsSnowflake(BaseGrantsSnowflake): + # The purpose of this test is to understand the user experience when providing + # an invalid 'grants' configuration. dbt will *not* try to intercept or interpret + # the database's own error at runtime -- it will just return those error messages. + # Hopefully they're helpful! + + @pytest.fixture(scope="class") + def schema_yml(self): + return schema_yml + + @pytest.fixture(scope="class") + def models(self, schema_yml): + return { + "my_invalid_model.sql": my_invalid_model_sql, + } + + # Adapters will need to reimplement these methods with the specific + # language of their database + def grantee_does_not_exist_error(self): + return "does not exist or not authorized" + + def privilege_does_not_exist_error(self): + return "unexpected" + + def test_invalid_grants(self, project, get_test_users, logs_dir, schema_yml): + # failure when grant to a user/role that doesn't exist + yaml_file = self.interpolate_name_overrides( + schema_yml["invalid_user_table_model_schema_yml"] + ) + write_file(yaml_file, project.project_root, "models", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "run"], expect_pass=False) + assert self.grantee_does_not_exist_error() in log_output + + # failure when grant to a privilege that doesn't exist + yaml_file = self.interpolate_name_overrides( + schema_yml["invalid_privilege_table_model_schema_yml"] + ) + write_file(yaml_file, project.project_root, "models", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "run"], expect_pass=False) + assert self.privilege_does_not_exist_error() in log_output diff --git a/tests/functional/adapter/grants/base_model_grants.py b/tests/functional/adapter/grants/base_model_grants.py new file mode 100644 index 000000000..1993d716a --- /dev/null +++ b/tests/functional/adapter/grants/base_model_grants.py @@ -0,0 +1,149 @@ +import pytest + +from .base_grants import BaseGrantsSnowflake +from dbt.tests.util import ( + get_manifest, + run_dbt_and_capture, + write_file, +) + + +my_model_sql = """ + select 1 as fun +""" + +schema_yml = { + "model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + grants: + select: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + grants: + select: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + "table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + "multiple_users_table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: ["{{ env_var('DBT_TEST_USER_1') }}", "{{ env_var('DBT_TEST_USER_2') }}"] +""", + "multiple_privileges_table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: ["{{ env_var('DBT_TEST_USER_1') }}"] + insert: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", +} + + +# Create our own copy of BaseModelGrants that allows is to overide the model_schemas +class BaseModelGrantsSnowflake(BaseGrantsSnowflake): + @pytest.fixture(scope="class") + def schema_yml(self): + return schema_yml + + @pytest.fixture(scope="class") + def models(self, schema_yml): + updated_schema = self.interpolate_name_overrides(schema_yml["model_schema_yml"]) + return { + "my_model.sql": my_model_sql, + "schema.yml": updated_schema, + } + + def get_model(self, model_id, project): + manifest = get_manifest(project.project_root) + return manifest.nodes[model_id] + + def test_view_table_grants(self, project, get_test_users, schema_yml): + # we want the test to fail, not silently skip + test_users = get_test_users + select_privilege_name = self.privilege_grantee_name_overrides()["select"] + insert_privilege_name = self.privilege_grantee_name_overrides()["insert"] + assert len(test_users) == 3 + + # View materialization, single select grant + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + model = self.get_model("model.test.my_model", project) + assert model.config.materialized == "view" + self.assert_expected_grants_match_actual(project, "my_model", model.config.grants) + + # View materialization, change select grant user + updated_yaml = self.interpolate_name_overrides(schema_yml["user2_model_schema_yml"]) + write_file(updated_yaml, project.project_root, "models", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + model = self.get_model("model.test.my_model", project) + self.assert_expected_grants_match_actual(project, "my_model", model.config.grants) + + # Table materialization, single select grant + updated_yaml = self.interpolate_name_overrides(schema_yml["table_model_schema_yml"]) + write_file(updated_yaml, project.project_root, "models", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + model = self.get_model("model.test.my_model", project) + assert model.config.materialized == "table" + self.assert_expected_grants_match_actual(project, "my_model", model.config.grants) + + # Table materialization, change select grant user + updated_yaml = self.interpolate_name_overrides(schema_yml["user2_table_model_schema_yml"]) + write_file(updated_yaml, project.project_root, "models", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + model = self.get_model("model.test.my_model", project) + self.assert_expected_grants_match_actual(project, "my_model", model.config.grants) + + # Table materialization, multiple grantees + updated_yaml = self.interpolate_name_overrides( + schema_yml["multiple_users_table_model_schema_yml"] + ) + write_file(updated_yaml, project.project_root, "models", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + model = self.get_model("model.test.my_model", project) + assert model.config.materialized == "table" + self.assert_expected_grants_match_actual(project, "my_model", model.config.grants) + + # Table materialization, multiple privileges + updated_yaml = self.interpolate_name_overrides( + schema_yml["multiple_privileges_table_model_schema_yml"] + ) + write_file(updated_yaml, project.project_root, "models", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "run"]) + assert len(results) == 1 + model = self.get_model("model.test.my_model", project) + assert model.config.materialized == "table" + self.assert_expected_grants_match_actual(project, "my_model", model.config.grants) diff --git a/tests/functional/adapter/grants/base_seed_grants.py b/tests/functional/adapter/grants/base_seed_grants.py new file mode 100644 index 000000000..63658ffff --- /dev/null +++ b/tests/functional/adapter/grants/base_seed_grants.py @@ -0,0 +1,148 @@ +import pytest + +from .base_grants import BaseGrantsSnowflake +from dbt.tests.util import ( + get_manifest, + run_dbt, + run_dbt_and_capture, + write_file, +) + +seeds__my_seed_csv = """ +id,name,some_date +1,Easton,1981-05-20T06:46:51 +2,Lillian,1978-09-03T18:10:33 +""".lstrip() + +schema_yml = { + "schema_base_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: + select: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_schema_base_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: + select: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + "ignore_grants_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: {} +""", + "zero_grants_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: + select: [] +""", +} + + +class BaseSeedGrantsSnowflake(BaseGrantsSnowflake): + def seeds_support_partial_refresh(self): + return True + + @pytest.fixture(scope="class") + def schema_yml(self): + return schema_yml + + @pytest.fixture(scope="class") + def seeds(self, schema_yml): + updated_schema = self.interpolate_name_overrides(schema_yml["schema_base_yml"]) + return { + "my_seed.csv": seeds__my_seed_csv, + "schema.yml": updated_schema, + } + + def get_seed(self, seed_id, project): + manifest = get_manifest(project.project_root) + return manifest.nodes[seed_id] + + def test_seed_grants(self, project, get_test_users, schema_yml): + test_users = get_test_users + select_privilege_name = self.privilege_grantee_name_overrides()["select"] + + # seed command + (results, log_output) = run_dbt_and_capture(["--debug", "seed"]) + assert len(results) == 1 + seed = self.get_seed("seed.test.my_seed", project) + assert "grant " in log_output + self.assert_expected_grants_match_actual(project, "my_seed", seed.config.grants) + + # run it again, with no config changes + (results, log_output) = run_dbt_and_capture(["--debug", "seed"]) + assert len(results) == 1 + if self.seeds_support_partial_refresh(): + # grants carried over -- nothing should have changed + assert "revoke " not in log_output + assert "grant " not in log_output + else: + # seeds are always full-refreshed on this adapter, so we need to re-grant + assert "grant " in log_output + + seed = self.get_seed("seed.test.my_seed", project) + self.assert_expected_grants_match_actual(project, "my_seed", seed.config.grants) + + # change the grantee, assert it updates + updated_yaml = self.interpolate_name_overrides(schema_yml["user2_schema_base_yml"]) + write_file(updated_yaml, project.project_root, "seeds", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "seed"]) + assert len(results) == 1 + seed = self.get_seed("seed.test.my_seed", project) + self.assert_expected_grants_match_actual(project, "my_seed", seed.config.grants) + + # run it again, with --full-refresh, grants should be the same + run_dbt(["seed", "--full-refresh"]) + seed = self.get_seed("seed.test.my_seed", project) + self.assert_expected_grants_match_actual(project, "my_seed", seed.config.grants) + + previous_grants = seed.config.grants + + # change config to 'grants: {}' -- should be completely ignored + updated_yaml = self.interpolate_name_overrides(schema_yml["ignore_grants_yml"]) + write_file(updated_yaml, project.project_root, "seeds", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "seed"]) + assert len(results) == 1 + assert "revoke " not in log_output + assert "grant " not in log_output + seed = self.get_seed("seed.test.my_seed", project) + expected_config = {} + expected_actual = previous_grants + assert seed.config.grants == expected_config + if self.seeds_support_partial_refresh(): + # ACTUAL grants will NOT match expected grants + self.assert_expected_grants_match_actual(project, "my_seed", expected_actual) + else: + # there should be ZERO grants on the seed + self.assert_expected_grants_match_actual(project, "my_seed", expected_config) + + # now run with ZERO grants -- all grants should be removed + # whether explicitly (revoke) or implicitly (recreated without any grants added on) + updated_yaml = self.interpolate_name_overrides(schema_yml["zero_grants_yml"]) + write_file(updated_yaml, project.project_root, "seeds", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "seed"]) + assert len(results) == 1 + if self.seeds_support_partial_refresh(): + assert "revoke " in log_output + expected = {} + seed = self.get_seed("seed.test.my_seed", project) + self.assert_expected_grants_match_actual(project, "my_seed", seed.config.grants) + + # run it again -- dbt shouldn't try to grant or revoke anything + (results, log_output) = run_dbt_and_capture(["--debug", "seed"]) + assert len(results) == 1 + assert "revoke " not in log_output + assert "grant " not in log_output + seed = self.get_seed("seed.test.my_seed", project) + self.assert_expected_grants_match_actual(project, "my_seed", seed.config.grants) diff --git a/tests/functional/adapter/grants/base_snapshot_grants.py b/tests/functional/adapter/grants/base_snapshot_grants.py new file mode 100644 index 000000000..7cd0bf16b --- /dev/null +++ b/tests/functional/adapter/grants/base_snapshot_grants.py @@ -0,0 +1,81 @@ +import pytest +from .base_grants import BaseGrantsSnowflake +from dbt.tests.util import ( + get_manifest, + run_dbt, + run_dbt_and_capture, + write_file, +) + + +my_snapshot_sql = """ +{% snapshot my_snapshot %} + {{ config( + check_cols='all', unique_key='id', strategy='check', + target_database=database, target_schema=schema + ) }} + select 1 as id, cast('blue' as {{ type_string() }}) as color +{% endsnapshot %} +""".strip() + +schema_yml = { + "snapshot_schema_yml": """ +version: 2 +snapshots: + - name: my_snapshot + config: + grants: + select: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_snapshot_schema_yml": """ +version: 2 +snapshots: + - name: my_snapshot + config: + grants: + select: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", +} + + +class BaseSnapshotGrantsSnowflake(BaseGrantsSnowflake): + @pytest.fixture(scope="class") + def schema_yml(self): + return schema_yml + + @pytest.fixture(scope="class") + def snapshots(self, schema_yml): + return { + "my_snapshot.sql": my_snapshot_sql, + "schema.yml": self.interpolate_name_overrides(schema_yml["snapshot_schema_yml"]), + } + + def get_snapshot(self, snapshot_id, project): + manifest = get_manifest(project.project_root) + return manifest.nodes[snapshot_id] + + def test_snapshot_grants(self, project, get_test_users, schema_yml): + test_users = get_test_users + select_privilege_name = self.privilege_grantee_name_overrides()["select"] + + # run the snapshot + results = run_dbt(["snapshot"]) + assert len(results) == 1 + snapshot = self.get_snapshot("snapshot.test.my_snapshot", project) + self.assert_expected_grants_match_actual(project, "my_snapshot", snapshot.config.grants) + + # run it again, nothing should have changed + (results, log_output) = run_dbt_and_capture(["--debug", "snapshot"]) + assert len(results) == 1 + snapshot = self.get_snapshot("snapshot.test.my_snapshot", project) + assert "revoke " not in log_output + assert "grant " not in log_output + self.assert_expected_grants_match_actual(project, "my_snapshot", snapshot.config.grants) + + # change the grantee, assert it updates + updated_yaml = self.interpolate_name_overrides(schema_yml["user2_snapshot_schema_yml"]) + write_file(updated_yaml, project.project_root, "snapshots", "schema.yml") + (results, log_output) = run_dbt_and_capture(["--debug", "snapshot"]) + assert len(results) == 1 + snapshot = self.get_snapshot("snapshot.test.my_snapshot", project) + self.assert_expected_grants_match_actual(project, "my_snapshot", snapshot.config.grants) diff --git a/tests/functional/adapter/grants/test_incremental_grants.py b/tests/functional/adapter/grants/test_incremental_grants.py new file mode 100644 index 000000000..4162666fd --- /dev/null +++ b/tests/functional/adapter/grants/test_incremental_grants.py @@ -0,0 +1,16 @@ +from .base_grants import BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch +from dbt.tests.adapter.grants.test_incremental_grants import BaseIncrementalGrants + + +# Run the adapter Snapshot Grant tests with patched assert_expected_grants_match_actual + + +class TestIncrementalGrantsSnowflake(BaseGrantsSnowflakePatch, BaseIncrementalGrants): + pass + + +# With "+copy_grants": True +class TestIncrementalGrantsCopyGrantsSnowflake( + BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch, BaseIncrementalGrants +): + pass diff --git a/tests/functional/adapter/grants/test_incremental_grants_with_database_role.py b/tests/functional/adapter/grants/test_incremental_grants_with_database_role.py new file mode 100644 index 000000000..75180b347 --- /dev/null +++ b/tests/functional/adapter/grants/test_incremental_grants_with_database_role.py @@ -0,0 +1,45 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_incremental_grants import BaseIncrementalGrantsSnowflake + + +class BaseIncrementalGrantsSnowflakeDatabaseRole(BaseIncrementalGrantsSnowflake): + """ + The base adapter model grant test but using new role syntax + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "incremental_model_schema_yml": """ +version: 2 +models: + - name: my_incremental_model + config: + materialized: incremental + grants: + select: + database_role: ["test_database_role_1"] +""", + "user2_incremental_model_schema_yml": """ +version: 2 +models: + - name: my_incremental_model + config: + materialized: incremental + grants: + select: + database_role: ["test_database_role_2"] +""", + } + + +class TestIncrementalGrantsSnowflakeDatabaseRole(BaseIncrementalGrantsSnowflakeDatabaseRole): + pass + + +# With "+copy_grants": True +class TestIncrementalGrantsCopyGrantsSnowflakeDatabaseRole( + BaseCopyGrantsSnowflake, BaseIncrementalGrantsSnowflakeDatabaseRole +): + pass diff --git a/tests/functional/adapter/grants/test_incremental_grants_with_role.py b/tests/functional/adapter/grants/test_incremental_grants_with_role.py new file mode 100644 index 000000000..904eb1643 --- /dev/null +++ b/tests/functional/adapter/grants/test_incremental_grants_with_role.py @@ -0,0 +1,45 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_incremental_grants import BaseIncrementalGrantsSnowflake + + +class BaseIncrementalGrantsSnowflakeRole(BaseIncrementalGrantsSnowflake): + """ + The base adapter model grant test but using new role syntax + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "incremental_model_schema_yml": """ +version: 2 +models: + - name: my_incremental_model + config: + materialized: incremental + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_incremental_model_schema_yml": """ +version: 2 +models: + - name: my_incremental_model + config: + materialized: incremental + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + } + + +class TestIncrementalGrantsSnowflakeRole(BaseIncrementalGrantsSnowflakeRole): + pass + + +# With "+copy_grants": True +class TestIncrementalGrantsCopyGrantsSnowflakeRole( + BaseCopyGrantsSnowflake, BaseIncrementalGrantsSnowflakeRole +): + pass diff --git a/tests/functional/adapter/grants/test_invalid_grants.py b/tests/functional/adapter/grants/test_invalid_grants.py new file mode 100644 index 000000000..52ad659b0 --- /dev/null +++ b/tests/functional/adapter/grants/test_invalid_grants.py @@ -0,0 +1,24 @@ +from .base_grants import BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch +from dbt.tests.adapter.grants.test_invalid_grants import BaseInvalidGrants + + +# Run the adapter Snapshot Grant tests with patched assert_expected_grants_match_actual + + +class InvalidGrantsSnowflake(BaseInvalidGrants): + def grantee_does_not_exist_error(self): + return "does not exist or not authorized" + + def privilege_does_not_exist_error(self): + return "unexpected" + + +class TestInvaildGrantsSnowflake(BaseGrantsSnowflakePatch, InvalidGrantsSnowflake): + pass + + +# With "+copy_grants": True +class TestInvalidGrantsCopyGrantsSnowflake( + BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch, InvalidGrantsSnowflake +): + pass diff --git a/tests/functional/adapter/grants/test_invalid_grants_with_database_role.py b/tests/functional/adapter/grants/test_invalid_grants_with_database_role.py new file mode 100644 index 000000000..3fc202113 --- /dev/null +++ b/tests/functional/adapter/grants/test_invalid_grants_with_database_role.py @@ -0,0 +1,45 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_invalid_grants import BaseInvalidGrantsSnowflake + + +class BaseInvalidGrantsSnowflakeDatabaseRole(BaseInvalidGrantsSnowflake): + """ + The base adapter Invalid grant test but using database roles + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "invalid_user_table_model_schema_yml": """ +version: 2 +models: + - name: my_invalid_model + config: + materialized: table + grants: + select: + database_role: ['invalid_user'] +""", + "invalid_privilege_table_model_schema_yml": """ +version: 2 +models: + - name: my_invalid_model + config: + materialized: table + grants: + fake_privilege: + database_role: ["test_database_role_1"] +""", + } + + +class TestModelGrantsSnowflakeDatabaseRole(BaseInvalidGrantsSnowflakeDatabaseRole): + pass + + +# With "+copy_grants": True +class TestModelGrantsCopyGrantsSnowflakeDatabaseRole( + BaseCopyGrantsSnowflake, BaseInvalidGrantsSnowflakeDatabaseRole +): + pass diff --git a/tests/functional/adapter/grants/test_invalid_grants_with_role.py b/tests/functional/adapter/grants/test_invalid_grants_with_role.py new file mode 100644 index 000000000..c4cecdd5b --- /dev/null +++ b/tests/functional/adapter/grants/test_invalid_grants_with_role.py @@ -0,0 +1,45 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_invalid_grants import BaseInvalidGrantsSnowflake + + +class BaseInvalidGrantsSnowflakeDatabaseRole(BaseInvalidGrantsSnowflake): + """ + The base adapter Invalid grant test but using database roles + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "invalid_user_table_model_schema_yml": """ +version: 2 +models: + - name: my_invalid_model + config: + materialized: table + grants: + select: + role: ['invalid_user'] +""", + "invalid_privilege_table_model_schema_yml": """ +version: 2 +models: + - name: my_invalid_model + config: + materialized: table + grants: + fake_privilege: + role: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + } + + +class TestModelGrantsSnowflakeDatabaseRole(BaseInvalidGrantsSnowflakeDatabaseRole): + pass + + +# With "+copy_grants": True +class TestModelGrantsCopyGrantsSnowflakeDatabaseRole( + BaseCopyGrantsSnowflake, BaseInvalidGrantsSnowflakeDatabaseRole +): + pass diff --git a/tests/functional/adapter/grants/test_model_grants.py b/tests/functional/adapter/grants/test_model_grants.py new file mode 100644 index 000000000..5f8b0fe31 --- /dev/null +++ b/tests/functional/adapter/grants/test_model_grants.py @@ -0,0 +1,16 @@ +from .base_grants import BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch +from dbt.tests.adapter.grants.test_model_grants import BaseModelGrants + + +# Run the adapter Model Grant tests with patched assert_expected_grants_match_actual + + +class TestModelGrantsSnowflake(BaseGrantsSnowflakePatch, BaseModelGrants): + pass + + +# With "+copy_grants": True +class TestModelGrantsCopyGrantsSnowflake( + BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch, BaseModelGrants +): + pass diff --git a/tests/functional/adapter/grants/test_model_grants_with_database_role.py b/tests/functional/adapter/grants/test_model_grants_with_database_role.py new file mode 100644 index 000000000..f597a0b06 --- /dev/null +++ b/tests/functional/adapter/grants/test_model_grants_with_database_role.py @@ -0,0 +1,85 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_model_grants import BaseModelGrantsSnowflake + + +class BaseModelGrantsSnowflakeDatabaseRole(BaseModelGrantsSnowflake): + """ + The base adapter model grant test but using database roles + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + grants: + select: + database_role: ["test_database_role_1"] +""", + "user2_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + grants: + select: + database_role: ["test_database_role_2"] +""", + "table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: + database_role: ["test_database_role_1"] +""", + "user2_table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: + database_role: ["test_database_role_2"] +""", + "multiple_users_table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: + database_role: ["test_database_role_1", "test_database_role_2"] +""", + "multiple_privileges_table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: + database_role: ["test_database_role_1"] + insert: + database_role: ["test_database_role_2"] +""", + } + + +class TestModelGrantsSnowflakeDatabaseRole(BaseModelGrantsSnowflakeDatabaseRole): + pass + + +# With "+copy_grants": True +class TestModelGrantsCopyGrantsSnowflakeDatabaseRole( + BaseCopyGrantsSnowflake, BaseModelGrantsSnowflakeDatabaseRole +): + pass diff --git a/tests/functional/adapter/grants/test_model_grants_with_role.py b/tests/functional/adapter/grants/test_model_grants_with_role.py new file mode 100644 index 000000000..a2eca45b6 --- /dev/null +++ b/tests/functional/adapter/grants/test_model_grants_with_role.py @@ -0,0 +1,85 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_model_grants import BaseModelGrantsSnowflake + + +class BaseModelGrantsSnowflakeRole(BaseModelGrantsSnowflake): + """ + The base adapter model grant test but using new role syntax + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + "table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + "multiple_users_table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_1') }}", "{{ env_var('DBT_TEST_USER_2') }}"] +""", + "multiple_privileges_table_model_schema_yml": """ +version: 2 +models: + - name: my_model + config: + materialized: table + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_1') }}"] + insert: + role: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + } + + +class TestModelGrantsSnowflakeRole(BaseModelGrantsSnowflakeRole): + pass + + +# With "+copy_grants": True +class TestModelGrantsCopyGrantsSnowflakeRole( + BaseCopyGrantsSnowflake, BaseModelGrantsSnowflakeRole +): + pass diff --git a/tests/functional/adapter/grants/test_seed_grants.py b/tests/functional/adapter/grants/test_seed_grants.py new file mode 100644 index 000000000..7a30b56cc --- /dev/null +++ b/tests/functional/adapter/grants/test_seed_grants.py @@ -0,0 +1,16 @@ +from .base_grants import BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch +from dbt.tests.adapter.grants.test_seed_grants import BaseSeedGrants + + +# Run the adapter Seed Grant tests with patched assert_expected_grants_match_actual + + +class TestSeedGrantsSnowflake(BaseGrantsSnowflakePatch, BaseSeedGrants): + pass + + +# With "+copy_grants": True +class TestSeedGrantsCopyGrantsSnowflake( + BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch, BaseSeedGrants +): + pass diff --git a/tests/functional/adapter/grants/test_seed_grants_with_database_role.py b/tests/functional/adapter/grants/test_seed_grants_with_database_role.py new file mode 100644 index 000000000..d57319812 --- /dev/null +++ b/tests/functional/adapter/grants/test_seed_grants_with_database_role.py @@ -0,0 +1,59 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_seed_grants import BaseSeedGrantsSnowflake + + +class BaseSeedGrantsSnowflakeDatabaseRole(BaseSeedGrantsSnowflake): + """ + The base adapter model grant test but using new role syntax + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "schema_base_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: + select: + database_role: ["test_database_role_1"] +""", + "user2_schema_base_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: + select: + database_role: ["test_database_role_2"] +""", + "ignore_grants_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: {} +""", + "zero_grants_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: + select: + database_role: [] +""", + } + + +class TestSeedGrantsSnowflakeDatabaseRole(BaseSeedGrantsSnowflakeDatabaseRole): + pass + + +# With "+copy_grants": True +class TestSeedGrantsCopyGrantsSnowflakeDatabaseRole( + BaseCopyGrantsSnowflake, BaseSeedGrantsSnowflakeDatabaseRole +): + pass diff --git a/tests/functional/adapter/grants/test_seed_grants_with_role.py b/tests/functional/adapter/grants/test_seed_grants_with_role.py new file mode 100644 index 000000000..e5bc6f827 --- /dev/null +++ b/tests/functional/adapter/grants/test_seed_grants_with_role.py @@ -0,0 +1,57 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_seed_grants import BaseSeedGrantsSnowflake + + +class BaseSeedGrantsSnowflakeRole(BaseSeedGrantsSnowflake): + """ + The base adapter model grant test but using new role syntax + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "schema_base_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_schema_base_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + "ignore_grants_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: {} +""", + "zero_grants_yml": """ +version: 2 +seeds: + - name: my_seed + config: + grants: + select: + role: [] +""", + } + + +class TestSeedGrantsSnowflakeRole(BaseSeedGrantsSnowflakeRole): + pass + + +# With "+copy_grants": True +class TestSeedGrantsCopyGrantsSnowflakeRole(BaseCopyGrantsSnowflake, BaseSeedGrantsSnowflakeRole): + pass diff --git a/tests/functional/adapter/grants/test_snapshot_grants.py b/tests/functional/adapter/grants/test_snapshot_grants.py new file mode 100644 index 000000000..e45085f5c --- /dev/null +++ b/tests/functional/adapter/grants/test_snapshot_grants.py @@ -0,0 +1,16 @@ +from .base_grants import BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch +from dbt.tests.adapter.grants.test_snapshot_grants import BaseSnapshotGrants + + +# Run the adapter Snapshot Grant tests with patched assert_expected_grants_match_actual + + +class TestSnapshotGrantsSnowflake(BaseGrantsSnowflakePatch, BaseSnapshotGrants): + pass + + +# With "+copy_grants": True +class TestSnapshotGrantsCopyGrantsSnowflake( + BaseCopyGrantsSnowflake, BaseGrantsSnowflakePatch, BaseSnapshotGrants +): + pass diff --git a/tests/functional/adapter/grants/test_snapshot_grants_with_database_role.py b/tests/functional/adapter/grants/test_snapshot_grants_with_database_role.py new file mode 100644 index 000000000..f77cea35a --- /dev/null +++ b/tests/functional/adapter/grants/test_snapshot_grants_with_database_role.py @@ -0,0 +1,43 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_snapshot_grants import BaseSnapshotGrantsSnowflake + + +class BaseSnapshotGrantsSnowflakeDatabaseRole(BaseSnapshotGrantsSnowflake): + """ + The base adapter Snapshot grant test but using new role syntax + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "snapshot_schema_yml": """ +version: 2 +snapshots: + - name: my_snapshot + config: + grants: + select: + database_role: ["test_database_role_1"] +""", + "user2_snapshot_schema_yml": """ +version: 2 +snapshots: + - name: my_snapshot + config: + grants: + select: + database_role: ["test_database_role_2"] +""", + } + + +class TestSnapshotGrantsSnowflakeDatabaseRole(BaseSnapshotGrantsSnowflakeDatabaseRole): + pass + + +# With "+copy_grants": True +class TestSnapshotGrantsCopyGrantsSnowflakeDatabaseRole( + BaseCopyGrantsSnowflake, BaseSnapshotGrantsSnowflakeDatabaseRole +): + pass diff --git a/tests/functional/adapter/grants/test_snapshot_grants_with_role.py b/tests/functional/adapter/grants/test_snapshot_grants_with_role.py new file mode 100644 index 000000000..7935f55f4 --- /dev/null +++ b/tests/functional/adapter/grants/test_snapshot_grants_with_role.py @@ -0,0 +1,43 @@ +import pytest +from .base_grants import BaseCopyGrantsSnowflake +from .base_snapshot_grants import BaseSnapshotGrantsSnowflake + + +class BaseSnapshotGrantsSnowflakeRole(BaseSnapshotGrantsSnowflake): + """ + The base adapter Snapshot grant test but using new role syntax + """ + + @pytest.fixture(scope="class") + def schema_yml(self, prefix): + return { + "snapshot_schema_yml": """ +version: 2 +snapshots: + - name: my_snapshot + config: + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_1') }}"] +""", + "user2_snapshot_schema_yml": """ +version: 2 +snapshots: + - name: my_snapshot + config: + grants: + select: + role: ["{{ env_var('DBT_TEST_USER_2') }}"] +""", + } + + +class TestSnapshotGrantsSnowflakeRole(BaseSnapshotGrantsSnowflakeRole): + pass + + +# With "+copy_grants": True +class TestSnapshotGrantsCopyGrantsSnowflakeRole( + BaseCopyGrantsSnowflake, BaseSnapshotGrantsSnowflakeRole +): + pass diff --git a/tests/functional/adapter/test_grants.py b/tests/functional/adapter/test_grants.py deleted file mode 100644 index 30e687f59..000000000 --- a/tests/functional/adapter/test_grants.py +++ /dev/null @@ -1,64 +0,0 @@ -import pytest -from dbt.tests.adapter.grants.test_incremental_grants import BaseIncrementalGrants -from dbt.tests.adapter.grants.test_invalid_grants import BaseInvalidGrants -from dbt.tests.adapter.grants.test_model_grants import BaseModelGrants -from dbt.tests.adapter.grants.test_seed_grants import BaseSeedGrants -from dbt.tests.adapter.grants.test_snapshot_grants import BaseSnapshotGrants - - -class BaseCopyGrantsSnowflake: - # Try every test case without copy_grants enabled (default), - # and with copy_grants enabled (this base class) - @pytest.fixture(scope="class") - def project_config_update(self): - return { - "models": { - "+copy_grants": True, - }, - "seeds": { - "+copy_grants": True, - }, - "snapshots": { - "+copy_grants": True, - }, - } - - -class TestInvalidGrantsSnowflake(BaseInvalidGrants): - def grantee_does_not_exist_error(self): - return "does not exist or not authorized" - - def privilege_does_not_exist_error(self): - return "unexpected" - - -class TestModelGrantsSnowflake(BaseModelGrants): - pass - - -class TestModelGrantsCopyGrantsSnowflake(BaseCopyGrantsSnowflake, BaseModelGrants): - pass - - -class TestIncrementalGrantsSnowflake(BaseIncrementalGrants): - pass - - -class TestIncrementalCopyGrantsSnowflake(BaseCopyGrantsSnowflake, BaseIncrementalGrants): - pass - - -class TestSeedGrantsSnowflake(BaseSeedGrants): - pass - - -class TestSeedCopyGrantsSnowflake(BaseCopyGrantsSnowflake, BaseSeedGrants): - pass - - -class TestSnapshotGrants(BaseSnapshotGrants): - pass - - -class TestSnapshotCopyGrantsSnowflake(BaseCopyGrantsSnowflake, BaseSnapshotGrants): - pass diff --git a/tests/unit/test_snowflake_adapter.py b/tests/unit/test_snowflake_adapter.py index 32e73eb45..7e3a0954d 100644 --- a/tests/unit/test_snowflake_adapter.py +++ b/tests/unit/test_snowflake_adapter.py @@ -25,7 +25,7 @@ ) -class TestSnowflakeAdapter(unittest.TestCase): +class SnowflakeAdapterBase(unittest.TestCase): def setUp(self): profile_cfg = { "outputs": { @@ -101,6 +101,9 @@ def tearDown(self): self.patcher.stop() self.load_state_check.stop() + +class TestSnowflakeAdapter(SnowflakeAdapterBase): + def test_quoting_on_drop_schema(self): relation = SnowflakeAdapter.Relation.create( database="test_database", @@ -993,3 +996,95 @@ def test_invalid_private_key_path(self): private_key_path="/tmp/does/not/exist.p8", ) self.assertRaises(FileNotFoundError, creds.auth_args) + + +class TestSnowflakeAdapter_diff_of_grants(SnowflakeAdapterBase): + def test_empty(self): + diff = self.adapter.diff_of_grants({}, {}) + assert diff == {} + + def test_object_types_but_no_grantee(self): + grants_a = {"select": {"role": {}}} + grants_b = {"select": {"role": {}}} + diff = self.adapter.diff_of_grants(grants_a, grants_b) + assert diff == {} + + def test_empty_grant_a(self): + grants_a = {} + grants_b = {"insert": {"role": ["MY_TEST_ROLE_1"]}} + + diff = self.adapter.diff_of_grants(grants_a, grants_b) + assert diff == {} + + def test_empty_grant_b(self): + grants_a = {"insert": {"role": ["MY_TEST_ROLE_1"]}} + grants_b = {} + + diff = self.adapter.diff_of_grants(grants_a, grants_b) + assert diff == grants_a + + def test_case_different_1(self): + grants_a = {"insert": {"role": ["MY_TEST_ROLE_1"]}} + grants_b = {"insert": {"role": ["my_test_role_1"]}} + + diff = self.adapter.diff_of_grants(grants_a, grants_b) + assert diff == {} + + def test_case_different_2(self): + grants_a = {"insert": {"role": ["my_test_role_1"]}} + grants_b = {"insert": {"role": ["MY_TEST_ROLE_1"]}} + + diff = self.adapter.diff_of_grants(grants_a, grants_b) + assert diff == {} + + def test_select_to_insert(self): + grants_a = {"insert": {"role": ["my_test_role_1"]}} + grants_b = {"select": {"database_role": ["my_test_role_1"]}} + + diff = self.adapter.diff_of_grants(grants_a, grants_b) + assert diff == grants_a + + def test_comment_example(self): + grants_a = {"key_x": {"key_a": ["VALUE_1", "VALUE_2"]}, "KEY_Y": {"key_b": ["value_2"]}} + grants_b = {"KEY_x": {"key_a": ["VALUE_1"]}, "key_y": {"key_b": ["value_3"]}} + + diff = self.adapter.diff_of_grants(grants_a, grants_b) + assert diff == {"key_x": {"key_a": ["VALUE_2"]}, "KEY_Y": {"key_b": ["value_2"]}} + + +class TestSnowflakeAdapter_standardize_grant_config(SnowflakeAdapterBase): + def test_empty(self): + std = self.adapter.standardize_grant_config({}) + assert std == {} + + def test_empty_privilege(self): + std = self.adapter.standardize_grant_config({"select": []}) + assert std == {} + + def test_empty_privilege_role_type(self): + std = self.adapter.standardize_grant_config({"select": [{"role": []}]}) + assert std == {} + + def test_old_style(self): + grant_config = {"select": ["my_test_role"]} + expected = {"role": {"select": ["my_test_role"]}} + + std = self.adapter.standardize_grant_config(grant_config) + assert std == expected + + def test_new_style(self): + grant_config = {"select": [{"database_role": ["my_test_role"]}]} + expected = {"database_role": {"select": ["my_test_role"]}} + + std = self.adapter.standardize_grant_config(grant_config) + assert std == expected + + def test_mixed_style(self): + grant_config = {"select": [{"database_role": ["my_test_role"]}, ["my_test_role_2"]]} + expected = { + "database_role": {"select": ["my_test_role"]}, + "role": {"select": ["my_test_role_2"]}, + } + + std = self.adapter.standardize_grant_config(grant_config) + assert std == expected