Skip to content
Open
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
5 changes: 5 additions & 0 deletions docs/resources/grant.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ grants:
on: warehouse some_warehouse
to: some_role

# Apps: USAGE on a Streamlit app so a role can open and run it
- priv: USAGE
on: streamlit somedb.someschema.some_streamlit
to: app_viewer_role

# AI: account-level privilege for Cortex AI SQL functions
- priv: USE AI FUNCTIONS
on: ACCOUNT
Expand Down
40 changes: 39 additions & 1 deletion docs/resources/streamlit.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,42 @@ streamlit_repo = Streamlit(
* `query_warehouse` (string) - The name of the warehouse to use for queries in the app.
* `comment` (string) - A comment or description for the Streamlit app.
* `owner` (string or Role) - The role that owns the Streamlit app. Defaults to "SYSADMIN".
* `tags` (dict) - A dictionary of tags to associate with the Streamlit app.
* `tags` (dict) - A dictionary of tags to associate with the Streamlit app.

## Granting access

A Streamlit app is a schema-scoped object. Grant `USAGE` on the app to let a
role open and run it — this is the whole access story for viewers when the app
uses owner's rights (all app queries execute as the app owner, so viewers need
no privileges on the underlying tables):

```yaml
grants:
# Let a viewer role open and run the app.
- priv: USAGE
on: streamlit my_db.my_schema.my_streamlit
to: app_viewer_role

# Schema-scope privilege to allow a role to create Streamlit apps.
- priv: CREATE STREAMLIT
on: schema my_db.my_schema
to: app_developer_role
```

```python
# Grant USAGE so a role can open the app
grant = Grant(
priv="USAGE",
on_streamlit="my_db.my_schema.my_streamlit",
to="app_viewer_role",
)
```

| Privilege | Purpose |
|-------------------|--------------------------------------------------------------------------|
| `USAGE` | Open, view, and run the Streamlit app (and `DESCRIBE` it). |
| `OWNERSHIP` | Full control. Set at create/deploy time — Snowflake does not support transferring streamlit ownership via `GRANT OWNERSHIP`. |
| `ALL` | All privileges above. |

The schema-scope privilege `CREATE STREAMLIT` lets a role create apps in that
schema; creating an app with a `ROOT_LOCATION` stage also needs `CREATE STAGE`.
31 changes: 31 additions & 0 deletions snowcap/data_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2773,6 +2773,37 @@ def fetch_stream(session: SnowflakeConnection, fqn: FQN):
raise NotImplementedError(f"Unsupported stream source type {data['source_type']}")


def fetch_streamlit(session: SnowflakeConnection, fqn: FQN):
streamlits = _show_resources(session, "STREAMLITS", fqn)
if len(streamlits) == 0:
return None
if len(streamlits) > 1:
raise Exception(f"Found multiple streamlits matching {fqn}")

data = streamlits[0]
desc_result = execute(session, f"DESC STREAMLIT {fqn}", cacheable=True)
properties = desc_result[0]
return {
"name": data["name"],
# from_ is omitted (like fetch_notebook): DESC STREAMLIT returns
# root_location as a fully-qualified, uppercased stage path, which
# never matches the declared from_ (e.g. "@my_stage") and would show
# perpetual drift. The spec marks from_ non-fetchable instead.
# version is only settable for repo-based apps and isn't returned by
# DESC STREAMLIT in a comparable form, so it's always None here —
# repo-based apps (from_="https://...", version="main") may drift on
# version. Known limitation.
"version": None,
# Snowflake's default main_file is streamlit_app.py; map it to None so
# an omitted field doesn't drift (same as fetch_notebook).
"main_file": None if properties.get("main_file") == "streamlit_app.py" else properties.get("main_file"),
"title": properties.get("title") or None,
"query_warehouse": data.get("query_warehouse") or None,
"comment": data.get("comment") or None,
"owner": _get_owner_identifier(data),
}


def fetch_tag(session: SnowflakeConnection, fqn: FQN):
try:
show_result = execute(session, "SHOW TAGS IN ACCOUNT", cacheable=True)
Expand Down
13 changes: 8 additions & 5 deletions snowcap/resources/streamlit.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Optional

from ..enums import ResourceType
Expand All @@ -17,7 +17,10 @@ class _Streamlit(ResourceSpec):
"""

name: ResourceName
from_: str
# DESC STREAMLIT returns root_location as a fully-qualified, uppercased
# stage path that never matches the declared from_ (e.g. "@my_stage"), so
# from_ is not fetchable — YAML is authoritative (same as Notebook).
from_: str = field(default=None, metadata={"fetchable": False})
version: Optional[str] = None
main_file: Optional[str] = None
title: Optional[str] = None
Expand All @@ -27,7 +30,7 @@ class _Streamlit(ResourceSpec):

def __post_init__(self):
super().__post_init__()
if self.from_.startswith("@"):
if self.from_ is not None and self.from_.startswith("@"):
if self.version is not None:
raise ValueError("Version should not be set when the source is a stage")

Expand Down Expand Up @@ -104,10 +107,10 @@ class Streamlit(NamedResource, TaggableResource, Resource):
scope = SchemaScope()
spec = _Streamlit

def init(
def __init__(
self,
name: str,
from_: str,
from_: str = None,
version: Optional[str] = None,
main_file: Optional[str] = None,
title: Optional[str] = None,
Expand Down
18 changes: 18 additions & 0 deletions tests/test_grant.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,24 @@ def test_grant_on_cortex_search_service():
assert "MONITOR ON CORTEX SEARCH SERVICE" in monitor_grant.create_sql()


def test_grant_on_streamlit():
"""USAGE on a STREAMLIT parses and renders correctly.

Streamlit is a schema-scoped app object. Granting USAGE lets a viewer
role open and run the app:
GRANT USAGE ON STREAMLIT <db>.<schema>.<app> TO ROLE r
OWNERSHIP is established at create/deploy time, not transferred via GRANT.
"""
grant = res.Grant(
priv="USAGE",
on_streamlit="somedb.someschema.someapp",
to="somerole",
)
assert grant._data.on == "SOMEDB.SOMESCHEMA.SOMEAPP"
assert grant._data.on_type == ResourceType.STREAMLIT
assert "USAGE ON STREAMLIT" in 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
19 changes: 19 additions & 0 deletions tests/test_privs.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,25 @@ def test_ownership_privilege(self):
assert CortexSearchServicePriv.OWNERSHIP.value == "OWNERSHIP"


#############################################################################
# StreamlitPriv Tests
#############################################################################


class TestStreamlitPriv:
"""Tests for StreamlitPriv enum values (Snowflake Streamlit apps)."""

def test_all_privilege(self):
assert StreamlitPriv.ALL.value == "ALL"

def test_usage_privilege(self):
"""USAGE is the priv needed to open and run a Streamlit app."""
assert StreamlitPriv.USAGE.value == "USAGE"

def test_ownership_privilege(self):
assert StreamlitPriv.OWNERSHIP.value == "OWNERSHIP"


#############################################################################
# is_ownership_priv() Tests
#############################################################################
Expand Down
Loading