diff --git a/docs/resources/grant.md b/docs/resources/grant.md index 81dad6d..25b3c71 100644 --- a/docs/resources/grant.md +++ b/docs/resources/grant.md @@ -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 diff --git a/docs/resources/streamlit.md b/docs/resources/streamlit.md index 799f09a..f657ef6 100644 --- a/docs/resources/streamlit.md +++ b/docs/resources/streamlit.md @@ -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. \ No newline at end of file +* `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`. diff --git a/snowcap/data_provider.py b/snowcap/data_provider.py index f556b0e..0e250e7 100644 --- a/snowcap/data_provider.py +++ b/snowcap/data_provider.py @@ -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) diff --git a/snowcap/resources/streamlit.py b/snowcap/resources/streamlit.py index 1685bc6..bd6416a 100644 --- a/snowcap/resources/streamlit.py +++ b/snowcap/resources/streamlit.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional from ..enums import ResourceType @@ -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 @@ -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") @@ -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, diff --git a/tests/test_grant.py b/tests/test_grant.py index a32961b..e30a4cf 100644 --- a/tests/test_grant.py +++ b/tests/test_grant.py @@ -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 .. 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) diff --git a/tests/test_privs.py b/tests/test_privs.py index 6450721..84ff639 100644 --- a/tests/test_privs.py +++ b/tests/test_privs.py @@ -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 #############################################################################