Repository: dbt-labs/dbt-core

Issue:
[CT-719] Add Grants to Postgres Materializations
blocked by: #5263

Once the above work is merged, there are some tweaks that need to be made in the Postgres adapter to fully expose the feature:
    - [ ] for incremental, seed, and snapshot materializations call apply_grants(..., revoke=True)
    - [ ] otherwise (i.e. view/table materializations) call apply_grants(..., revoke=False) for a performance improvement


Discussion:
- `get_grant_sql`: https://www.postgresql.org/docs/current/sql-grant.html (let's take this formulation as our default)
- `get_revoke_sql`: https://www.postgresql.org/docs/current/sql-revoke.html (let's take this formulation as our default)
- `get_show_grants_sql`: https://www.postgresql.org/docs/current/infoschema-table-privileges.html or https://www.postgresql.org/docs/current/infoschema-role-table-grants.html (I'm more inclined to use `show grants` as the default, since that's supported by more databases)

Resolving patch:
diff --git a/core/dbt/adapters/base/impl.py b/core/dbt/adapters/base/impl.py
--- a/core/dbt/adapters/base/impl.py
+++ b/core/dbt/adapters/base/impl.py
@@ -159,6 +159,7 @@ class BaseAdapter(metaclass=AdapterMeta):
         - convert_datetime_type
         - convert_date_type
         - convert_time_type
+        - standardize_grants_dict
 
     Macros:
         - get_catalog
@@ -538,6 +539,33 @@ def list_relations_without_caching(self, schema_relation: BaseRelation) -> List[
             "`list_relations_without_caching` is not implemented for this " "adapter!"
         )
 
+    ###
+    # Methods about grants
+    ###
+    @available
+    def standardize_grants_dict(self, grants_table: agate.Table) -> dict:
+        """Translate the result of `show grants` (or equivalent) to match the
+        grants which a user would configure in their project.
+
+        Ideally, the SQL to show grants should also be filtering:
+        filter OUT any grants TO the current user/role (e.g. OWNERSHIP).
+        If that's not possible in SQL, it can be done in this method instead.
+
+        :param grants_table: An agate table containing the query result of
+            the SQL returned by get_show_grant_sql
+        :return: A standardized dictionary matching the `grants` config
+        :rtype: dict
+        """
+        grants_dict: Dict[str, List[str]] = {}
+        for row in grants_table:
+            grantee = row["grantee"]
+            privilege = row["privilege_type"]
+            if privilege in grants_dict.keys():
+                grants_dict[privilege].append(grantee)
+            else:
+                grants_dict.update({privilege: [grantee]})
+        return grants_dict
+
     ###
     # Provided methods about relations
     ###
diff --git a/core/dbt/context/base.py b/core/dbt/context/base.py
--- a/core/dbt/context/base.py
+++ b/core/dbt/context/base.py
@@ -1,6 +1,6 @@
 import json
 import os
-from typing import Any, Dict, NoReturn, Optional, Mapping, Iterable, Set
+from typing import Any, Dict, NoReturn, Optional, Mapping, Iterable, Set, List
 
 from dbt import flags
 from dbt import tracking
@@ -657,6 +657,35 @@ def print(msg: str) -> str:
             print(msg)
         return ""
 
+    @contextmember
+    @staticmethod
+    def diff_of_two_dicts(
+        dict_a: Dict[str, List[str]], dict_b: Dict[str, List[str]]
+    ) -> Dict[str, List[str]]:
+        """
+        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
+
 
 def generate_base_context(cli_vars: Dict[str, Any]) -> Dict[str, Any]:
     ctx = BaseContext(cli_vars)
diff --git a/core/dbt/parser/manifest.py b/core/dbt/parser/manifest.py
--- a/core/dbt/parser/manifest.py
+++ b/core/dbt/parser/manifest.py
@@ -636,7 +636,9 @@ def read_manifest_for_partial_parse(self) -> Optional[Manifest]:
         if not flags.PARTIAL_PARSE:
             fire_event(PartialParsingNotEnabled())
             return None
-        path = os.path.join(self.root_project.target_path, PARTIAL_PARSE_FILE_NAME)
+        path = os.path.join(
+            self.root_project.project_root, self.root_project.target_path, PARTIAL_PARSE_FILE_NAME
+        )
 
         reparse_reason = None
 


Test patch:
diff --git a/core/dbt/tests/util.py b/core/dbt/tests/util.py
--- a/core/dbt/tests/util.py
+++ b/core/dbt/tests/util.py
@@ -73,7 +73,10 @@ def run_dbt(args: List[str] = None, expect_pass=True):
     return res
 
 
-# Use this if you need to capture the command logs in a test
+# Use this if you need to capture the command logs in a test.
+# If you want the logs that are normally written to a file, you must
+# start with the "--debug" flag. The structured schema log CI test
+# will turn the logs into json, so you have to be prepared for that.
 def run_dbt_and_capture(args: List[str] = None, expect_pass=True):
     try:
         stringbuf = capture_stdout_logs()
@@ -83,6 +86,11 @@ def run_dbt_and_capture(args: List[str] = None, expect_pass=True):
     finally:
         stop_capture_stdout_logs()
 
+    # Json logs will have lots of escape characters which will
+    # make checks for strings in the logs fail, so remove those.
+    if '{"code":' in stdout:
+        stdout = stdout.replace("\\", "")
+
     return res, stdout
 
 
diff --git a/test/setup_db.sh b/test/setup_db.sh
--- a/test/setup_db.sh
+++ b/test/setup_db.sh
@@ -46,6 +46,9 @@ psql -c "GRANT CREATE, CONNECT ON DATABASE dbt TO root WITH GRANT OPTION;"
 psql -c "CREATE ROLE noaccess WITH PASSWORD 'password' NOSUPERUSER;"
 psql -c "ALTER ROLE noaccess WITH LOGIN;"
 psql -c "GRANT CONNECT ON DATABASE dbt TO noaccess;"
+psql -c "CREATE ROLE dbt_test_user_1;"
+psql -c "CREATE ROLE dbt_test_user_2;"
+psql -c "CREATE ROLE dbt_test_user_3;"
 
 psql -c 'CREATE DATABASE "dbtMixedCase";'
 psql -c 'GRANT CREATE, CONNECT ON DATABASE "dbtMixedCase" TO root WITH GRANT OPTION;'
diff --git a/test/unit/test_context.py b/test/unit/test_context.py
--- a/test/unit/test_context.py
+++ b/test/unit/test_context.py
@@ -199,6 +199,7 @@ def assert_has_keys(required_keys: Set[str], maybe_keys: Set[str], ctx: Dict[str
         "modules",
         "flags",
         "print",
+        "diff_of_two_dicts"
     }
 )
 
diff --git a/tests/adapter/dbt/tests/adapter/grants/base_grants.py b/tests/adapter/dbt/tests/adapter/grants/base_grants.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/grants/base_grants.py
@@ -0,0 +1,58 @@
+import pytest
+import os
+from dbt.tests.util import (
+    relation_from_name,
+    get_connection,
+)
+from dbt.context.base import BaseContext  # diff_of_two_dicts only
+
+TEST_USER_ENV_VARS = ["DBT_TEST_USER_1", "DBT_TEST_USER_2", "DBT_TEST_USER_3"]
+
+
+def replace_all(text, dic):
+    for i, j in dic.items():
+        text = text.replace(i, j)
+    return text
+
+
+class BaseGrants:
+    def privilege_grantee_name_overrides(self):
+        # these privilege and grantee names are valid on most databases, but not all!
+        # looking at you, BigQuery
+        # optionally use this to map from "select" --> "other_select_name", "insert" --> ...
+        return {
+            "select": "select",
+            "insert": "insert",
+            "fake_privilege": "fake_privilege",
+            "invalid_user": "invalid_user",
+        }
+
+    def interpolate_name_overrides(self, yaml_text):
+        return replace_all(yaml_text, self.privilege_grantee_name_overrides())
+
+    @pytest.fixture(scope="class", autouse=True)
+    def get_test_users(self, project):
+        test_users = []
+        for env_var in TEST_USER_ENV_VARS:
+            user_name = os.getenv(env_var)
+            if user_name:
+                test_users.append(user_name)
+        return test_users
+
+    def get_grants_on_relation(self, project, relation_name):
+        relation = relation_from_name(project.adapter, relation_name)
+        adapter = project.adapter
+        with get_connection(adapter):
+            kwargs = {"relation": relation}
+            show_grant_sql = adapter.execute_macro("get_show_grant_sql", kwargs=kwargs)
+            _, grant_table = adapter.execute(show_grant_sql, fetch=True)
+            actual_grants = adapter.standardize_grants_dict(grant_table)
+        return actual_grants
+
+    def assert_expected_grants_match_actual(self, project, relation_name, expected_grants):
+        actual_grants = self.get_grants_on_relation(project, relation_name)
+        # need a case-insensitive comparison
+        # so just a simple "assert expected == actual_grants" won't work
+        diff_a = BaseContext.diff_of_two_dicts(actual_grants, expected_grants)
+        diff_b = BaseContext.diff_of_two_dicts(expected_grants, actual_grants)
+        assert diff_a == diff_b == {}
diff --git a/tests/adapter/dbt/tests/adapter/grants/test_incremental_grants.py b/tests/adapter/dbt/tests/adapter/grants/test_incremental_grants.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/grants/test_incremental_grants.py
@@ -0,0 +1,102 @@
+import pytest
+from dbt.tests.util import (
+    run_dbt,
+    run_dbt_and_capture,
+    get_manifest,
+    write_file,
+    relation_from_name,
+    get_connection,
+)
+from dbt.tests.adapter.grants.base_grants import BaseGrants
+
+my_incremental_model_sql = """
+  select 1 as fun
+"""
+
+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 BaseIncrementalGrants(BaseGrants):
+    @pytest.fixture(scope="class")
+    def models(self):
+        updated_schema = self.interpolate_name_overrides(incremental_model_schema_yml)
+        return {
+            "my_incremental_model.sql": my_incremental_model_sql,
+            "schema.yml": updated_schema,
+        }
+
+    def test_incremental_grants(self, project, get_test_users):
+        # 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
+        manifest = get_manifest(project.project_root)
+        model_id = "model.test.my_incremental_model"
+        model = manifest.nodes[model_id]
+        assert model.config.materialized == "incremental"
+        expected = {select_privilege_name: [test_users[0]]}
+        self.assert_expected_grants_match_actual(project, "my_incremental_model", expected)
+
+        # 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'
+        self.assert_expected_grants_match_actual(project, "my_incremental_model", expected)
+
+        # Incremental materialization, change select grant user
+        updated_yaml = self.interpolate_name_overrides(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
+        manifest = get_manifest(project.project_root)
+        model = manifest.nodes[model_id]
+        assert model.config.materialized == "incremental"
+        expected = {select_privilege_name: [test_users[1]]}
+        self.assert_expected_grants_match_actual(project, "my_incremental_model", expected)
+
+        # Incremental materialization, same config, now with --full-refresh
+        run_dbt(["--debug", "run", "--full-refresh"])
+        assert len(results) == 1
+        # whether grants or revokes happened will vary by adapter
+        self.assert_expected_grants_match_actual(project, "my_incremental_model", expected)
+
+        # 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
+        self.assert_expected_grants_match_actual(project, "my_incremental_model", expected)
+
+
+class TestIncrementalGrants(BaseIncrementalGrants):
+    pass
diff --git a/tests/adapter/dbt/tests/adapter/grants/test_invalid_grants.py b/tests/adapter/dbt/tests/adapter/grants/test_invalid_grants.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/grants/test_invalid_grants.py
@@ -0,0 +1,68 @@
+import pytest
+from dbt.tests.util import (
+    run_dbt_and_capture,
+    write_file,
+)
+from dbt.tests.adapter.grants.base_grants import BaseGrants
+
+my_invalid_model_sql = """
+  select 1 as fun
+"""
+
+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 BaseInvalidGrants(BaseGrants):
+    # 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 models(self):
+        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"
+
+    def privilege_does_not_exist_error(self):
+        return "unrecognized privilege"
+
+    def test_invalid_grants(self, project, get_test_users, logs_dir):
+        # failure when grant to a user/role that doesn't exist
+        yaml_file = self.interpolate_name_overrides(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(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
+
+
+class TestInvalidGrants(BaseInvalidGrants):
+    pass
diff --git a/tests/adapter/dbt/tests/adapter/grants/test_model_grants.py b/tests/adapter/dbt/tests/adapter/grants/test_model_grants.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/grants/test_model_grants.py
@@ -0,0 +1,156 @@
+import pytest
+from dbt.tests.util import (
+    run_dbt_and_capture,
+    get_manifest,
+    write_file,
+)
+from dbt.tests.adapter.grants.base_grants import BaseGrants
+
+my_model_sql = """
+  select 1 as fun
+"""
+
+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') }}"]
+"""
+
+
+class BaseModelGrants(BaseGrants):
+    @pytest.fixture(scope="class")
+    def models(self):
+        updated_schema = self.interpolate_name_overrides(model_schema_yml)
+        return {
+            "my_model.sql": my_model_sql,
+            "schema.yml": updated_schema,
+        }
+
+    def test_view_table_grants(self, project, get_test_users):
+        # 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
+        manifest = get_manifest(project.project_root)
+        model_id = "model.test.my_model"
+        model = manifest.nodes[model_id]
+        expected = {select_privilege_name: [test_users[0]]}
+        assert model.config.grants == expected
+        assert model.config.materialized == "view"
+        self.assert_expected_grants_match_actual(project, "my_model", expected)
+
+        # View materialization, change select grant user
+        updated_yaml = self.interpolate_name_overrides(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
+
+        expected = {select_privilege_name: [get_test_users[1]]}
+        self.assert_expected_grants_match_actual(project, "my_model", expected)
+
+        # Table materialization, single select grant
+        updated_yaml = self.interpolate_name_overrides(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
+        manifest = get_manifest(project.project_root)
+        model_id = "model.test.my_model"
+        model = manifest.nodes[model_id]
+        assert model.config.materialized == "table"
+        expected = {select_privilege_name: [test_users[0]]}
+        self.assert_expected_grants_match_actual(project, "my_model", expected)
+
+        # Table materialization, change select grant user
+        updated_yaml = self.interpolate_name_overrides(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
+        manifest = get_manifest(project.project_root)
+        model = manifest.nodes[model_id]
+        assert model.config.materialized == "table"
+        expected = {select_privilege_name: [test_users[1]]}
+        self.assert_expected_grants_match_actual(project, "my_model", expected)
+
+        # Table materialization, multiple grantees
+        updated_yaml = self.interpolate_name_overrides(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
+        manifest = get_manifest(project.project_root)
+        model = manifest.nodes[model_id]
+        assert model.config.materialized == "table"
+        expected = {select_privilege_name: [test_users[0], test_users[1]]}
+        self.assert_expected_grants_match_actual(project, "my_model", expected)
+
+        # Table materialization, multiple privileges
+        updated_yaml = self.interpolate_name_overrides(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
+        manifest = get_manifest(project.project_root)
+        model = manifest.nodes[model_id]
+        assert model.config.materialized == "table"
+        expected = {select_privilege_name: [test_users[0]], insert_privilege_name: [test_users[1]]}
+        self.assert_expected_grants_match_actual(project, "my_model", expected)
+
+
+class TestModelGrants(BaseModelGrants):
+    pass
diff --git a/tests/adapter/dbt/tests/adapter/grants/test_seed_grants.py b/tests/adapter/dbt/tests/adapter/grants/test_seed_grants.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/grants/test_seed_grants.py
@@ -0,0 +1,143 @@
+import pytest
+from dbt.tests.util import (
+    run_dbt,
+    run_dbt_and_capture,
+    get_manifest,
+    write_file,
+)
+from dbt.tests.adapter.grants.base_grants import BaseGrants
+
+seeds__my_seed_csv = """
+id,name,some_date
+1,Easton,1981-05-20T06:46:51
+2,Lillian,1978-09-03T18:10:33
+""".lstrip()
+
+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 BaseSeedGrants(BaseGrants):
+    def seeds_support_partial_refresh(self):
+        return True
+
+    @pytest.fixture(scope="class")
+    def seeds(self):
+        updated_schema = self.interpolate_name_overrides(schema_base_yml)
+        return {
+            "my_seed.csv": seeds__my_seed_csv,
+            "schema.yml": updated_schema,
+        }
+
+    def test_seed_grants(self, project, get_test_users):
+        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
+        manifest = get_manifest(project.project_root)
+        seed_id = "seed.test.my_seed"
+        seed = manifest.nodes[seed_id]
+        expected = {select_privilege_name: [test_users[0]]}
+        assert seed.config.grants == expected
+        assert "grant " in log_output
+        self.assert_expected_grants_match_actual(project, "my_seed", expected)
+
+        # 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
+        self.assert_expected_grants_match_actual(project, "my_seed", expected)
+
+        # change the grantee, assert it updates
+        updated_yaml = self.interpolate_name_overrides(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
+        expected = {select_privilege_name: [test_users[1]]}
+        self.assert_expected_grants_match_actual(project, "my_seed", expected)
+
+        # run it again, with --full-refresh, grants should be the same
+        run_dbt(["seed", "--full-refresh"])
+        self.assert_expected_grants_match_actual(project, "my_seed", expected)
+
+        # change config to 'grants: {}' -- should be completely ignored
+        updated_yaml = self.interpolate_name_overrides(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
+        manifest = get_manifest(project.project_root)
+        seed_id = "seed.test.my_seed"
+        seed = manifest.nodes[seed_id]
+        expected_config = {}
+        expected_actual = {select_privilege_name: [test_users[1]]}
+        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(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 = {}
+        self.assert_expected_grants_match_actual(project, "my_seed", expected)
+
+        # 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
+        self.assert_expected_grants_match_actual(project, "my_seed", expected)
+
+
+class TestSeedGrants(BaseSeedGrants):
+    pass
diff --git a/tests/adapter/dbt/tests/adapter/grants/test_snapshot_grants.py b/tests/adapter/dbt/tests/adapter/grants/test_snapshot_grants.py
new file mode 100644
--- /dev/null
+++ b/tests/adapter/dbt/tests/adapter/grants/test_snapshot_grants.py
@@ -0,0 +1,78 @@
+import pytest
+from dbt.tests.util import (
+    run_dbt,
+    run_dbt_and_capture,
+    get_manifest,
+    write_file,
+)
+from dbt.tests.adapter.grants.base_grants import BaseGrants
+
+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()
+
+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 BaseSnapshotGrants(BaseGrants):
+    @pytest.fixture(scope="class")
+    def snapshots(self):
+        return {
+            "my_snapshot.sql": my_snapshot_sql,
+            "schema.yml": self.interpolate_name_overrides(snapshot_schema_yml),
+        }
+
+    def test_snapshot_grants(self, project, get_test_users):
+        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
+        manifest = get_manifest(project.project_root)
+        snapshot_id = "snapshot.test.my_snapshot"
+        snapshot = manifest.nodes[snapshot_id]
+        expected = {select_privilege_name: [test_users[0]]}
+        assert snapshot.config.grants == expected
+        self.assert_expected_grants_match_actual(project, "my_snapshot", expected)
+
+        # run it again, nothing should have changed
+        (results, log_output) = run_dbt_and_capture(["--debug", "snapshot"])
+        assert len(results) == 1
+        assert "revoke " not in log_output
+        assert "grant " not in log_output
+        self.assert_expected_grants_match_actual(project, "my_snapshot", expected)
+
+        # change the grantee, assert it updates
+        updated_yaml = self.interpolate_name_overrides(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
+        expected = {select_privilege_name: [test_users[1]]}
+        self.assert_expected_grants_match_actual(project, "my_snapshot", expected)
+
+
+class TestSnapshotGrants(BaseSnapshotGrants):
+    pass
diff --git a/tests/functional/configs/test_grant_configs.py b/tests/functional/configs/test_grant_configs.py
--- a/tests/functional/configs/test_grant_configs.py
+++ b/tests/functional/configs/test_grant_configs.py
@@ -57,11 +57,10 @@ def project_config_update(self):
         return dbt_project_yml
 
     def test_model_grant_config(self, project, logs_dir):
-        # This test uses "my_select" instead of "select", so that when
-        # actual granting of permissions happens, it won't break this
-        # test.
-        results = run_dbt(["run"])
-        assert len(results) == 1
+        # This test uses "my_select" instead of "select", so we need
+        # use "parse" instead of "run" because we will get compilation
+        # errors for the grants.
+        run_dbt(["parse"])
 
         manifest = get_manifest(project.project_root)
         model_id = "model.test.my_model"
@@ -77,7 +76,7 @@ def test_model_grant_config(self, project, logs_dir):
 
         # add model grant with clobber
         write_file(my_model_clobber_sql, project.project_root, "models", "my_model.sql")
-        results = run_dbt(["run"])
+        run_dbt(["parse"])
         manifest = get_manifest(project.project_root)
         model_config = manifest.nodes[model_id].config
 
@@ -86,7 +85,7 @@ def test_model_grant_config(self, project, logs_dir):
 
         # change model to extend grants
         write_file(my_model_extend_sql, project.project_root, "models", "my_model.sql")
-        results = run_dbt(["run"])
+        run_dbt(["parse"])
         manifest = get_manifest(project.project_root)
         model_config = manifest.nodes[model_id].config
 
@@ -95,8 +94,7 @@ def test_model_grant_config(self, project, logs_dir):
 
         # add schema file with extend
         write_file(append_schema_yml, project.project_root, "models", "schema.yml")
-        results = run_dbt(["run"])
-        assert len(results) == 1
+        run_dbt(["parse"])
 
         manifest = get_manifest(project.project_root)
         model_config = manifest.nodes[model_id].config
@@ -106,8 +104,7 @@ def test_model_grant_config(self, project, logs_dir):
 
         # change model file to have string instead of list
         write_file(my_model_extend_string_sql, project.project_root, "models", "my_model.sql")
-        results = run_dbt(["run"])
-        assert len(results) == 1
+        run_dbt(["parse"])
 
         manifest = get_manifest(project.project_root)
         model_config = manifest.nodes[model_id].config
@@ -117,8 +114,7 @@ def test_model_grant_config(self, project, logs_dir):
 
         # change model file to have string instead of list
         write_file(my_model_extend_twice_sql, project.project_root, "models", "my_model.sql")
-        results = run_dbt(["run"])
-        assert len(results) == 1
+        run_dbt(["parse"])
 
         manifest = get_manifest(project.project_root)
         model_config = manifest.nodes[model_id].config
@@ -135,8 +131,7 @@ def test_model_grant_config(self, project, logs_dir):
             "log-path": logs_dir,
         }
         write_config_file(config, project.project_root, "dbt_project.yml")
-        results = run_dbt(["run"])
-        assert len(results) == 1
+        run_dbt(["parse"])
 
         manifest = get_manifest(project.project_root)
         model_config = manifest.nodes[model_id].config
@@ -146,8 +141,7 @@ def test_model_grant_config(self, project, logs_dir):
 
         # Remove my_model config, leaving only schema file
         write_file(my_model_base_sql, project.project_root, "models", "my_model.sql")
-        results = run_dbt(["run"])
-        assert len(results) == 1
+        run_dbt(["parse"])
 
         manifest = get_manifest(project.project_root)
         model_config = manifest.nodes[model_id].config