diff --git a/docs/resources/cortex_search_service.md b/docs/resources/cortex_search_service.md new file mode 100644 index 0000000..3dcc2e9 --- /dev/null +++ b/docs/resources/cortex_search_service.md @@ -0,0 +1,129 @@ +--- +description: >- + Grant USAGE / MONITOR on a Snowflake Cortex Search Service. +--- + +# CortexSearchService + +[Snowflake Documentation](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview) | Snowcap CLI label: `cortex_search_service` + +A Cortex Search Service is a schema-scoped Snowflake AI service that exposes +semantic + lexical search over a base table or view. Snowcap supports granting +access to existing services declaratively. The service itself (the +`CREATE CORTEX SEARCH SERVICE ... AS ` body, embedding model, +`target_lag`, attribute set, etc.) is **not** modeled as a concrete resource — +create it via DDL or dbt, then manage who can call it through `grants:`. + +## Examples + +### YAML + +```yaml +grants: + # Required to call SNOWFLAKE.CORTEX.SEARCH_PREVIEW() against the service. + - priv: USAGE + on: cortex search service somedb.someschema.transcript_search + to: customer_support + + # Required for get_ai_observability_events() / Cortex Search request logs. + - priv: MONITOR + on: cortex search service somedb.someschema.transcript_search + to: search_observability_role + + # Schema-scope privilege to allow a role to create new services. + - priv: CREATE CORTEX SEARCH SERVICE + on: schema somedb.someschema + to: search_author_role +``` + +### Python + +```python +# Grant USAGE +grant = Grant( + priv="USAGE", + on_cortex_search_service="somedb.someschema.transcript_search", + to="customer_support", +) + +# Grant MONITOR +grant = Grant( + priv="MONITOR", + on_cortex_search_service="somedb.someschema.transcript_search", + to="search_observability_role", +) +``` + +## Privileges + +| Privilege | Purpose | +|-------------|-------------------------------------------------------------------------| +| `USAGE` | Call `SNOWFLAKE.CORTEX.SEARCH_PREVIEW(...)` against the service. | +| `MONITOR` | Read 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 CORTEX SEARCH SERVICE` is part of +[Grant](grant.md) under [SchemaPriv] — see the schema-privileges example +above. + +## Minimal example: full Cortex access for a developer role + +A common goal is "let this role use Cortex Code in Snowsight, call Cortex AI +SQL functions, and query our Cortex Search Service." Three pieces stack +together: + +```yaml +# 1. Account-level privilege for Cortex AI SQL (AI_COMPLETE, AI_FILTER, +# SUMMARIZE, embeddings, etc.). Granted to PUBLIC by default — declare it +# explicitly so access survives a future PUBLIC revoke. +grants: + - priv: USE AI FUNCTIONS + on: ACCOUNT + to: dbt_developer + + # 2. (Optional) USAGE on the search service itself + - priv: USAGE + on: cortex search service db_dev.cortex.faq_search + to: dbt_developer + +# 3. Database-role grants on the SNOWFLAKE shared database. COPILOT_USER is +# required for the Cortex Code pane in Snowsight. CORTEX_USER (or +# CORTEX_AGENT_USER) is required for Cortex AI SQL functions and Cortex +# Code's underlying calls. +database_role_grants: + - database_role: SNOWFLAKE.COPILOT_USER + roles: + - dbt_developer + - database_role: SNOWFLAKE.CORTEX_USER + roles: + - dbt_developer +``` + +### Gotchas + +- `SNOWFLAKE.CORTEX_USER` is granted to `PUBLIC` by default, so a role + inherits it transitively unless your account has revoked that default. + `SNOWFLAKE.COPILOT_USER` is **not** granted to `PUBLIC` — without an + explicit grant the Cortex Code pane is hidden in Snowsight. +- Declaring a `database_role_grants` entry for a role that is already + granted to another grantee (e.g. `ACCOUNTADMIN`) requires snowcap ≥ the + release containing the multi-grantee fetch fix. Earlier versions emit a + spurious `UpdateResource(to_role: ACCOUNTADMIN → )` diff instead of + a clean create. +- `USE AI FUNCTIONS ON ACCOUNT` is the account privilege, separate from + the `SNOWFLAKE.CORTEX_USER` database role. Both are typically required + for Cortex AI SQL calls; missing either produces a + "Function requires X privilege" error at runtime. +- Querying a search service also requires `USAGE` on its parent database + and schema. If those are absent the call fails before reaching the + service-level USAGE check. + +## See also + +- [Snowflake — Cortex Search overview](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-overview) +- [Snowflake — Cortex Code access control](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code-snowsight#access-control-requirements) +- [Snowflake — Cortex AI SQL required privileges](https://docs.snowflake.com/en/user-guide/snowflake-cortex/aisql#required-privileges) +- [Snowflake — Cortex Search Monitor / logs](https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-search/cortex-search-monitor) +- [DatabaseRole](database_role.md) — for granting `SNOWFLAKE.*` database roles +- [Grant](grant.md) — for the underlying grant resource and YAML schema diff --git a/docs/resources/grant.md b/docs/resources/grant.md index 34215db..81dad6d 100644 --- a/docs/resources/grant.md +++ b/docs/resources/grant.md @@ -44,6 +44,21 @@ grants: - priv: USAGE on: warehouse some_warehouse to: some_role + + # AI: account-level privilege for Cortex AI SQL functions + - priv: USE AI FUNCTIONS + on: ACCOUNT + to: cortex_user_role + + # AI: USAGE on a Cortex Search Service to call SNOWFLAKE.CORTEX.SEARCH_PREVIEW + - priv: USAGE + on: cortex search service somedb.someschema.someservice + to: search_consumer_role + + # AI: MONITOR on a Cortex Search Service to query observability logs + - priv: MONITOR + on: cortex search service somedb.someschema.someservice + to: search_observability_role ``` #### Future Grants diff --git a/mkdocs.yml b/mkdocs.yml index 30170bb..888a98d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -115,6 +115,8 @@ nav: - Role: resources/role.md - RoleGrant: resources/role_grant.md - User: resources/user.md + - AI: + - CortexSearchService: resources/cortex_search_service.md - Account & Monitoring: - AccountParameter: resources/account_parameter.md - EventTable: resources/event_table.md diff --git a/snowcap/data_provider.py b/snowcap/data_provider.py index 3435269..a14a596 100644 --- a/snowcap/data_provider.py +++ b/snowcap/data_provider.py @@ -1542,7 +1542,7 @@ def fetch_database_role_grant(session: SnowflakeConnection, fqn: FQN): if len(role_grants) > 1: raise Exception(f"Found multiple database role grants matching {fqn}") - data = show_result[0] + data = role_grants[0] to_role = None to_database_role = None @@ -3483,7 +3483,7 @@ def get_database_role_name_set() -> set[str]: continue # Skip undocumented privs - if data["privilege"] in ["CREATE CORTEX SEARCH SERVICE", "CANCEL QUERY"]: + if data["privilege"] in ["CANCEL QUERY"]: continue name = data["name"] @@ -3520,7 +3520,7 @@ def get_database_role_name_set() -> set[str]: continue # Skip undocumented privs - if data["privilege"] in ["CREATE CORTEX SEARCH SERVICE", "CANCEL QUERY"]: + if data["privilege"] in ["CANCEL QUERY"]: continue name = data["name"] @@ -3560,7 +3560,7 @@ def get_database_role_name_set() -> set[str]: continue # Skip undocumented privs - if data["privilege"] in ["CREATE CORTEX SEARCH SERVICE", "CANCEL QUERY"]: + if data["privilege"] in ["CANCEL QUERY"]: continue name = data["name"] diff --git a/snowcap/enums.py b/snowcap/enums.py index 3851194..3b12b9b 100644 --- a/snowcap/enums.py +++ b/snowcap/enums.py @@ -55,6 +55,7 @@ class ResourceType(ParseableEnum): CLASS = "CLASS" COLUMN = "COLUMN" COMPUTE_POOL = "COMPUTE POOL" + CORTEX_SEARCH_SERVICE = "CORTEX SEARCH SERVICE" DATABASE = "DATABASE" DATABASE_ROLE = "DATABASE ROLE" DATABASE_ROLE_GRANT = "DATABASE ROLE GRANT" diff --git a/snowcap/privs.py b/snowcap/privs.py index 7bf8b5b..4d6ca8c 100644 --- a/snowcap/privs.py +++ b/snowcap/privs.py @@ -84,6 +84,7 @@ class AccountPriv(Priv): OVERRIDE_SHARE_RESTRICTIONS = "OVERRIDE SHARE RESTRICTIONS" PURCHASE_DATA_EXCHANGE_LISTING = "PURCHASE DATA EXCHANGE LISTING" RESOLVE_ALL = "RESOLVE ALL" + USE_AI_FUNCTIONS = "USE AI FUNCTIONS" class AlertPriv(Priv): @@ -111,6 +112,13 @@ class DatabaseRolePriv(Priv): USAGE = "USAGE" +class CortexSearchServicePriv(Priv): + ALL = "ALL" + MONITOR = "MONITOR" + OWNERSHIP = "OWNERSHIP" + USAGE = "USAGE" + + class DirectoryTablePriv(Priv): OWNERSHIP = "OWNERSHIP" @@ -384,6 +392,7 @@ class WarehousePriv(Priv): ResourceType.CLASS: None, ResourceType.COLUMN: None, ResourceType.COMPUTE_POOL: None, + ResourceType.CORTEX_SEARCH_SERVICE: CortexSearchServicePriv, ResourceType.DATABASE_ROLE: DatabaseRolePriv, ResourceType.DATABASE: DatabasePriv, ResourceType.DIRECTORY_TABLE: DirectoryTablePriv, diff --git a/snowcap/resources/resource.py b/snowcap/resources/resource.py index 033277d..b27770f 100644 --- a/snowcap/resources/resource.py +++ b/snowcap/resources/resource.py @@ -287,6 +287,13 @@ def get_metadata(cls, field_name: str) -> ResourceSpecMetadata: # management still goes through the specific subtypes (APIIntegration, # CatalogIntegration, etc.). ResourceType.INTEGRATION: AccountScope(), + # CORTEX SEARCH SERVICE — Snowflake AI search service, schema-scoped. + # No concrete resource class yet (CREATE CORTEX SEARCH SERVICE involves an + # AS body and embedding model config that warrants its own PR). + # Registering a SchemaScope here lets users write + # `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(), } diff --git a/tests/integration/data_provider/test_fetch_resource.py b/tests/integration/data_provider/test_fetch_resource.py index a1ef2ea..7199d2a 100644 --- a/tests/integration/data_provider/test_fetch_resource.py +++ b/tests/integration/data_provider/test_fetch_resource.py @@ -663,3 +663,45 @@ def test_fetch_grant_of_database_role(cursor, suffix, marked_for_cleanup): assert result == data +def test_fetch_grant_of_database_role_multiple_grantees(cursor, suffix, marked_for_cleanup): + """Regression: a database role granted to multiple roles must fetch each grantee's + row independently. Prior to the fix in fetch_database_role_grant, every fetch + returned show_result[0] (the first SHOW GRANTS row) regardless of the URN's + requested grantee, so adding a second grantee in YAML produced a spurious + UPDATE diff (to_role: -> ) instead of CREATE. + """ + db_role = res.DatabaseRole( + name=f"TEST_FETCH_DRG_MULTI_{suffix}", + database="STATIC_DATABASE", + owner=TEST_ROLE, + ) + create(cursor, db_role) + marked_for_cleanup.append(db_role) + + role_a = res.Role(name=f"TEST_FETCH_DRG_MULTI_A_{suffix}", owner=TEST_ROLE) + role_b = res.Role(name=f"TEST_FETCH_DRG_MULTI_B_{suffix}", owner=TEST_ROLE) + create(cursor, role_a) + create(cursor, role_b) + marked_for_cleanup.append(role_a) + marked_for_cleanup.append(role_b) + + grant_a = res.DatabaseRoleGrant(database_role=db_role, to_role=role_a) + grant_b = res.DatabaseRoleGrant(database_role=db_role, to_role=role_b) + create(cursor, grant_a) + create(cursor, grant_b) + + result_a = safe_fetch(cursor, grant_a.urn) + result_b = safe_fetch(cursor, grant_b.urn) + + assert result_a is not None + assert result_b is not None + # Each fetch must return its own grantee, not the first row from SHOW GRANTS. + assert result_a != result_b + assert clean_resource_data(res.DatabaseRoleGrant.spec, result_a) == clean_resource_data( + res.DatabaseRoleGrant.spec, grant_a.to_dict() + ) + assert clean_resource_data(res.DatabaseRoleGrant.spec, result_b) == clean_resource_data( + res.DatabaseRoleGrant.spec, grant_b.to_dict() + ) + + diff --git a/tests/test_grant.py b/tests/test_grant.py index fa71c65..a32961b 100644 --- a/tests/test_grant.py +++ b/tests/test_grant.py @@ -148,6 +148,32 @@ def test_grant_on_dynamic_tables(): assert grant._data.on_type == ResourceType.DYNAMIC_TABLE +def test_grant_on_cortex_search_service(): + """USAGE/MONITOR on a CORTEX SEARCH SERVICE parses and renders correctly. + + Cortex Search is a schema-scoped service. Grants like + GRANT USAGE ON CORTEX SEARCH SERVICE .. TO ROLE r + let consuming roles query the service via SNOWFLAKE.CORTEX.SEARCH_PREVIEW; + MONITOR exposes get_ai_observability_events logs. + """ + grant = res.Grant( + priv="USAGE", + on_cortex_search_service="somedb.someschema.someservice", + to="somerole", + ) + assert grant._data.on == "SOMEDB.SOMESCHEMA.SOMESERVICE" + assert grant._data.on_type == ResourceType.CORTEX_SEARCH_SERVICE + assert "USAGE ON CORTEX SEARCH SERVICE" in grant.create_sql() + + monitor_grant = res.Grant( + priv="MONITOR", + on_cortex_search_service="somedb.someschema.someservice", + to="somerole", + ) + assert monitor_grant._data.on_type == ResourceType.CORTEX_SEARCH_SERVICE + assert "MONITOR ON CORTEX SEARCH SERVICE" 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 e12da12..6450721 100644 --- a/tests/test_privs.py +++ b/tests/test_privs.py @@ -7,6 +7,7 @@ Priv, AccountPriv, AlertPriv, + CortexSearchServicePriv, DatabasePriv, DatabaseRolePriv, DirectoryTablePriv, @@ -159,6 +160,10 @@ def test_apply_masking_policy_privilege(self): """AccountPriv has APPLY MASKING POLICY privilege.""" assert AccountPriv.APPLY_MASKING_POLICY.value == "APPLY MASKING POLICY" + def test_use_ai_functions_privilege(self): + """AccountPriv has USE AI FUNCTIONS privilege (required for Cortex AI SQL functions).""" + assert AccountPriv.USE_AI_FUNCTIONS.value == "USE AI FUNCTIONS" + def test_account_priv_count(self): """AccountPriv has expected number of privileges.""" # Should have at least 40 account-level privileges @@ -319,6 +324,29 @@ def test_usage_privilege(self): assert StagePriv.USAGE.value == "USAGE" +############################################################################# +# CortexSearchServicePriv Tests +############################################################################# + + +class TestCortexSearchServicePriv: + """Tests for CortexSearchServicePriv enum values (Snowflake Cortex Search).""" + + def test_all_privilege(self): + assert CortexSearchServicePriv.ALL.value == "ALL" + + def test_usage_privilege(self): + """USAGE is the priv needed to query a Cortex Search Service.""" + assert CortexSearchServicePriv.USAGE.value == "USAGE" + + def test_monitor_privilege(self): + """MONITOR is the priv needed for AI observability / request logs.""" + assert CortexSearchServicePriv.MONITOR.value == "MONITOR" + + def test_ownership_privilege(self): + assert CortexSearchServicePriv.OWNERSHIP.value == "OWNERSHIP" + + ############################################################################# # is_ownership_priv() Tests #############################################################################