Skip to content

Commit cf5a6c3

Browse files
authored
release/v0.11.0 (#174) Bump version
* v0.11.0 release --------- Co-authored-by: TJ Murphy <[email protected]>
1 parent 78581a8 commit cf5a6c3

31 files changed

+669
-85
lines changed
File renamed without changes.
File renamed without changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"database_role": "STATIC_DATABASE.STATIC_DATABASE_ROLE",
3+
"to_role": "STATIC_ROLE",
4+
"to_database_role": null
5+
}
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "some_masking_policy",
3+
"args": [
4+
{"name": "val", "data_type": "VARCHAR"}
5+
],
6+
"returns": "VARCHAR(16777216)",
7+
"body": "CASE WHEN current_role() IN ('ANALYST') THEN VAL ELSE '*********' END",
8+
"comment": "Masks email addresses",
9+
"exempt_other_policies": false,
10+
"owner": "SYSADMIN"
11+
}

tests/integration/data_provider/test_fetch_resource.py

+58
Original file line numberDiff line numberDiff line change
@@ -744,3 +744,61 @@ def test_fetch_database_role_grant(cursor, suffix, marked_for_cleanup):
744744
result = clean_resource_data(res.Grant.spec, result)
745745
data = clean_resource_data(res.Grant.spec, grant.to_dict())
746746
assert result == data
747+
748+
749+
def test_fetch_database_role(cursor, suffix, marked_for_cleanup):
750+
role = res.DatabaseRole(
751+
name=f"TEST_FETCH_DATABASE_ROLE_{suffix}",
752+
database="STATIC_DATABASE",
753+
owner=TEST_ROLE,
754+
)
755+
create(cursor, role)
756+
marked_for_cleanup.append(role)
757+
758+
result = safe_fetch(cursor, role.urn)
759+
assert result is not None
760+
result = clean_resource_data(res.DatabaseRole.spec, result)
761+
data = clean_resource_data(res.DatabaseRole.spec, role.to_dict())
762+
assert result == data
763+
764+
765+
def test_fetch_grant_of_database_role(cursor, suffix, marked_for_cleanup):
766+
db_role = res.DatabaseRole(
767+
name=f"TEST_FETCH_GRANT_OF_DATABASE_ROLE_{suffix}",
768+
database="STATIC_DATABASE",
769+
owner=TEST_ROLE,
770+
)
771+
create(cursor, db_role)
772+
marked_for_cleanup.append(db_role)
773+
774+
role = res.Role(name=f"TEST_FETCH_GRANT_OF_DATABASE_ROLE_{suffix}", owner=TEST_ROLE)
775+
create(cursor, role)
776+
marked_for_cleanup.append(role)
777+
778+
grant = res.DatabaseRoleGrant(database_role=db_role, to_role=role)
779+
create(cursor, grant)
780+
781+
result = safe_fetch(cursor, grant.urn)
782+
assert result is not None
783+
result = clean_resource_data(res.DatabaseRoleGrant.spec, result)
784+
data = clean_resource_data(res.DatabaseRoleGrant.spec, grant.to_dict())
785+
assert result == data
786+
787+
788+
def test_fetch_masking_policy(cursor, suffix, marked_for_cleanup):
789+
policy = res.MaskingPolicy(
790+
name=f"TEST_FETCH_MASKING_POLICY_{suffix}",
791+
args=[{"name": "val", "data_type": "STRING"}],
792+
returns="STRING",
793+
body="CASE WHEN current_role() IN ('ANALYST') THEN VAL ELSE '*********' END",
794+
comment="Masks email addresses",
795+
owner=TEST_ROLE,
796+
)
797+
create(cursor, policy)
798+
marked_for_cleanup.append(policy)
799+
800+
result = safe_fetch(cursor, policy.urn)
801+
assert result is not None
802+
result = clean_resource_data(res.MaskingPolicy.spec, result)
803+
data = clean_resource_data(res.MaskingPolicy.spec, policy.to_dict())
804+
assert result == data

tests/integration/test_blueprint.py

+88
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717
from titan.client import reset_cache
1818
from titan.enums import BlueprintScope, ResourceType
19+
from titan.exceptions import NotADAGException
1920
from titan.gitops import collect_blueprint_config
2021
from titan.resources.database import public_schema_urn
2122

@@ -607,3 +608,90 @@ def test_blueprint_share_custom_owner(cursor, suffix):
607608
blueprint.apply(session, plan)
608609
finally:
609610
cursor.execute(f"DROP SHARE IF EXISTS {share_name}")
611+
612+
613+
def test_stage_read_write_privilege_execution_order(cursor, suffix, marked_for_cleanup):
614+
session = cursor.connection
615+
616+
role_name = f"STAGE_ACCESS_ROLE_{suffix}"
617+
618+
blueprint = Blueprint()
619+
620+
role = res.Role(name=role_name)
621+
read_grant = res.Grant(priv="READ", on_stage="STATIC_DATABASE.PUBLIC.STATIC_STAGE", to=role)
622+
write_grant = res.Grant(priv="WRITE", on_stage="STATIC_DATABASE.PUBLIC.STATIC_STAGE", to=role)
623+
624+
# Incorrect order of execution
625+
read_grant.requires(write_grant)
626+
627+
blueprint.add(role, read_grant, write_grant)
628+
629+
marked_for_cleanup.append(role)
630+
631+
with pytest.raises(NotADAGException):
632+
blueprint.plan(session)
633+
634+
blueprint = Blueprint()
635+
636+
role = res.Role(name=role_name)
637+
read_grant = res.Grant(priv="READ", on_stage="STATIC_DATABASE.PUBLIC.STATIC_STAGE", to=role)
638+
write_grant = res.Grant(priv="WRITE", on_stage="STATIC_DATABASE.PUBLIC.STATIC_STAGE", to=role)
639+
640+
# Implicitly ordered incorrectly
641+
blueprint.add(role, write_grant, read_grant)
642+
643+
plan = blueprint.plan(session)
644+
assert len(plan) == 3
645+
blueprint.apply(session, plan)
646+
647+
blueprint = Blueprint()
648+
649+
read_on_all = res.GrantOnAll(
650+
priv="READ", on_type="STAGE", in_type="SCHEMA", in_name="STATIC_DATABASE.PUBLIC", to=role_name
651+
)
652+
future_read = res.FutureGrant(
653+
priv="READ", on_type="STAGE", in_type="SCHEMA", in_name="STATIC_DATABASE.PUBLIC", to=role_name
654+
)
655+
write_on_all = res.GrantOnAll(
656+
priv="WRITE", on_type="STAGE", in_type="SCHEMA", in_name="STATIC_DATABASE.PUBLIC", to=role_name
657+
)
658+
future_write = res.FutureGrant(
659+
priv="WRITE", on_type="STAGE", in_type="SCHEMA", in_name="STATIC_DATABASE.PUBLIC", to=role_name
660+
)
661+
662+
# Implicitly ordered incorrectly
663+
blueprint.add(future_write, future_read, write_on_all, read_on_all)
664+
665+
plan = blueprint.plan(session)
666+
assert len(plan) == 4
667+
blueprint.apply(session, plan)
668+
669+
670+
def test_grant_database_role_to_database_role(cursor, suffix, marked_for_cleanup):
671+
session = cursor.connection
672+
bp = Blueprint()
673+
674+
parent = res.DatabaseRole(name=f"DBR2DBR_PARENT_{suffix}", database="STATIC_DATABASE")
675+
child1 = res.DatabaseRole(name=f"DBR2DBR_CHILD_1_{suffix}", database="STATIC_DATABASE")
676+
child2 = res.DatabaseRole(name=f"DBR2DBR_CHILD_2_{suffix}", database="STATIC_DATABASE")
677+
drg1 = res.DatabaseRoleGrant(database_role=child1, to_database_role=parent)
678+
drg2 = res.DatabaseRoleGrant(database_role=child2, to_database_role=parent)
679+
680+
marked_for_cleanup.append(parent)
681+
marked_for_cleanup.append(child1)
682+
marked_for_cleanup.append(child2)
683+
684+
bp.add(parent, child1, child2, drg1, drg2)
685+
plan = bp.plan(session)
686+
assert len(plan) == 5
687+
bp.apply(session, plan)
688+
689+
grant1 = safe_fetch(cursor, res.DatabaseRoleGrant(database_role=child1, to_database_role=parent).urn)
690+
assert grant1 is not None
691+
assert grant1["database_role"] == str(child1.fqn)
692+
assert grant1["to_database_role"] == str(parent.fqn)
693+
694+
grant2 = safe_fetch(cursor, res.DatabaseRoleGrant(database_role=child2, to_database_role=parent).urn)
695+
assert grant2 is not None
696+
assert grant2["database_role"] == str(child2.fqn)
697+
assert grant2["to_database_role"] == str(parent.fqn)

tests/integration/test_export.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,22 @@
11
import pytest
22

3-
from titan.operations.export import export_resources
3+
from titan.identifiers import URN, parse_FQN
4+
from titan.operations.export import export_resources, _format_resource_config
5+
from titan.enums import ResourceType
6+
from titan.data_provider import fetch_resource
47

58
pytestmark = pytest.mark.requires_snowflake
69

710

811
def test_export_all(cursor):
912
assert export_resources(session=cursor.connection)
13+
14+
15+
def test_export_schema(cursor):
16+
urn = URN(ResourceType.SCHEMA, parse_FQN("STATIC_DATABASE.STATIC_SCHEMA", is_db_scoped=True))
17+
resource = fetch_resource(cursor, urn)
18+
assert resource
19+
resource_cfg = _format_resource_config(urn, resource, ResourceType.SCHEMA)
20+
assert resource_cfg
21+
assert "database" in resource_cfg
22+
assert resource_cfg["database"] == "STATIC_DATABASE"

tests/integration/test_lifecycle.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def test_create_drop_from_json(resource, cursor, suffix):
5555
res.FutureGrant,
5656
res.Grant,
5757
res.RoleGrant,
58+
res.DatabaseRoleGrant,
5859
res.ScannerPackage,
5960
res.Service,
6061
):
@@ -64,6 +65,7 @@ def test_create_drop_from_json(resource, cursor, suffix):
6465
database = res.Database(name=lifecycle_db, owner="SYSADMIN")
6566

6667
feature_enabled = True
68+
drop_sql = None
6769

6870
try:
6971
fetch_session.cache_clear()
@@ -213,8 +215,8 @@ def test_task_lifecycle_remove_predecessor(cursor, suffix, marked_for_cleanup):
213215

214216

215217
def test_database_role_grants(cursor, suffix, marked_for_cleanup):
216-
db = res.Database(name="whatever")
217-
role = res.DatabaseRole(name="whatever_role", database=db)
218+
db = res.Database(name=f"TEST_DATABASE_ROLE_GRANTS_{suffix}")
219+
role = res.DatabaseRole(name=f"TEST_DATABASE_ROLE_GRANTS_{suffix}", database=db)
218220
grant = res.Grant(priv="USAGE", on_schema=db.public_schema.fqn, to=role)
219221
future_grant = res.FutureGrant(priv="SELECT", on_future_tables_in=db, to=role)
220222

tests/integration/test_resources.py

+13
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,16 @@ def test_fetch_warehouse_snowpark_optimized(cursor, suffix, marked_for_cleanup):
127127
data = safe_fetch(cursor, warehouse.urn)
128128
assert data is not None
129129
assert data["warehouse_type"] == "SNOWPARK-OPTIMIZED"
130+
131+
132+
def test_snowflake_builtin_database_role_grant(cursor, suffix, marked_for_cleanup):
133+
drg = res.DatabaseRoleGrant(database_role="SNOWFLAKE.CORTEX_USER", to_role="STATIC_ROLE")
134+
marked_for_cleanup.append(drg)
135+
cursor.execute(drg.create_sql())
136+
137+
dbr = res.DatabaseRole(name=f"TEST_GRANT_DATABASE_ROLE_{suffix}", database="STATIC_DATABASE")
138+
drg = res.DatabaseRoleGrant(database_role=dbr, to_database_role="STATIC_DATABASE.STATIC_DATABASE_ROLE")
139+
marked_for_cleanup.append(dbr)
140+
marked_for_cleanup.append(drg)
141+
cursor.execute(dbr.create_sql())
142+
cursor.execute(drg.create_sql())

tests/test_grant.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -253,23 +253,23 @@ def test_grant_database_role_to_database_role():
253253
database = res.Database(name="somedb")
254254
parent = res.DatabaseRole(name="parent", database=database)
255255
child = res.DatabaseRole(name="child", database=database)
256-
grant = res.RoleGrant(role=child, to_role=parent)
257-
assert grant.role.name == "child"
256+
grant = res.DatabaseRoleGrant(database_role=child, to_database_role=parent)
257+
assert grant.database_role.name == "child"
258258
assert grant.to.name == "parent"
259259

260260

261261
def test_grant_database_role_to_account_role():
262262
database = res.Database(name="somedb")
263263
parent = res.Role(name="parent")
264264
child = res.DatabaseRole(name="child", database=database)
265-
grant = res.RoleGrant(role=child, to_role=parent)
266-
assert grant.role.name == "child"
265+
grant = res.DatabaseRoleGrant(database_role=child, to_role=parent)
266+
assert grant.database_role.name == "child"
267267
assert grant.to.name == "parent"
268268

269269

270270
def test_grant_database_role_to_system_role():
271271
database = res.Database(name="somedb")
272272
child = res.DatabaseRole(name="child", database=database)
273-
grant = res.RoleGrant(role=child, to_role="SYSADMIN")
274-
assert grant.role.name == "child"
273+
grant = res.DatabaseRoleGrant(database_role=child, to_role="SYSADMIN")
274+
assert grant.database_role.name == "child"
275275
assert grant.to.name == "SYSADMIN"

tests/test_identities.py

+1
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def test_data_identity(resource):
107107
assert serialized == data
108108

109109

110+
@pytest.mark.skip(reason="SQL parsing will be deprecated")
110111
def test_sql_identity(resource: tuple[type[Resource], dict]):
111112
resource_cls, data = resource
112113
if resource_cls.__name__ == "ScannerPackage":

tests/test_resources.py

+7
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,10 @@ def test_user_type_fallback(caplog):
344344
user = res.User(name="test_user", user_type="SERVICE")
345345
assert "The 'user_type' parameter is deprecated. Use 'type' instead." in caplog.text
346346
assert user._data.type == UserType.SERVICE
347+
348+
349+
def test_future_grant_alt_syntax():
350+
db = res.Database(name="DB")
351+
role = res.Role(name="ROLE")
352+
fg = res.FutureGrant(priv="SELECT", on_type="table", in_type=db.resource_type, in_name=db.name, to=role)
353+
assert fg

titan/blueprint.py

+44-2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
MissingPrivilegeException,
2525
MissingResourceException,
2626
NonConformingPlanException,
27+
NotADAGException,
2728
OrphanResourceException,
2829
)
2930
from .identifiers import URN, parse_identifier, parse_URN, resource_label_for_type
@@ -33,7 +34,7 @@
3334
)
3435
from .resource_name import ResourceName
3536
from .resource_tags import ResourceTags
36-
from .resources import Database, RoleGrant, Schema
37+
from .resources import Database, FutureGrant, Grant, GrantOnAll, RoleGrant, Schema
3738
from .resources.database import public_schema_urn
3839
from .resources.resource import (
3940
RESOURCE_SCOPES,
@@ -870,6 +871,46 @@ def _create_grandparent_refs(self) -> None:
870871
if isinstance(resource.scope, SchemaScope):
871872
resource.requires(resource.container.container)
872873

874+
def _create_stage_privilege_refs(self) -> None:
875+
stage_grants: dict[str, list[Grant]] = {}
876+
stage_future_grants: dict[ResourceName, list[FutureGrant]] = {}
877+
stage_grant_on_all: dict[ResourceName, list[GrantOnAll]] = {}
878+
879+
for resource in _walk(self._root):
880+
if isinstance(resource, Grant):
881+
if resource._data.on_type == ResourceType.STAGE:
882+
if resource._data.on not in stage_grants:
883+
stage_grants[resource._data.on] = []
884+
stage_grants[resource._data.on].append(resource)
885+
elif isinstance(resource, FutureGrant):
886+
if resource._data.on_type == ResourceType.STAGE:
887+
if resource._data.in_name not in stage_future_grants:
888+
stage_future_grants[resource._data.in_name] = []
889+
stage_future_grants[resource._data.in_name].append(resource)
890+
elif isinstance(resource, GrantOnAll):
891+
if resource._data.on_type == ResourceType.STAGE:
892+
if resource._data.in_name not in stage_grant_on_all:
893+
stage_grant_on_all[resource._data.in_name] = []
894+
stage_grant_on_all[resource._data.in_name].append(resource)
895+
896+
def _apply_refs(stage_grants):
897+
for stage in stage_grants.keys():
898+
read_grants = []
899+
write_grants = []
900+
for grant in stage_grants[stage]:
901+
if grant._data.priv == "READ":
902+
read_grants.append(grant)
903+
elif grant._data.priv == "WRITE":
904+
write_grants.append(grant)
905+
906+
for w_grant in write_grants:
907+
for r_grant in read_grants:
908+
w_grant.requires(r_grant)
909+
910+
_apply_refs(stage_grants)
911+
_apply_refs(stage_future_grants)
912+
_apply_refs(stage_grant_on_all)
913+
873914
def _finalize_resources(self) -> None:
874915
for resource in _walk(self._root):
875916
resource._finalized = True
@@ -883,6 +924,7 @@ def _finalize(self, session_ctx: SessionContext) -> None:
883924
self._create_tag_references()
884925
self._create_ownership_refs(session_ctx)
885926
self._create_grandparent_refs()
927+
self._create_stage_privilege_refs()
886928
self._finalize_resources()
887929

888930
def generate_manifest(self, session_ctx: SessionContext) -> Manifest:
@@ -1233,7 +1275,7 @@ def topological_sort(resource_set: set[T], references: set[tuple[T, T]]) -> dict
12331275
outgoing_edges[node].difference_update(empty_neighbors)
12341276
nodes.reverse()
12351277
if len(nodes) != len(resource_set):
1236-
raise Exception("Graph is not a DAG")
1278+
raise NotADAGException("Graph is not a DAG")
12371279
return {value: index for index, value in enumerate(nodes)}
12381280

12391281

0 commit comments

Comments
 (0)