From 36e0f7c91bc40acec976ef4438c706e7ae5bda87 Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Thu, 16 Jul 2026 23:14:02 -0700 Subject: [PATCH 1/2] feat(grants): support SEMANTIC VIEW privileges and grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semantic views are the backbone of Cortex Analyst, but granting a role access to them required manual SQL. Register SEMANTIC VIEW as a grants-only resource type following the CORTEX SEARCH SERVICE precedent (#18): enum member, SemanticViewPriv (SELECT/REFERENCES/MONITOR/ OWNERSHIP/ALL), SchemaPriv.CREATE_SEMANTIC_VIEW, and a SchemaScope registration — no concrete resource class, no type-specific branching. Supported forms: - priv: CREATE SEMANTIC VIEW / on: schema db.schema - priv: SELECT / on: semantic view db.schema.name (on_semantic_view=) - on: all|future semantic views in schema db.schema Fetch/plan round-trip is covered at unit level (GrantedPrivilege typing, SQL fixture parse) plus a --snowflake-gated integration test; that test only runs against a live account, which CI does not do — verify live before release. Closes #30 --- docs/resources/grant.md | 21 ++++++ docs/resources/semantic_view.md | 88 ++++++++++++++++++++++++ docs/snowflake-permissions.md | 6 ++ mkdocs.yml | 1 + snowcap/enums.py | 1 + snowcap/privs.py | 10 +++ snowcap/resources/resource.py | 7 ++ tests/fixtures/sql/grant.sql | 1 + tests/integration/test_grant_patterns.py | 79 ++++++++++++++++++++- tests/test_grant.py | 70 +++++++++++++++++++ tests/test_privs.py | 57 +++++++++++++++ 11 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 docs/resources/semantic_view.md diff --git a/docs/resources/grant.md b/docs/resources/grant.md index 81dad6d..eefbde2 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 + + # AI: schema-scope privilege to allow a role to create semantic views + - priv: CREATE SEMANTIC VIEW + on: schema somedb.someschema + to: semantic_view_author_role + + # AI: SELECT on a Semantic View to query it via Cortex Analyst + - priv: SELECT + on: semantic view somedb.someschema.somesv + to: semantic_view_consumer_role ``` #### Future Grants @@ -77,6 +87,11 @@ grants: - future tables in schema someschema - future views in schema someschema to: somerole + + # AI: future semantic views in schema + - priv: SELECT + on: future semantic views in schema somedb.someschema + to: somerole ``` #### Grants on All Resources @@ -95,6 +110,11 @@ grants: - all tables in schema someschema - all views in schema someschema to: somerole + + # AI: all semantic views in schema + - priv: SELECT + on: all semantic views in schema somedb.someschema + to: somerole ``` ### Python @@ -185,6 +205,7 @@ grant_on_all = Grant( - `"schema my_db.my_schema"` - for schema privileges - `"warehouse my_wh"` - for warehouse privileges - `"database my_db"` - for database privileges + - `"semantic view my_db.my_schema.my_sv"` - for semantic view privileges - `"future tables in schema my_schema"` - for future grants - `"all tables in database my_db"` - for grants on all existing objects diff --git a/docs/resources/semantic_view.md b/docs/resources/semantic_view.md new file mode 100644 index 0000000..5eae522 --- /dev/null +++ b/docs/resources/semantic_view.md @@ -0,0 +1,88 @@ +--- +description: >- + Grant SELECT / REFERENCES / MONITOR on a Snowflake Semantic View. +--- + +# SemanticView + +[Snowflake Documentation](https://docs.snowflake.com/en/user-guide/views-semantic/overview) | Snowcap CLI label: `semantic_view` + +A Semantic View is a schema-scoped Snowflake object that defines business +metrics, dimensions, and relationships over one or more tables, used by +Cortex Analyst and other consumers to query data in business terms. Snowcap +supports granting access to existing semantic views declaratively. The +semantic view itself (the `CREATE SEMANTIC VIEW ... TABLES (...) FACTS (...) +DIMENSIONS (...) METRICS (...)` body) is **not** modeled as a concrete +resource — create it via DDL or dbt, then manage who can query it through +`grants:`. + +## Examples + +### YAML + +```yaml +grants: + # Required to query the semantic view (e.g. via Cortex Analyst). + - priv: SELECT + on: semantic view somedb.someschema.sales_metrics + to: analyst_role + + # Required to inspect the semantic view's structure via the information schema. + - priv: REFERENCES + on: semantic view somedb.someschema.sales_metrics + to: analyst_role + + # Required for get_ai_observability_events() / Cortex Analyst request logs. + - priv: MONITOR + on: semantic view somedb.someschema.sales_metrics + to: search_observability_role + + # Schema-scope privilege to allow a role to create new semantic views. + - priv: CREATE SEMANTIC VIEW + on: schema somedb.someschema + to: semantic_view_author_role +``` + +### Python + +```python +# Grant SELECT +grant = Grant( + priv="SELECT", + on_semantic_view="somedb.someschema.sales_metrics", + to="analyst_role", +) + +# Grant REFERENCES +grant = Grant( + priv="REFERENCES", + on_semantic_view="somedb.someschema.sales_metrics", + to="analyst_role", +) + +# Grant MONITOR +grant = Grant( + priv="MONITOR", + on_semantic_view="somedb.someschema.sales_metrics", + to="search_observability_role", +) +``` + +## Privileges + +| Privilege | Purpose | +|--------------|---------------------------------------------------------------------------| +| `SELECT` | Query the semantic view — sufficient on its own, without `SELECT` on the underlying tables. | +| `REFERENCES` | Inspect the semantic view's structure via the information schema. | +| `MONITOR` | Read Cortex Analyst request logs via `get_ai_observability_events(...)`. | +| `OWNERSHIP` | Standard ownership semantics — drop, alter, transfer. | +| `ALL` | Convenience: expand to all of the above. | + +The schema-scope privilege `CREATE SEMANTIC VIEW` is part of +[Grant](grant.md) under `SchemaPriv` — see the schema-privileges example +above. + +## See also + +- [Snowflake — Semantic views overview](https://docs.snowflake.com/en/user-guide/views-semantic/overview) +- [Grant](grant.md) — for the underlying grant resource and YAML schema diff --git a/docs/snowflake-permissions.md b/docs/snowflake-permissions.md index 7d9ec22..cb672cb 100644 --- a/docs/snowflake-permissions.md +++ b/docs/snowflake-permissions.md @@ -60,6 +60,12 @@ GRANT CREATE COMPUTE POOL ON ACCOUNT TO ROLE SNOWCAP_ADMIN; GRANT IMPORTED PRIVILEGES ON DATABASE SNOWFLAKE TO ROLE SNOWCAP_ADMIN; ``` +Unlike the account-level privileges above, `CREATE SEMANTIC VIEW` is schema-scoped — grant it per schema where your configuration manages semantic view grants: + +```sql +GRANT CREATE SEMANTIC VIEW ON SCHEMA somedb.someschema TO ROLE SNOWCAP_ADMIN; +``` + See [Optimizing Grant Fetching with ACCOUNT_USAGE](getting-started.md#optimizing-grant-fetching-with-account_usage) for when the ACCOUNT_USAGE optimization is worth enabling. ### Step 3: Create the service user diff --git a/mkdocs.yml b/mkdocs.yml index 446e21e..f9f9d50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -117,6 +117,7 @@ nav: - User: resources/user.md - AI: - CortexSearchService: resources/cortex_search_service.md + - SemanticView: resources/semantic_view.md - Account & Monitoring: - AccountParameter: resources/account_parameter.md - EventTable: resources/event_table.md diff --git a/snowcap/enums.py b/snowcap/enums.py index 3b12b9b..4d19892 100644 --- a/snowcap/enums.py +++ b/snowcap/enums.py @@ -95,6 +95,7 @@ class ResourceType(ParseableEnum): SCHEMA = "SCHEMA" SECRET = "SECRET" SECURITY_INTEGRATION = "SECURITY INTEGRATION" + SEMANTIC_VIEW = "SEMANTIC VIEW" SEQUENCE = "SEQUENCE" SERVICE = "SERVICE" SHARE = "SHARE" diff --git a/snowcap/privs.py b/snowcap/privs.py index 4d6ca8c..211ae46 100644 --- a/snowcap/privs.py +++ b/snowcap/privs.py @@ -261,6 +261,7 @@ class SchemaPriv(Priv): CREATE_RESOURCE_GROUP = "CREATE RESOURCE GROUP" CREATE_ROW_ACCESS_POLICY = "CREATE ROW ACCESS POLICY" CREATE_SECRET = "CREATE SECRET" + CREATE_SEMANTIC_VIEW = "CREATE SEMANTIC VIEW" CREATE_SEQUENCE = "CREATE SEQUENCE" CREATE_SERVICE = "CREATE SERVICE" CREATE_SERVICE_CLASS = "CREATE SERVICE CLASS" @@ -286,6 +287,14 @@ class SecretPriv(Priv): USAGE = "USAGE" +class SemanticViewPriv(Priv): + ALL = "ALL" + MONITOR = "MONITOR" + OWNERSHIP = "OWNERSHIP" + REFERENCES = "REFERENCES" + SELECT = "SELECT" + + class GitRepositoryPriv(Priv): OWNERSHIP = "OWNERSHIP" READ = "READ" @@ -427,6 +436,7 @@ class WarehousePriv(Priv): ResourceType.SCHEMA: SchemaPriv, ResourceType.SECRET: SecretPriv, ResourceType.SECURITY_INTEGRATION: IntegrationPriv, + ResourceType.SEMANTIC_VIEW: SemanticViewPriv, ResourceType.SEQUENCE: SequencePriv, ResourceType.SERVICE: None, ResourceType.SHARE: None, diff --git a/snowcap/resources/resource.py b/snowcap/resources/resource.py index e36f3e7..94cf772 100644 --- a/snowcap/resources/resource.py +++ b/snowcap/resources/resource.py @@ -310,6 +310,13 @@ 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(), + # SEMANTIC VIEW — Snowflake semantic layer object, schema-scoped. + # No concrete resource class yet (CREATE SEMANTIC VIEW involves tables, + # relationships, facts, and metrics that warrant its own PR). + # Registering a SchemaScope here lets users write + # `priv: SELECT on semantic view ..` in YAML to + # manage access to semantic views they create out-of-band (e.g. via dbt or DDL). + ResourceType.SEMANTIC_VIEW: SchemaScope(), } diff --git a/tests/fixtures/sql/grant.sql b/tests/fixtures/sql/grant.sql index c01c39e..a8705fa 100644 --- a/tests/fixtures/sql/grant.sql +++ b/tests/fixtures/sql/grant.sql @@ -19,5 +19,6 @@ GRANT CREATE MATERIALIZED VIEW ON SCHEMA mydb.myschema TO ROLE myrole; GRANT ALL PRIVILEGES ON FUNCTION mydb.myschema.add5(number) TO ROLE analyst; GRANT ALL PRIVILEGES ON FUNCTION mydb.myschema.add5(string) TO ROLE analyst; GRANT USAGE ON PROCEDURE mydb.myschema.myprocedure(number) TO ROLE analyst; +GRANT SELECT ON SEMANTIC VIEW mydb.myschema.mysemanticview TO ROLE analyst; diff --git a/tests/integration/test_grant_patterns.py b/tests/integration/test_grant_patterns.py index 46ab215..3ed4f58 100644 --- a/tests/integration/test_grant_patterns.py +++ b/tests/integration/test_grant_patterns.py @@ -9,11 +9,12 @@ import os import pytest +import snowflake.connector.errors from tests.helpers import safe_fetch from snowcap import resources as res from snowcap.blueprint import Blueprint, CreateResource -from snowcap.client import reset_cache +from snowcap.client import FEATURE_NOT_ENABLED_ERR, UNSUPPORTED_FEATURE, reset_cache from snowcap.gitops import collect_blueprint_config TEST_ROLE = os.environ.get("TEST_SNOWFLAKE_ROLE") @@ -327,6 +328,82 @@ def test_role_grant_multiple_roles_to_single_role(self, cursor, suffix, marked_f assert grant_data is not None, f"Role grant {source_name} -> {target_name} was not created" +class TestSemanticViewGrantsIntegration: + """Grant fetch round-trip for SEMANTIC VIEW. + + SHOW GRANTS returns granted_on='SEMANTIC_VIEW' for a live semantic-view + grant; whether that string maps to ResourceType.SEMANTIC_VIEW (via + fetch_role_privileges' granted_on.replace('_', ' ') normalization) can + only be verified against a real account. This test proves that mapping, + plus that ALL/FUTURE SEMANTIC VIEWS IN SCHEMA grants execute successfully. + """ + + def test_semantic_view_grant_fetch_round_trip(self, cursor, suffix, test_db, marked_for_cleanup): + """Test: SELECT + FUTURE SEMANTIC VIEWS IN SCHEMA grants fetch back with no drift.""" + session = cursor.connection + + schema_name = f"SV_GRANT_SCHEMA_{suffix}" + schema = res.Schema(name=f"{test_db}.{schema_name}") + cursor.execute(schema.create_sql(if_not_exists=True)) + marked_for_cleanup.append(schema) + + table_fqn = f"{test_db}.{schema_name}.SV_SOURCE_TABLE_{suffix}" + cursor.execute(f"CREATE TABLE {table_fqn} (id INT, name STRING)") + + sv_fqn = f"{test_db}.{schema_name}.SV_{suffix}" + try: + cursor.execute(f"CREATE SEMANTIC VIEW {sv_fqn} TABLES (t AS {table_fqn} PRIMARY KEY (id))") + except snowflake.connector.errors.ProgrammingError as err: + if err.errno in (UNSUPPORTED_FEATURE, FEATURE_NOT_ENABLED_ERR): + pytest.skip("SEMANTIC VIEW is not supported on this account") + raise + + # Create a role to receive grants + role_name = f"SV_GRANT_ROLE_{suffix}" + role = res.Role(name=role_name) + cursor.execute(role.create_sql(if_not_exists=True)) + marked_for_cleanup.append(role) + + # Grant SELECT on the specific semantic view, plus a FUTURE grant on the schema + yaml_config = { + "grants": [ + { + "priv": "SELECT", + "on_semantic_view": sv_fqn, + "to_role": role_name, + }, + { + "priv": "SELECT", + "on": f"FUTURE SEMANTIC VIEWS IN SCHEMA {test_db}.{schema_name}", + "to_role": role_name, + }, + ], + } + + bc = collect_blueprint_config(yaml_config) + blueprint = Blueprint.from_config(bc) + + plan = blueprint.plan(session) + assert len(plan) == 2 + assert all(isinstance(p, CreateResource) for p in plan) + + blueprint.apply(session, plan) + + # Verify the SELECT ON SEMANTIC VIEW grant fetches back and maps to ResourceType.SEMANTIC_VIEW + select_grant = res.Grant(priv="SELECT", on_semantic_view=sv_fqn, to=role) + assert select_grant.urn.resource_type.value == "SEMANTIC VIEW" + fetched = safe_fetch(cursor, select_grant.urn) + assert fetched is not None, "SELECT ON SEMANTIC VIEW grant was not created" + assert fetched["priv"] == "SELECT" + + # Re-plan should show zero drift for both grants + reset_cache() + bc2 = collect_blueprint_config(yaml_config) + blueprint2 = Blueprint.from_config(bc2) + plan2 = blueprint2.plan(session) + assert len(plan2) == 0, f"Expected no drift but got: {plan2}" + + class TestGrantCleanupIntegration: """Test that grant cleanup works correctly.""" diff --git a/tests/test_grant.py b/tests/test_grant.py index a32961b..eb33a1e 100644 --- a/tests/test_grant.py +++ b/tests/test_grant.py @@ -174,6 +174,39 @@ def test_grant_on_cortex_search_service(): assert "MONITOR ON CORTEX SEARCH SERVICE" in monitor_grant.create_sql() +def test_grant_on_semantic_view(): + """SELECT/REFERENCES on a SEMANTIC VIEW parses and renders correctly. + + Semantic Views are schema-scoped objects. Grants like + GRANT SELECT ON SEMANTIC VIEW .. TO ROLE r + let consuming roles query the semantic view; REFERENCES lets roles + reference it from other objects. + """ + grant = res.Grant( + priv="SELECT", + on_semantic_view="somedb.someschema.somesv", + to="somerole", + ) + assert grant._data.on == "SOMEDB.SOMESCHEMA.SOMESV" + assert grant._data.on_type == ResourceType.SEMANTIC_VIEW + assert "SELECT ON SEMANTIC VIEW" in grant.create_sql() + + references_grant = res.Grant( + priv="REFERENCES", + on_semantic_view="somedb.someschema.somesv", + to="somerole", + ) + assert references_grant._data.on_type == ResourceType.SEMANTIC_VIEW + assert "REFERENCES ON SEMANTIC VIEW" in references_grant.create_sql() + + +def test_grant_create_semantic_view_on_schema(): + """CREATE SEMANTIC VIEW is a schema privilege granted like other CREATE privs.""" + grant = res.Grant(priv="CREATE SEMANTIC VIEW", on_schema="somedb.someschema", to="somerole") + assert grant.on_type == ResourceType.SCHEMA + assert "GRANT CREATE SEMANTIC VIEW ON SCHEMA" in grant.create_sql() + + def test_grant_database_role_to_database_role(): database = res.Database(name="somedb") parent = res.DatabaseRole(name="parent", database=database) @@ -644,3 +677,40 @@ def test_on_list_of_all_future_grants_still_expands(self): assert grant.rest_of_ons == ["future schemas in database raw_prd"] additional = grant.process_shortcuts() assert len(additional) == 1 + + +class TestSemanticViewBulkGrants: + """ALL/FUTURE SEMANTIC VIEWS IN SCHEMA grants (string and list `on:` forms). + + SEMANTIC VIEW is the first multi-word plural resource type to exercise the + generic ALL/FUTURE parser (grant.py's singularize path) end-to-end; prior + coverage of that path stopped at single-word plurals like TABLES/SCHEMAS. + """ + + def test_all_semantic_views_in_schema_string_form(self): + grant = res.Grant( + priv="SELECT", + on="ALL SEMANTIC VIEWS IN SCHEMA somedb.someschema", + to="somerole", + ) + assert grant.items_type == ResourceType.SEMANTIC_VIEW + assert grant.on_type == ResourceType.SCHEMA + assert grant.grant_type == GrantType.ALL + assert "ON ALL SEMANTIC VIEW" in grant.create_sql() + assert "IN SCHEMA SOMEDB.SOMESCHEMA" in grant.create_sql() + + def test_future_semantic_views_in_schema_list_form(self): + grant = res.Grant( + priv="SELECT", + on=["FUTURE", "SEMANTIC VIEWS", "SCHEMA", "somedb.someschema"], + to="somerole", + ) + assert grant.items_type == ResourceType.SEMANTIC_VIEW + assert grant.on_type == ResourceType.SCHEMA + assert grant.grant_type == GrantType.FUTURE + assert "ON FUTURE SEMANTIC VIEWS IN SCHEMA SOMEDB.SOMESCHEMA" in grant.create_sql() + + def test_on_all_semantic_views_in_schema_kwarg_raises(self): + """on_all_* kwargs are rejected by design; only on=[...]/on="..." forms are supported.""" + with pytest.raises(ValueError): + res.Grant(priv="SELECT", on_all_semantic_views_in_schema="somedb.someschema", to="somerole") diff --git a/tests/test_privs.py b/tests/test_privs.py index 6450721..1f13ef0 100644 --- a/tests/test_privs.py +++ b/tests/test_privs.py @@ -30,6 +30,7 @@ RolePriv, SchemaPriv, SecretPriv, + SemanticViewPriv, SequencePriv, StagePriv, StreamPriv, @@ -46,6 +47,7 @@ system_role_for_priv, ) from snowcap.enums import ResourceType +from snowcap.identifiers import resource_label_for_type, resource_type_for_label # Pseudo-resources and meta-resources that don't have associated privileges @@ -109,6 +111,7 @@ def test_priv_subclasses(self): RolePriv, SchemaPriv, SecretPriv, + SemanticViewPriv, SequencePriv, StagePriv, StreamPriv, @@ -231,6 +234,10 @@ def test_create_function_privilege(self): """SchemaPriv has CREATE FUNCTION privilege.""" assert SchemaPriv.CREATE_FUNCTION.value == "CREATE FUNCTION" + def test_create_semantic_view_privilege(self): + """SchemaPriv has CREATE SEMANTIC VIEW privilege.""" + assert SchemaPriv.CREATE_SEMANTIC_VIEW.value == "CREATE SEMANTIC VIEW" + def test_schema_priv_count(self): """SchemaPriv has expected number of privileges.""" # Should have at least 30 schema-level privileges @@ -347,6 +354,35 @@ def test_ownership_privilege(self): assert CortexSearchServicePriv.OWNERSHIP.value == "OWNERSHIP" +############################################################################# +# SemanticViewPriv Tests +############################################################################# + + +class TestSemanticViewPriv: + """Tests for SemanticViewPriv enum values (Snowflake Semantic Views).""" + + def test_all_privilege(self): + assert SemanticViewPriv.ALL.value == "ALL" + + def test_monitor_privilege(self): + assert SemanticViewPriv.MONITOR.value == "MONITOR" + + def test_ownership_privilege(self): + assert SemanticViewPriv.OWNERSHIP.value == "OWNERSHIP" + + def test_references_privilege(self): + assert SemanticViewPriv.REFERENCES.value == "REFERENCES" + + def test_select_privilege(self): + assert SemanticViewPriv.SELECT.value == "SELECT" + + def test_all_privs_for_resource_type(self): + """all_privs_for_resource_type() returns the SemanticViewPriv members (excluding ALL/OWNERSHIP).""" + privs = all_privs_for_resource_type(ResourceType.SEMANTIC_VIEW) + assert set(privs) == {"MONITOR", "REFERENCES", "SELECT"} + + ############################################################################# # is_ownership_priv() Tests ############################################################################# @@ -511,6 +547,14 @@ def test_from_grant_with_schema(self): assert gp.privilege == SchemaPriv.USAGE assert gp.on == "MY_SCHEMA" + def test_from_grant_with_semantic_view(self): + """GrantedPrivilege.from_grant works with the space-normalized granted_on + SHOW GRANTS produces for semantic views (fetch_role_privileges does + granted_on.replace('_', ' '), turning 'SEMANTIC_VIEW' into 'SEMANTIC VIEW').""" + gp = GrantedPrivilege.from_grant(privilege="SELECT", granted_on="SEMANTIC VIEW", name="MY_SV") + assert gp.privilege == SemanticViewPriv.SELECT + assert gp.on == "MY_SV" + def test_from_grant_with_no_priv_type(self): """GrantedPrivilege.from_grant returns string privilege for resource types without privilege enums.""" # GRANT resource type has no associated privilege enum @@ -577,6 +621,10 @@ def test_integration_types_map_to_integration_priv(self): assert PRIVS_FOR_RESOURCE_TYPE[ResourceType.NOTIFICATION_INTEGRATION] == IntegrationPriv assert PRIVS_FOR_RESOURCE_TYPE[ResourceType.SECURITY_INTEGRATION] == IntegrationPriv + def test_semantic_view_maps_to_semantic_view_priv(self): + """SEMANTIC_VIEW resource type maps to SemanticViewPriv.""" + assert PRIVS_FOR_RESOURCE_TYPE[ResourceType.SEMANTIC_VIEW] is SemanticViewPriv + ############################################################################# # CREATE_PRIV_FOR_RESOURCE_TYPE Mapping Tests @@ -749,3 +797,12 @@ def test_user_priv_values(self): assert UserPriv.ALL.value == "ALL" assert UserPriv.MONITOR.value == "MONITOR" assert UserPriv.OWNERSHIP.value == "OWNERSHIP" + + +def test_semantic_view_resource_type_label_roundtrip(): + """ResourceType.SEMANTIC_VIEW round-trips through both the enum value and the YAML label.""" + assert ResourceType("SEMANTIC VIEW") == ResourceType.SEMANTIC_VIEW + + resource_type = resource_type_for_label("semantic_view") + assert resource_type == ResourceType.SEMANTIC_VIEW + assert resource_label_for_type(resource_type) == "semantic_view" From 09530f341dce77534fabb2bebb6a6f2a7721b38f Mon Sep 17 00:00:00 2001 From: Gregory Clunies Date: Thu, 16 Jul 2026 23:14:15 -0700 Subject: [PATCH 2/2] fix(grants): render valid REVOKE for ALL-grants drop_grant's GrantType.ALL branch omitted the mandatory FROM clause and passed items_type singular, producing invalid SQL like 'REVOKE SELECT ON ALL TABLE IN SCHEMA db.schema' for every resource type. Removing any 'on: all ...' grant from YAML would fail at apply time. Mirror the FUTURE branch: pluralize+uppercase the object type and append FROM . Strengthen test_revoke_all_grant from a substring check (which hid the bug) to full-statement equality. --- snowcap/lifecycle.py | 5 ++++- tests/test_lifecycle.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/snowcap/lifecycle.py b/snowcap/lifecycle.py index 52c998b..80d380b 100644 --- a/snowcap/lifecycle.py +++ b/snowcap/lifecycle.py @@ -595,10 +595,13 @@ def drop_grant(urn: URN, data: dict, **kwargs): "REVOKE", data["priv"], "ON ALL", - data["items_type"], + pluralize(data["items_type"]).upper(), "IN", data["on_type"], data["on"], + "FROM", + data["to_type"], + data["to"], ) else: return tidy_sql( diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index ae84cb8..25dd0f6 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -1139,10 +1139,11 @@ def test_revoke_all_grant(self): "on": "MY_DB.MY_SCHEMA", "items_type": "TABLE", "to": "MY_ROLE", + "to_type": "ROLE", "grant_type": GrantType.ALL, } result = drop_grant(urn, data) - assert "REVOKE SELECT ON ALL TABLE" in result + assert result == "REVOKE SELECT ON ALL TABLES IN SCHEMA MY_DB.MY_SCHEMA FROM ROLE MY_ROLE" def test_revoke_future_grant_from_database_role(self): """Test revoking future grant from database role."""