Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions docs/resources/cortex_search_service.md
Original file line number Diff line number Diff line change
@@ -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 <query>` 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 → <new>)` 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
15 changes: 15 additions & 0 deletions docs/resources/grant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions snowcap/data_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"]
Expand Down
1 change: 1 addition & 0 deletions snowcap/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions snowcap/privs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions snowcap/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <query> body and embedding model config that warrants its own PR).
# Registering a SchemaScope here lets users write
# `priv: USAGE on cortex search service <db>.<schema>.<name>` in YAML to
# manage access to services they create out-of-band (e.g. via dbt or DDL).
ResourceType.CORTEX_SEARCH_SERVICE: SchemaScope(),
}


Expand Down
42 changes: 42 additions & 0 deletions tests/integration/data_provider/test_fetch_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <first grantee> -> <new grantee>) 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()
)


26 changes: 26 additions & 0 deletions tests/test_grant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <db>.<schema>.<svc> 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)
Expand Down
28 changes: 28 additions & 0 deletions tests/test_privs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Priv,
AccountPriv,
AlertPriv,
CortexSearchServicePriv,
DatabasePriv,
DatabaseRolePriv,
DirectoryTablePriv,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
#############################################################################
Expand Down
Loading