From b28509a80dfe29ace72262fb4b9552c69e5310a0 Mon Sep 17 00:00:00 2001 From: Brandon Jackson Date: Wed, 15 Jul 2026 18:05:11 -0500 Subject: [PATCH] feat(dbt_project): support DBT PROJECT grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DBT PROJECT (dbt Projects on Snowflake) as a schema-scoped grantable resource type so users can write: grants: - priv: USAGE on: dbt project .. to: - priv: MONITOR on: dbt project .. to: USAGE is required to EXECUTE DBT PROJECT and list/retrieve project files; MONITOR exposes project details + run history in Snowsight. The schema-scope priv CREATE DBT PROJECT is added to SchemaPriv for deploy-from-Workspace flows. Changes (mirrors the CORTEX SEARCH SERVICE grant PR): - ResourceType.DBT_PROJECT added to the enum (alphabetical). - DbtProjectPriv(Priv) with ALL/MONITOR/OWNERSHIP/USAGE. - PRIVS_FOR_RESOURCE_TYPE entry mapping the type to the priv class. - SchemaPriv.CREATE_DBT_PROJECT added. - RESOURCE_SCOPES entry registering SchemaScope() (INTEGRATION/Cortex precedent: no concrete resource class yet, just enough to let `priv: ... on: dbt project ...` parse and render). - Unit tests: DbtProjectPriv enum values + end-to-end Grant SQL for USAGE and MONITOR. - Docs: docs/resources/dbt_project.md (new), grant.md example, mkdocs nav. Full dbt project resource modeling (CREATE DBT PROJECT ... FROM with profiles/target config) is left for a future PR — this covers grants. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016L3b4GTh5nyFy5vxQ6e9TY --- docs/resources/dbt_project.md | 88 +++++++++++++++++++++++++++++++++++ docs/resources/grant.md | 10 ++++ mkdocs.yml | 2 + snowcap/data_provider.py | 15 ++++++ snowcap/enums.py | 1 + snowcap/privs.py | 9 ++++ snowcap/resources/resource.py | 6 +++ tests/test_grant.py | 27 +++++++++++ tests/test_privs.py | 24 ++++++++++ 9 files changed, 182 insertions(+) create mode 100644 docs/resources/dbt_project.md diff --git a/docs/resources/dbt_project.md b/docs/resources/dbt_project.md new file mode 100644 index 0000000..8324aa2 --- /dev/null +++ b/docs/resources/dbt_project.md @@ -0,0 +1,88 @@ +--- +description: >- + Grant USAGE / MONITOR on a Snowflake dbt project object (dbt Projects on Snowflake). +--- + +# DbtProject + +[Snowflake Documentation](https://docs.snowflake.com/en/user-guide/data-engineering/dbt-projects-on-snowflake-access-control) | Snowcap CLI label: `dbt_project` + +A dbt project object (part of *dbt Projects on Snowflake*) is a schema-scoped +object that packages a dbt project so it can be executed with `EXECUTE DBT +PROJECT`. Snowcap supports granting access to existing dbt project objects +declaratively. The object itself (`CREATE DBT PROJECT ... FROM ` +with its profiles/target config) is **not** modeled as a concrete resource — +create it via a Snowflake Workspace or DDL, then manage who can execute and +monitor it through `grants:`. + +## Examples + +### YAML + +```yaml +grants: + # Required to EXECUTE DBT PROJECT and to list/retrieve the project's files. + - priv: USAGE + on: dbt project somedb.someschema.analytics_dbt + to: transformer_role + + # Required to view the project (details + run history) in Snowsight. + - priv: MONITOR + on: dbt project somedb.someschema.analytics_dbt + to: analytics_observer + + # Schema-scope privilege to allow a role to create new dbt project objects + # (e.g. deploying from a Workspace). + - priv: CREATE DBT PROJECT + on: schema somedb.someschema + to: dbt_author_role +``` + +### Python + +```python +# Grant USAGE (execute the project + read its files) +grant = Grant( + priv="USAGE", + on_dbt_project="somedb.someschema.analytics_dbt", + to="transformer_role", +) + +# Grant MONITOR (Snowsight project details + run history) +grant = Grant( + priv="MONITOR", + on_dbt_project="somedb.someschema.analytics_dbt", + to="analytics_observer", +) +``` + +## Privileges + +| Privilege | Purpose | +|--------------------|---------------------------------------------------------------------------------------------| +| `USAGE` | Execute the dbt project (`EXECUTE DBT PROJECT`) and list/retrieve its files. | +| `MONITOR` | View the project's details and run history in Snowsight. | +| `OWNERSHIP` | Full control of the object. Exclusive to a single role (like tasks). | +| `ALL` | All privileges above. | + +The schema-scope privilege `CREATE DBT PROJECT` (see [Schema](#) grants) lets a +role create dbt project objects in that schema. + +## Minimal example + +Two roles, least privilege: one runs the project, one only watches it in +Snowsight. Neither owns it — ownership stays with the deploying role. + +```yaml +grants: + - priv: USAGE + on: dbt project analytics.transforms.daily_models + to: dbt_runner + - priv: MONITOR + on: dbt project analytics.transforms.daily_models + to: analytics_viewer +``` + +> Note: in a managed-access schema, granting these privileges is restricted to +> the schema owner or a role with `MANAGE GRANTS` — the same constraint that +> applies to tasks. diff --git a/docs/resources/grant.md b/docs/resources/grant.md index 81dad6d..9a32359 100644 --- a/docs/resources/grant.md +++ b/docs/resources/grant.md @@ -59,6 +59,16 @@ grants: - priv: MONITOR on: cortex search service somedb.someschema.someservice to: search_observability_role + + # Data Engineering: USAGE on a dbt project object to EXECUTE DBT PROJECT + - priv: USAGE + on: dbt project somedb.someschema.analytics_dbt + to: transformer_role + + # Data Engineering: MONITOR on a dbt project for Snowsight run history + - priv: MONITOR + on: dbt project somedb.someschema.analytics_dbt + to: analytics_observer ``` #### Future Grants diff --git a/mkdocs.yml b/mkdocs.yml index 446e21e..995b607 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -123,6 +123,8 @@ nav: - ResourceMonitor: resources/resource_monitor.md - Apps: - Streamlit: resources/streamlit.md + - Data Engineering: + - DbtProject: resources/dbt_project.md - Data Loading: - File Formats: - JSONFileFormat: resources/json_file_format.md diff --git a/snowcap/data_provider.py b/snowcap/data_provider.py index f556b0e..7d6558b 100644 --- a/snowcap/data_provider.py +++ b/snowcap/data_provider.py @@ -1622,6 +1622,21 @@ def fetch_database_role_grant(session: SnowflakeConnection, fqn: FQN): } +def fetch_dbt_project(session: SnowflakeConnection, fqn: FQN): + projects = _show_resources(session, "DBT PROJECTS", fqn) + if len(projects) == 0: + return None + if len(projects) > 1: + raise Exception(f"Found multiple dbt projects matching {fqn}") + + data = projects[0] + return { + "name": data["name"], + "owner": _get_owner_identifier(data), + "comment": data["comment"] or None, + } + + def fetch_dynamic_table(session: SnowflakeConnection, fqn: FQN): show_result = _show_resources(session, "DYNAMIC TABLES", fqn) if len(show_result) == 0: diff --git a/snowcap/enums.py b/snowcap/enums.py index 3b12b9b..b4c05e9 100644 --- a/snowcap/enums.py +++ b/snowcap/enums.py @@ -59,6 +59,7 @@ class ResourceType(ParseableEnum): DATABASE = "DATABASE" DATABASE_ROLE = "DATABASE ROLE" DATABASE_ROLE_GRANT = "DATABASE ROLE GRANT" + DBT_PROJECT = "DBT PROJECT" DIRECTORY_TABLE = "DIRECTORY TABLE" DYNAMIC_TABLE = "DYNAMIC TABLE" EVENT_TABLE = "EVENT TABLE" diff --git a/snowcap/privs.py b/snowcap/privs.py index 4d6ca8c..8156b8a 100644 --- a/snowcap/privs.py +++ b/snowcap/privs.py @@ -119,6 +119,13 @@ class CortexSearchServicePriv(Priv): USAGE = "USAGE" +class DbtProjectPriv(Priv): + ALL = "ALL" + MONITOR = "MONITOR" + OWNERSHIP = "OWNERSHIP" + USAGE = "USAGE" + + class DirectoryTablePriv(Priv): OWNERSHIP = "OWNERSHIP" @@ -240,6 +247,7 @@ class SchemaPriv(Priv): CREATE_AUTHENTICATION_POLICY = "CREATE AUTHENTICATION POLICY" CREATE_CORTEX_SEARCH_SERVICE = "CREATE CORTEX SEARCH SERVICE" CREATE_DATASET = "CREATE DATASET" + CREATE_DBT_PROJECT = "CREATE DBT PROJECT" CREATE_DYNAMIC_TABLE = "CREATE DYNAMIC TABLE" CREATE_EVENT_TABLE = "CREATE EVENT TABLE" CREATE_EXTERNAL_TABLE = "CREATE EXTERNAL TABLE" @@ -395,6 +403,7 @@ class WarehousePriv(Priv): ResourceType.CORTEX_SEARCH_SERVICE: CortexSearchServicePriv, ResourceType.DATABASE_ROLE: DatabaseRolePriv, ResourceType.DATABASE: DatabasePriv, + ResourceType.DBT_PROJECT: DbtProjectPriv, ResourceType.DIRECTORY_TABLE: DirectoryTablePriv, ResourceType.DYNAMIC_TABLE: TablePriv, ResourceType.EVENT_TABLE: EventTablePriv, diff --git a/snowcap/resources/resource.py b/snowcap/resources/resource.py index e36f3e7..566cdc0 100644 --- a/snowcap/resources/resource.py +++ b/snowcap/resources/resource.py @@ -310,6 +310,12 @@ def get_metadata(cls, field_name: str) -> ResourceSpecMetadata: # `priv: USAGE on cortex search service ..` in YAML to # manage access to services they create out-of-band (e.g. via dbt or DDL). ResourceType.CORTEX_SEARCH_SERVICE: SchemaScope(), + # DBT PROJECT — dbt Projects on Snowflake, schema-scoped. No concrete + # resource class yet (CREATE DBT PROJECT wraps a workspace/git source + + # profiles config that warrants its own PR). Registering a SchemaScope here + # lets users write `priv: USAGE on dbt project ..` in YAML + # to manage access to projects they create out-of-band (Workspaces / DDL). + ResourceType.DBT_PROJECT: SchemaScope(), } diff --git a/tests/test_grant.py b/tests/test_grant.py index a32961b..acd6cdf 100644 --- a/tests/test_grant.py +++ b/tests/test_grant.py @@ -174,6 +174,33 @@ def test_grant_on_cortex_search_service(): assert "MONITOR ON CORTEX SEARCH SERVICE" in monitor_grant.create_sql() +def test_grant_on_dbt_project(): + """USAGE/MONITOR on a DBT PROJECT parses and renders correctly. + + A dbt project object (dbt Projects on Snowflake) is schema-scoped. USAGE + lets a role execute the project and list/retrieve its files; MONITOR + exposes project details + run history in Snowsight: + GRANT USAGE ON DBT PROJECT .. TO ROLE r + GRANT MONITOR ON DBT PROJECT .. TO ROLE observer + """ + grant = res.Grant( + priv="USAGE", + on_dbt_project="somedb.someschema.someproject", + to="somerole", + ) + assert grant._data.on == "SOMEDB.SOMESCHEMA.SOMEPROJECT" + assert grant._data.on_type == ResourceType.DBT_PROJECT + assert "USAGE ON DBT PROJECT" in grant.create_sql() + + monitor_grant = res.Grant( + priv="MONITOR", + on_dbt_project="somedb.someschema.someproject", + to="observer", + ) + assert monitor_grant._data.on_type == ResourceType.DBT_PROJECT + assert "MONITOR ON DBT PROJECT" in monitor_grant.create_sql() + + def test_grant_database_role_to_database_role(): database = res.Database(name="somedb") parent = res.DatabaseRole(name="parent", database=database) diff --git a/tests/test_privs.py b/tests/test_privs.py index 6450721..6d1956c 100644 --- a/tests/test_privs.py +++ b/tests/test_privs.py @@ -10,6 +10,7 @@ CortexSearchServicePriv, DatabasePriv, DatabaseRolePriv, + DbtProjectPriv, DirectoryTablePriv, EventTablePriv, ExternalVolumePriv, @@ -347,6 +348,29 @@ def test_ownership_privilege(self): assert CortexSearchServicePriv.OWNERSHIP.value == "OWNERSHIP" +############################################################################# +# DbtProjectPriv Tests +############################################################################# + + +class TestDbtProjectPriv: + """Tests for DbtProjectPriv enum values (dbt Projects on Snowflake).""" + + def test_all_privilege(self): + assert DbtProjectPriv.ALL.value == "ALL" + + def test_usage_privilege(self): + """USAGE is the priv needed to execute a dbt project + list its files.""" + assert DbtProjectPriv.USAGE.value == "USAGE" + + def test_monitor_privilege(self): + """MONITOR is the priv needed for project details + run history.""" + assert DbtProjectPriv.MONITOR.value == "MONITOR" + + def test_ownership_privilege(self): + assert DbtProjectPriv.OWNERSHIP.value == "OWNERSHIP" + + ############################################################################# # is_ownership_priv() Tests #############################################################################