From cf5a6c36e758863bf68b0156fb36f995dc8d1c30 Mon Sep 17 00:00:00 2001 From: TJ Murphy Date: Tue, 17 Dec 2024 00:21:44 -0800 Subject: [PATCH] release/v0.11.0 (#174) Bump version * v0.11.0 release --------- Co-authored-by: TJ Murphy <1796+teej@users.noreply.github.com> --- .../generate_docstring_prompt.txt | 0 .../generate_new_resource_prompt.txt | 0 tests/fixtures/json/database_role_grant.json | 5 + tests/fixtures/json/masking_policy.json | 11 ++ .../data_provider/test_fetch_resource.py | 58 ++++++++++ tests/integration/test_blueprint.py | 88 +++++++++++++++ tests/integration/test_export.py | 15 ++- tests/integration/test_lifecycle.py | 6 +- tests/integration/test_resources.py | 13 +++ tests/test_grant.py | 12 +- tests/test_identities.py | 1 + tests/test_resources.py | 7 ++ titan/blueprint.py | 46 +++++++- titan/data_provider.py | 88 +++++++++++++++ titan/data_types.py | 49 ++++---- titan/enums.py | 3 + titan/exceptions.py | 4 + titan/gitops.py | 26 +++++ titan/identifiers.py | 3 +- titan/lifecycle.py | 55 ++++++++- titan/operations/export.py | 14 ++- titan/resources/__init__.py | 5 +- titan/resources/compute_pool.py | 22 +--- titan/resources/grant.py | 106 ++++++++++++++++-- titan/resources/masking_policy.py | 62 ++++++++++ titan/resources/resource.py | 17 ++- titan/resources/role.py | 4 + titan/resources/task.py | 7 +- tools/test_account_configs/base.yml | 11 +- tools/test_account_configs/enterprise.yml | 14 +++ version.md | 2 +- 31 files changed, 669 insertions(+), 85 deletions(-) rename {misc => prompts}/generate_docstring_prompt.txt (100%) rename {misc => prompts}/generate_new_resource_prompt.txt (100%) create mode 100644 tests/fixtures/json/database_role_grant.json create mode 100644 tests/fixtures/json/masking_policy.json create mode 100644 titan/resources/masking_policy.py diff --git a/misc/generate_docstring_prompt.txt b/prompts/generate_docstring_prompt.txt similarity index 100% rename from misc/generate_docstring_prompt.txt rename to prompts/generate_docstring_prompt.txt diff --git a/misc/generate_new_resource_prompt.txt b/prompts/generate_new_resource_prompt.txt similarity index 100% rename from misc/generate_new_resource_prompt.txt rename to prompts/generate_new_resource_prompt.txt diff --git a/tests/fixtures/json/database_role_grant.json b/tests/fixtures/json/database_role_grant.json new file mode 100644 index 00000000..3cf4d494 --- /dev/null +++ b/tests/fixtures/json/database_role_grant.json @@ -0,0 +1,5 @@ +{ + "database_role": "STATIC_DATABASE.STATIC_DATABASE_ROLE", + "to_role": "STATIC_ROLE", + "to_database_role": null +} diff --git a/tests/fixtures/json/masking_policy.json b/tests/fixtures/json/masking_policy.json new file mode 100644 index 00000000..08d36db4 --- /dev/null +++ b/tests/fixtures/json/masking_policy.json @@ -0,0 +1,11 @@ +{ + "name": "some_masking_policy", + "args": [ + {"name": "val", "data_type": "VARCHAR"} + ], + "returns": "VARCHAR(16777216)", + "body": "CASE WHEN current_role() IN ('ANALYST') THEN VAL ELSE '*********' END", + "comment": "Masks email addresses", + "exempt_other_policies": false, + "owner": "SYSADMIN" +} diff --git a/tests/integration/data_provider/test_fetch_resource.py b/tests/integration/data_provider/test_fetch_resource.py index 71ca47d9..21645186 100644 --- a/tests/integration/data_provider/test_fetch_resource.py +++ b/tests/integration/data_provider/test_fetch_resource.py @@ -744,3 +744,61 @@ def test_fetch_database_role_grant(cursor, suffix, marked_for_cleanup): result = clean_resource_data(res.Grant.spec, result) data = clean_resource_data(res.Grant.spec, grant.to_dict()) assert result == data + + +def test_fetch_database_role(cursor, suffix, marked_for_cleanup): + role = res.DatabaseRole( + name=f"TEST_FETCH_DATABASE_ROLE_{suffix}", + database="STATIC_DATABASE", + owner=TEST_ROLE, + ) + create(cursor, role) + marked_for_cleanup.append(role) + + result = safe_fetch(cursor, role.urn) + assert result is not None + result = clean_resource_data(res.DatabaseRole.spec, result) + data = clean_resource_data(res.DatabaseRole.spec, role.to_dict()) + assert result == data + + +def test_fetch_grant_of_database_role(cursor, suffix, marked_for_cleanup): + db_role = res.DatabaseRole( + name=f"TEST_FETCH_GRANT_OF_DATABASE_ROLE_{suffix}", + database="STATIC_DATABASE", + owner=TEST_ROLE, + ) + create(cursor, db_role) + marked_for_cleanup.append(db_role) + + role = res.Role(name=f"TEST_FETCH_GRANT_OF_DATABASE_ROLE_{suffix}", owner=TEST_ROLE) + create(cursor, role) + marked_for_cleanup.append(role) + + grant = res.DatabaseRoleGrant(database_role=db_role, to_role=role) + create(cursor, grant) + + result = safe_fetch(cursor, grant.urn) + assert result is not None + result = clean_resource_data(res.DatabaseRoleGrant.spec, result) + data = clean_resource_data(res.DatabaseRoleGrant.spec, grant.to_dict()) + assert result == data + + +def test_fetch_masking_policy(cursor, suffix, marked_for_cleanup): + policy = res.MaskingPolicy( + name=f"TEST_FETCH_MASKING_POLICY_{suffix}", + args=[{"name": "val", "data_type": "STRING"}], + returns="STRING", + body="CASE WHEN current_role() IN ('ANALYST') THEN VAL ELSE '*********' END", + comment="Masks email addresses", + owner=TEST_ROLE, + ) + create(cursor, policy) + marked_for_cleanup.append(policy) + + result = safe_fetch(cursor, policy.urn) + assert result is not None + result = clean_resource_data(res.MaskingPolicy.spec, result) + data = clean_resource_data(res.MaskingPolicy.spec, policy.to_dict()) + assert result == data diff --git a/tests/integration/test_blueprint.py b/tests/integration/test_blueprint.py index 1b9a74f6..10fb5302 100644 --- a/tests/integration/test_blueprint.py +++ b/tests/integration/test_blueprint.py @@ -16,6 +16,7 @@ ) from titan.client import reset_cache from titan.enums import BlueprintScope, ResourceType +from titan.exceptions import NotADAGException from titan.gitops import collect_blueprint_config from titan.resources.database import public_schema_urn @@ -607,3 +608,90 @@ def test_blueprint_share_custom_owner(cursor, suffix): blueprint.apply(session, plan) finally: cursor.execute(f"DROP SHARE IF EXISTS {share_name}") + + +def test_stage_read_write_privilege_execution_order(cursor, suffix, marked_for_cleanup): + session = cursor.connection + + role_name = f"STAGE_ACCESS_ROLE_{suffix}" + + blueprint = Blueprint() + + role = res.Role(name=role_name) + read_grant = res.Grant(priv="READ", on_stage="STATIC_DATABASE.PUBLIC.STATIC_STAGE", to=role) + write_grant = res.Grant(priv="WRITE", on_stage="STATIC_DATABASE.PUBLIC.STATIC_STAGE", to=role) + + # Incorrect order of execution + read_grant.requires(write_grant) + + blueprint.add(role, read_grant, write_grant) + + marked_for_cleanup.append(role) + + with pytest.raises(NotADAGException): + blueprint.plan(session) + + blueprint = Blueprint() + + role = res.Role(name=role_name) + read_grant = res.Grant(priv="READ", on_stage="STATIC_DATABASE.PUBLIC.STATIC_STAGE", to=role) + write_grant = res.Grant(priv="WRITE", on_stage="STATIC_DATABASE.PUBLIC.STATIC_STAGE", to=role) + + # Implicitly ordered incorrectly + blueprint.add(role, write_grant, read_grant) + + plan = blueprint.plan(session) + assert len(plan) == 3 + blueprint.apply(session, plan) + + blueprint = Blueprint() + + read_on_all = res.GrantOnAll( + priv="READ", on_type="STAGE", in_type="SCHEMA", in_name="STATIC_DATABASE.PUBLIC", to=role_name + ) + future_read = res.FutureGrant( + priv="READ", on_type="STAGE", in_type="SCHEMA", in_name="STATIC_DATABASE.PUBLIC", to=role_name + ) + write_on_all = res.GrantOnAll( + priv="WRITE", on_type="STAGE", in_type="SCHEMA", in_name="STATIC_DATABASE.PUBLIC", to=role_name + ) + future_write = res.FutureGrant( + priv="WRITE", on_type="STAGE", in_type="SCHEMA", in_name="STATIC_DATABASE.PUBLIC", to=role_name + ) + + # Implicitly ordered incorrectly + blueprint.add(future_write, future_read, write_on_all, read_on_all) + + plan = blueprint.plan(session) + assert len(plan) == 4 + blueprint.apply(session, plan) + + +def test_grant_database_role_to_database_role(cursor, suffix, marked_for_cleanup): + session = cursor.connection + bp = Blueprint() + + parent = res.DatabaseRole(name=f"DBR2DBR_PARENT_{suffix}", database="STATIC_DATABASE") + child1 = res.DatabaseRole(name=f"DBR2DBR_CHILD_1_{suffix}", database="STATIC_DATABASE") + child2 = res.DatabaseRole(name=f"DBR2DBR_CHILD_2_{suffix}", database="STATIC_DATABASE") + drg1 = res.DatabaseRoleGrant(database_role=child1, to_database_role=parent) + drg2 = res.DatabaseRoleGrant(database_role=child2, to_database_role=parent) + + marked_for_cleanup.append(parent) + marked_for_cleanup.append(child1) + marked_for_cleanup.append(child2) + + bp.add(parent, child1, child2, drg1, drg2) + plan = bp.plan(session) + assert len(plan) == 5 + bp.apply(session, plan) + + grant1 = safe_fetch(cursor, res.DatabaseRoleGrant(database_role=child1, to_database_role=parent).urn) + assert grant1 is not None + assert grant1["database_role"] == str(child1.fqn) + assert grant1["to_database_role"] == str(parent.fqn) + + grant2 = safe_fetch(cursor, res.DatabaseRoleGrant(database_role=child2, to_database_role=parent).urn) + assert grant2 is not None + assert grant2["database_role"] == str(child2.fqn) + assert grant2["to_database_role"] == str(parent.fqn) diff --git a/tests/integration/test_export.py b/tests/integration/test_export.py index 0d4238dc..b9fc73fc 100644 --- a/tests/integration/test_export.py +++ b/tests/integration/test_export.py @@ -1,9 +1,22 @@ import pytest -from titan.operations.export import export_resources +from titan.identifiers import URN, parse_FQN +from titan.operations.export import export_resources, _format_resource_config +from titan.enums import ResourceType +from titan.data_provider import fetch_resource pytestmark = pytest.mark.requires_snowflake def test_export_all(cursor): assert export_resources(session=cursor.connection) + + +def test_export_schema(cursor): + urn = URN(ResourceType.SCHEMA, parse_FQN("STATIC_DATABASE.STATIC_SCHEMA", is_db_scoped=True)) + resource = fetch_resource(cursor, urn) + assert resource + resource_cfg = _format_resource_config(urn, resource, ResourceType.SCHEMA) + assert resource_cfg + assert "database" in resource_cfg + assert resource_cfg["database"] == "STATIC_DATABASE" diff --git a/tests/integration/test_lifecycle.py b/tests/integration/test_lifecycle.py index 9731f148..32d23ef8 100644 --- a/tests/integration/test_lifecycle.py +++ b/tests/integration/test_lifecycle.py @@ -55,6 +55,7 @@ def test_create_drop_from_json(resource, cursor, suffix): res.FutureGrant, res.Grant, res.RoleGrant, + res.DatabaseRoleGrant, res.ScannerPackage, res.Service, ): @@ -64,6 +65,7 @@ def test_create_drop_from_json(resource, cursor, suffix): database = res.Database(name=lifecycle_db, owner="SYSADMIN") feature_enabled = True + drop_sql = None try: fetch_session.cache_clear() @@ -213,8 +215,8 @@ def test_task_lifecycle_remove_predecessor(cursor, suffix, marked_for_cleanup): def test_database_role_grants(cursor, suffix, marked_for_cleanup): - db = res.Database(name="whatever") - role = res.DatabaseRole(name="whatever_role", database=db) + db = res.Database(name=f"TEST_DATABASE_ROLE_GRANTS_{suffix}") + role = res.DatabaseRole(name=f"TEST_DATABASE_ROLE_GRANTS_{suffix}", database=db) grant = res.Grant(priv="USAGE", on_schema=db.public_schema.fqn, to=role) future_grant = res.FutureGrant(priv="SELECT", on_future_tables_in=db, to=role) diff --git a/tests/integration/test_resources.py b/tests/integration/test_resources.py index 23e9e901..6a0f3e8f 100644 --- a/tests/integration/test_resources.py +++ b/tests/integration/test_resources.py @@ -127,3 +127,16 @@ def test_fetch_warehouse_snowpark_optimized(cursor, suffix, marked_for_cleanup): data = safe_fetch(cursor, warehouse.urn) assert data is not None assert data["warehouse_type"] == "SNOWPARK-OPTIMIZED" + + +def test_snowflake_builtin_database_role_grant(cursor, suffix, marked_for_cleanup): + drg = res.DatabaseRoleGrant(database_role="SNOWFLAKE.CORTEX_USER", to_role="STATIC_ROLE") + marked_for_cleanup.append(drg) + cursor.execute(drg.create_sql()) + + dbr = res.DatabaseRole(name=f"TEST_GRANT_DATABASE_ROLE_{suffix}", database="STATIC_DATABASE") + drg = res.DatabaseRoleGrant(database_role=dbr, to_database_role="STATIC_DATABASE.STATIC_DATABASE_ROLE") + marked_for_cleanup.append(dbr) + marked_for_cleanup.append(drg) + cursor.execute(dbr.create_sql()) + cursor.execute(drg.create_sql()) diff --git a/tests/test_grant.py b/tests/test_grant.py index 69471f17..b5c9e3c3 100644 --- a/tests/test_grant.py +++ b/tests/test_grant.py @@ -253,8 +253,8 @@ def test_grant_database_role_to_database_role(): database = res.Database(name="somedb") parent = res.DatabaseRole(name="parent", database=database) child = res.DatabaseRole(name="child", database=database) - grant = res.RoleGrant(role=child, to_role=parent) - assert grant.role.name == "child" + grant = res.DatabaseRoleGrant(database_role=child, to_database_role=parent) + assert grant.database_role.name == "child" assert grant.to.name == "parent" @@ -262,14 +262,14 @@ def test_grant_database_role_to_account_role(): database = res.Database(name="somedb") parent = res.Role(name="parent") child = res.DatabaseRole(name="child", database=database) - grant = res.RoleGrant(role=child, to_role=parent) - assert grant.role.name == "child" + grant = res.DatabaseRoleGrant(database_role=child, to_role=parent) + assert grant.database_role.name == "child" assert grant.to.name == "parent" def test_grant_database_role_to_system_role(): database = res.Database(name="somedb") child = res.DatabaseRole(name="child", database=database) - grant = res.RoleGrant(role=child, to_role="SYSADMIN") - assert grant.role.name == "child" + grant = res.DatabaseRoleGrant(database_role=child, to_role="SYSADMIN") + assert grant.database_role.name == "child" assert grant.to.name == "SYSADMIN" diff --git a/tests/test_identities.py b/tests/test_identities.py index ee86773f..745f4af6 100644 --- a/tests/test_identities.py +++ b/tests/test_identities.py @@ -107,6 +107,7 @@ def test_data_identity(resource): assert serialized == data +@pytest.mark.skip(reason="SQL parsing will be deprecated") def test_sql_identity(resource: tuple[type[Resource], dict]): resource_cls, data = resource if resource_cls.__name__ == "ScannerPackage": diff --git a/tests/test_resources.py b/tests/test_resources.py index 79b71db1..fc293ed8 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -344,3 +344,10 @@ def test_user_type_fallback(caplog): user = res.User(name="test_user", user_type="SERVICE") assert "The 'user_type' parameter is deprecated. Use 'type' instead." in caplog.text assert user._data.type == UserType.SERVICE + + +def test_future_grant_alt_syntax(): + db = res.Database(name="DB") + role = res.Role(name="ROLE") + fg = res.FutureGrant(priv="SELECT", on_type="table", in_type=db.resource_type, in_name=db.name, to=role) + assert fg diff --git a/titan/blueprint.py b/titan/blueprint.py index 169ae329..8fda91df 100644 --- a/titan/blueprint.py +++ b/titan/blueprint.py @@ -24,6 +24,7 @@ MissingPrivilegeException, MissingResourceException, NonConformingPlanException, + NotADAGException, OrphanResourceException, ) from .identifiers import URN, parse_identifier, parse_URN, resource_label_for_type @@ -33,7 +34,7 @@ ) from .resource_name import ResourceName from .resource_tags import ResourceTags -from .resources import Database, RoleGrant, Schema +from .resources import Database, FutureGrant, Grant, GrantOnAll, RoleGrant, Schema from .resources.database import public_schema_urn from .resources.resource import ( RESOURCE_SCOPES, @@ -870,6 +871,46 @@ def _create_grandparent_refs(self) -> None: if isinstance(resource.scope, SchemaScope): resource.requires(resource.container.container) + def _create_stage_privilege_refs(self) -> None: + stage_grants: dict[str, list[Grant]] = {} + stage_future_grants: dict[ResourceName, list[FutureGrant]] = {} + stage_grant_on_all: dict[ResourceName, list[GrantOnAll]] = {} + + for resource in _walk(self._root): + if isinstance(resource, Grant): + if resource._data.on_type == ResourceType.STAGE: + if resource._data.on not in stage_grants: + stage_grants[resource._data.on] = [] + stage_grants[resource._data.on].append(resource) + elif isinstance(resource, FutureGrant): + if resource._data.on_type == ResourceType.STAGE: + if resource._data.in_name not in stage_future_grants: + stage_future_grants[resource._data.in_name] = [] + stage_future_grants[resource._data.in_name].append(resource) + elif isinstance(resource, GrantOnAll): + if resource._data.on_type == ResourceType.STAGE: + if resource._data.in_name not in stage_grant_on_all: + stage_grant_on_all[resource._data.in_name] = [] + stage_grant_on_all[resource._data.in_name].append(resource) + + def _apply_refs(stage_grants): + for stage in stage_grants.keys(): + read_grants = [] + write_grants = [] + for grant in stage_grants[stage]: + if grant._data.priv == "READ": + read_grants.append(grant) + elif grant._data.priv == "WRITE": + write_grants.append(grant) + + for w_grant in write_grants: + for r_grant in read_grants: + w_grant.requires(r_grant) + + _apply_refs(stage_grants) + _apply_refs(stage_future_grants) + _apply_refs(stage_grant_on_all) + def _finalize_resources(self) -> None: for resource in _walk(self._root): resource._finalized = True @@ -883,6 +924,7 @@ def _finalize(self, session_ctx: SessionContext) -> None: self._create_tag_references() self._create_ownership_refs(session_ctx) self._create_grandparent_refs() + self._create_stage_privilege_refs() self._finalize_resources() 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 outgoing_edges[node].difference_update(empty_neighbors) nodes.reverse() if len(nodes) != len(resource_set): - raise Exception("Graph is not a DAG") + raise NotADAGException("Graph is not a DAG") return {value: index for index, value in enumerate(nodes)} diff --git a/titan/data_provider.py b/titan/data_provider.py index 89ef3440..26479f6f 100644 --- a/titan/data_provider.py +++ b/titan/data_provider.py @@ -939,6 +939,33 @@ def fetch_database_role(session: SnowflakeConnection, fqn: FQN): } +def fetch_database_role_grant(session: SnowflakeConnection, fqn: FQN): + show_result = execute(session, f"SHOW GRANTS OF DATABASE ROLE {fqn.database}.{fqn.name}", cacheable=True) + + subject, subject_name = next(iter(fqn.params.items())) + + role_grants = _filter_result(show_result, granted_to=subject.upper(), grantee_name=subject_name) + if len(role_grants) == 0: + return None + if len(role_grants) > 1: + raise Exception(f"Found multiple database role grants matching {fqn}") + + data = show_result[0] + + to_role = None + to_database_role = None + if data["granted_to"] == "ROLE": + to_role = _quote_snowflake_identifier(data["grantee_name"]) + elif data["granted_to"] == "DATABASE_ROLE": + to_database_role = data["grantee_name"] + + return { + "database_role": data["role"], + "to_role": to_role, + "to_database_role": to_database_role, + } + + def fetch_dynamic_table(session: SnowflakeConnection, fqn: FQN): show_result = _show_resources(session, "DYNAMIC TABLES", fqn) if len(show_result) == 0: @@ -1329,6 +1356,29 @@ def fetch_image_repository(session: SnowflakeConnection, fqn: FQN): return {"name": fqn.name, "owner": _get_owner_identifier(data)} +def fetch_masking_policy(session: SnowflakeConnection, fqn: FQN): + policies = _show_resources(session, "MASKING POLICIES", fqn) + if len(policies) == 0: + return None + if len(policies) > 1: + raise Exception(f"Found multiple masking policies matching {fqn}") + + data = policies[0] + options = json.loads(data["options"]) if data["options"] else {} + desc_result = execute(session, f"DESC MASKING POLICY {fqn}", cacheable=True) + properties = desc_result[0] + + return { + "name": data["name"], + "owner": _get_owner_identifier(data), + "args": _parse_signature(properties["signature"]), + "returns": properties["return_type"], + "body": properties["body"], + "comment": data["comment"] or None, + "exempt_other_policies": options.get("exempt_other_policies", "false") == "true", + } + + def fetch_materialized_view(session: SnowflakeConnection, fqn: FQN): materialized_views = _show_resources(session, "MATERIALIZED VIEWS", fqn) if len(materialized_views) == 0: @@ -2466,6 +2516,40 @@ def list_database_roles(session: SnowflakeConnection, database=None) -> list[FQN return roles +def list_database_role_grants(session: SnowflakeConnection, database=None) -> list[FQN]: + databases: list[ResourceName] + if database: + databases = [ResourceName(database)] + else: + databases = _list_databases(session) + + role_grants = [] + for database_name in databases: + try: + # A rare case where we need to always quote the identifier. Snowflake chokes if the database name + # is DATABASE, but this will work if quoted + if database_name == "DATABASE": + database_name._quoted = True + database_roles = execute(session, f"SHOW DATABASE ROLES IN DATABASE {database_name}") + except ProgrammingError as err: + if err.errno == DOES_NOT_EXIST_ERR: + continue + raise + for role in database_roles: + show_result = execute(session, f"SHOW GRANTS OF DATABASE ROLE {database_name}.{role['name']}") + for data in show_result: + subject = "role" if data["granted_to"] == "ROLE" else "database_role" + database, name = data["role"].split(".") + role_grants.append( + FQN( + name=resource_name_from_snowflake_metadata(name), + database=resource_name_from_snowflake_metadata(database), + params={subject: data["grantee_name"]}, + ) + ) + return role_grants + + def list_dynamic_tables(session: SnowflakeConnection) -> list[FQN]: return list_schema_scoped_resource(session, "DYNAMIC TABLES") @@ -2558,6 +2642,10 @@ def list_image_repositories(session: SnowflakeConnection) -> list[FQN]: return list_schema_scoped_resource(session, "IMAGE REPOSITORIES") +def list_masking_policies(session: SnowflakeConnection) -> list[FQN]: + return list_schema_scoped_resource(session, "MASKING POLICIES") + + def list_network_policies(session: SnowflakeConnection) -> list[FQN]: return list_account_scoped_resource(session, "NETWORK POLICIES") diff --git a/titan/data_types.py b/titan/data_types.py index ba846eef..bdd639ae 100644 --- a/titan/data_types.py +++ b/titan/data_types.py @@ -2,6 +2,10 @@ from .enums import DataType +NUMBER_TYPES = ("NUMBER", "DECIMAL", "DEC", "NUMERIC", "INT", "INTEGER", "BIGINT", "SMALLINT", "TINYINT", "BYTEINT") +FLOAT_TYPES = ("FLOAT", "FLOAT4", "FLOAT8", "REAL", "DOUBLE", "DOUBLE PRECISION", "REAL") +VARCHAR_TYPES = ("VARCHAR", "STRING", "TEXT", "NVARCHAR", "NVARCHAR2", "CHAR VARYING", "NCHAR VARYING") + def convert_to_canonical_data_type(data_type: Union[str, DataType, None]) -> Optional[str]: if data_type is None: @@ -9,40 +13,13 @@ def convert_to_canonical_data_type(data_type: Union[str, DataType, None]) -> Opt if isinstance(data_type, DataType): data_type = str(data_type) data_type = data_type.upper() - if data_type in ( - "NUMBER", - "DECIMAL", - "DEC", - "NUMERIC", - "INT", - "INTEGER", - "BIGINT", - "SMALLINT", - "TINYINT", - "BYTEINT", - ): + if data_type in NUMBER_TYPES: return "NUMBER(38,0)" - if data_type in ( - "FLOAT", - "FLOAT4", - "FLOAT8", - "REAL", - "DOUBLE", - "DOUBLE PRECISION", - "REAL", - ): + if data_type in FLOAT_TYPES: return "FLOAT" if data_type in ("BOOLEAN", "BOOL"): return "BOOLEAN" - if data_type in ( - "VARCHAR", - "STRING", - "TEXT", - "NVARCHAR", - "NVARCHAR2", - "CHAR VARYING", - "NCHAR VARYING", - ): + if data_type in VARCHAR_TYPES: return "VARCHAR(16777216)" if data_type in ("CHAR", "CHARACTER", "NCHAR"): return "VARCHAR(1)" @@ -55,3 +32,15 @@ def convert_to_canonical_data_type(data_type: Union[str, DataType, None]) -> Opt if data_type in ("TIME"): return "TIME(9)" return data_type + + +def convert_to_simple_data_type(data_type: str) -> str: + if data_type in NUMBER_TYPES: + return "NUMBER" + if data_type in FLOAT_TYPES: + return "FLOAT" + if data_type in ("BOOLEAN", "BOOL"): + return "BOOLEAN" + if data_type in VARCHAR_TYPES: + return "VARCHAR" + return data_type diff --git a/titan/enums.py b/titan/enums.py index 4c87f36b..08896bd0 100644 --- a/titan/enums.py +++ b/titan/enums.py @@ -62,6 +62,7 @@ class ResourceType(ParseableEnum): COMPUTE_POOL = "COMPUTE POOL" DATABASE = "DATABASE" DATABASE_ROLE = "DATABASE ROLE" + DATABASE_ROLE_GRANT = "DATABASE ROLE GRANT" DIRECTORY_TABLE = "DIRECTORY TABLE" DYNAMIC_TABLE = "DYNAMIC TABLE" EVENT_TABLE = "EVENT TABLE" @@ -81,6 +82,7 @@ class ResourceType(ParseableEnum): ICEBERG_TABLE = "ICEBERG TABLE" IMAGE_REPOSITORY = "IMAGE REPOSITORY" INTEGRATION = "INTEGRATION" + MASKING_POLICY = "MASKING POLICY" MATERIALIZED_VIEW = "MATERIALIZED VIEW" NETWORK_POLICY = "NETWORK POLICY" NETWORK_RULE = "NETWORK RULE" @@ -385,6 +387,7 @@ def resource_type_is_grant(resource_type: ResourceType) -> bool: ResourceType.ROLE_GRANT, ResourceType.GRANT_ON_ALL, ResourceType.FUTURE_GRANT, + ResourceType.DATABASE_ROLE_GRANT, ) diff --git a/titan/exceptions.py b/titan/exceptions.py index 93c04f9b..3130302f 100644 --- a/titan/exceptions.py +++ b/titan/exceptions.py @@ -48,3 +48,7 @@ class WrongEditionException(Exception): class ResourceHasContainerException(Exception): pass + + +class NotADAGException(Exception): + pass diff --git a/titan/gitops.py b/titan/gitops.py index 7f4e9c52..16fc82c6 100644 --- a/titan/gitops.py +++ b/titan/gitops.py @@ -12,6 +12,7 @@ from .identifiers import resource_label_for_type, resource_type_for_label from .resources import ( Database, + DatabaseRoleGrant, Resource, RoleGrant, Schema, @@ -68,6 +69,29 @@ def _resources_from_role_grants_config(role_grants_config: list) -> list: return resources +def _resources_from_database_role_grants_config(database_role_grants_config: list) -> list: + if len(database_role_grants_config) == 0: + return [] + resources = [] + for database_role_grant in database_role_grants_config: + if "to_role" in database_role_grant: + resources.append( + DatabaseRoleGrant( + database_role=database_role_grant["database_role"], + to_role=database_role_grant["to_role"], + ) + ) + else: + for role in database_role_grant.get("roles", []): + resources.append( + DatabaseRoleGrant( + database_role=database_role_grant["database_role"], + to_role=role, + ) + ) + return resources + + def _resources_from_database_config(databases_config: list) -> list: resources = [] for database in databases_config: @@ -111,6 +135,7 @@ def _resources_for_config(config: dict, vars: dict): # Special cases database_config = config.pop("databases", []) role_grants = config.pop("role_grants", []) + database_role_grants = config.pop("database_role_grants", []) users = config.pop("users", []) resources = [] @@ -165,6 +190,7 @@ def _resources_for_config(config: dict, vars: dict): resources.extend(_resources_from_database_config(database_config)) resources.extend(_resources_from_role_grants_config(role_grants)) + resources.extend(_resources_from_database_role_grants_config(database_role_grants)) resources.extend(_resources_from_users_config(users)) # This code helps resolve grant references to the fully qualified name of the resource. diff --git a/titan/identifiers.py b/titan/identifiers.py index bf2c6a17..77842e92 100644 --- a/titan/identifiers.py +++ b/titan/identifiers.py @@ -5,12 +5,13 @@ from .enums import ResourceType from .parse_primitives import FullyQualifiedIdentifier from .resource_name import ResourceName +from .var import VarString class FQN: def __init__( self, - name: ResourceName, + name: Union[ResourceName, VarString], database: Optional[ResourceName] = None, schema: Optional[ResourceName] = None, arg_types: Optional[list] = None, diff --git a/titan/lifecycle.py b/titan/lifecycle.py index 910d1c15..21f2cc80 100644 --- a/titan/lifecycle.py +++ b/titan/lifecycle.py @@ -73,6 +73,24 @@ def create_database(urn: URN, data: dict, props: Props, if_not_exists: bool = Fa ) +def create_database_role_grant(urn: URN, data: dict, props: Props, if_not_exists: bool = False) -> str: + if data["to_role"] is not None: + to = data["to_role"] + to_type = "ROLE" + else: + to = data["to_database_role"] + to_type = "DATABASE ROLE" + + return tidy_sql( + "GRANT", + "DATABASE ROLE", + data["database_role"], + "TO", + to_type, + to, + ) + + def create_function(urn: URN, data: dict, props: Props, if_not_exists: bool = False) -> str: db = f"{urn.fqn.database}." if urn.fqn.database else "" schema = f"{urn.fqn.schema}." if urn.fqn.schema else "" @@ -138,6 +156,17 @@ def create_grant_on_all(urn: URN, data: dict, props: Props, if_not_exists: bool) ) +def create_masking_policy(urn: URN, data: dict, props: Props, if_not_exists: bool = False) -> str: + return tidy_sql( + "CREATE", + urn.resource_type, + "IF NOT EXISTS" if if_not_exists else "", + urn.fqn, + "AS", + props.render(data), + ) + + def create_procedure(urn: URN, data: dict, props: Props, if_not_exists: bool = False) -> str: if if_not_exists: raise Exception("IF NOT EXISTS not supported for CREATE PROCEDURE") @@ -153,9 +182,19 @@ def create_procedure(urn: URN, data: dict, props: Props, if_not_exists: bool = F def create_role_grant(urn: URN, data: dict, props: Props, if_not_exists: bool = False): + if data["to_role"] is not None: + to = data["to_role"] + to_type = "ROLE" + else: + to = data["to_user"] + to_type = "USER" return tidy_sql( "GRANT", - props.render(data), + "ROLE", + data["role"], + "TO", + to_type, + to, ) @@ -391,6 +430,20 @@ def drop_database(urn: URN, data: dict, if_exists: bool) -> str: ) +def drop_database_role_grant(urn: URN, data: dict, **kwargs): + + from_type = "ROLE" if data["to_role"] else "DATABASE ROLE" + from_name = data["to_role"] if data["to_role"] else data["to_database_role"] + + return tidy_sql( + "REVOKE DATABASE ROLE", + ResourceName(data["database_role"]), + "FROM", + from_type, + ResourceName(from_name), + ) + + def drop_function(urn: URN, data: dict, if_exists: bool) -> str: return tidy_sql( "DROP", diff --git a/titan/operations/export.py b/titan/operations/export.py index 7d7f577a..5450d797 100644 --- a/titan/operations/export.py +++ b/titan/operations/export.py @@ -59,20 +59,24 @@ def export_resource(session, resource_type: ResourceType) -> dict[str, list]: logger.warning(f"Found resource {urn} in metadata but failed to fetch") continue try: - resources.append(_format_resource_config(resource, resource_type)) + resources.append(_format_resource_config(urn, resource, resource_type)) except Exception as e: logger.warning(f"Failed to format resource {urn}: {e}") continue return {pluralize(resource_label): resources} -def _format_resource_config(resource: dict, resource_type: ResourceType) -> dict: +def _format_resource_config(urn: URN, resource: dict, resource_type: ResourceType) -> dict: if resource_type == ResourceType.GRANT: return grant_yaml(resource) # Sort dict based on key name resource = {k: resource[k] for k in sorted(resource)} # Put name field at the top of the dict - first_field = {} + first_fields = {} if "name" in resource: - first_field = {"name": resource.pop("name")} - return {**first_field, **resource} + first_fields = {"name": resource.pop("name")} + + if resource_type == ResourceType.SCHEMA: + first_fields["database"] = str(urn.database().fqn) + + return {**first_fields, **resource} diff --git a/titan/resources/__init__.py b/titan/resources/__init__.py index f45faef7..5d29bbd1 100644 --- a/titan/resources/__init__.py +++ b/titan/resources/__init__.py @@ -16,10 +16,11 @@ from .failover_group import FailoverGroup from .file_format import CSVFileFormat, JSONFileFormat, ParquetFileFormat from .function import JavascriptUDF, PythonUDF -from .grant import FutureGrant, Grant, GrantOnAll, RoleGrant +from .grant import FutureGrant, Grant, GrantOnAll, RoleGrant, DatabaseRoleGrant from .hybrid_table import HybridTable from .iceberg_table import SnowflakeIcebergTable from .image_repository import ImageRepository +from .masking_policy import MaskingPolicy from .materialized_view import MaterializedView from .network_policy import NetworkPolicy from .network_rule import NetworkRule @@ -83,6 +84,7 @@ "CSVFileFormat", "Database", "DatabaseRole", + "DatabaseRoleGrant", "DynamicTable", "EmailNotificationIntegration", "EventTable", @@ -104,6 +106,7 @@ "InternalStage", "JavascriptUDF", "JSONFileFormat", + "MaskingPolicy", "MaterializedView", "NetworkPolicy", "NetworkRule", diff --git a/titan/resources/compute_pool.py b/titan/resources/compute_pool.py index 61f1528b..fd0cdec1 100644 --- a/titan/resources/compute_pool.py +++ b/titan/resources/compute_pool.py @@ -1,9 +1,8 @@ from dataclasses import dataclass, field -from ..enums import AccountEdition, ParseableEnum, ResourceType +from ..enums import AccountEdition, ResourceType from ..props import ( BoolProp, - EnumProp, IntProp, Props, StringProp, @@ -14,24 +13,13 @@ from .role import Role -class InstanceFamily(ParseableEnum): - CPU_X64_XS = "CPU_X64_XS" - CPU_X64_S = "CPU_X64_S" - CPU_X64_M = "CPU_X64_M" - CPU_X64_L = "CPU_X64_L" - CPU_X64_XL = "CPU_X64_XL" - CPU_X64_2XL = "CPU_X64_2XL" - CPU_X64_3XL = "CPU_X64_3XL" - CPU_X64_4XL = "CPU_X64_4XL" - - @dataclass(unsafe_hash=True) class _ComputePool(ResourceSpec): name: ResourceName owner: Role = "SYSADMIN" min_nodes: int = None max_nodes: int = None - instance_family: InstanceFamily = None + instance_family: str = None auto_resume: bool = True initially_suspended: bool = field(default=None, metadata={"fetchable": False}) auto_suspend_secs: int = 3600 @@ -51,7 +39,7 @@ class ComputePool(NamedResource, Resource): owner (string or Role): The owner of the compute pool. Defaults to "ACCOUNTADMIN". min_nodes (int): The minimum number of nodes in the compute pool. max_nodes (int): The maximum number of nodes in the compute pool. - instance_family (string or InstanceFamily): The family of instances to use for the compute nodes. + instance_family (string): The family of instances to use for the compute nodes. auto_resume (bool): Whether the compute pool should automatically resume when queries are submitted. Defaults to True. initially_suspended (bool): Whether the compute pool should start in a suspended state. auto_suspend_secs (int): The number of seconds of inactivity after which the compute pool should automatically suspend. Defaults to 3600. @@ -92,7 +80,7 @@ class ComputePool(NamedResource, Resource): props = Props( min_nodes=IntProp("min_nodes"), max_nodes=IntProp("max_nodes"), - instance_family=EnumProp("instance_family", InstanceFamily), + instance_family=StringProp("instance_family"), auto_resume=BoolProp("auto_resume"), initially_suspended=BoolProp("initially_suspended"), auto_suspend_secs=IntProp("auto_suspend_secs"), @@ -107,7 +95,7 @@ def __init__( owner: str = "SYSADMIN", min_nodes: int = None, max_nodes: int = None, - instance_family: InstanceFamily = None, + instance_family: str = None, auto_resume: bool = True, initially_suspended: bool = None, auto_suspend_secs: int = 3600, diff --git a/titan/resources/grant.py b/titan/resources/grant.py index 5a711416..582db8c9 100644 --- a/titan/resources/grant.py +++ b/titan/resources/grant.py @@ -13,7 +13,7 @@ from ..role_ref import RoleRef from ..scope import AccountScope from .resource import NamedResource, Resource, ResourcePointer, ResourceSpec -from .role import Role +from .role import Role, DatabaseRole from .user import User logger = logging.getLogger("titan") @@ -338,7 +338,6 @@ def __init__( priv: str, to: Role, grant_option: bool = False, - owner: str = None, **kwargs, ): on_type = kwargs.pop("on_type", None) @@ -347,17 +346,15 @@ def __init__( to_type = kwargs.pop("to_type", None) granted_in_ref = None - if all([on_type, in_type, in_name]): - in_type = ResourceType(in_type) - on_type = ResourceType(on_type) - granted_in_ref = ResourcePointer(name=in_name, resource_type=in_type) - if all([to_type, to]): to_type = ResourceType(to_type) to = ResourcePointer(name=to, resource_type=to_type) + if all([on_type, in_type, in_name]): + in_type = ResourceType(in_type) + on_type = ResourceType(on_type) + granted_in_ref = ResourcePointer(name=in_name, resource_type=in_type) else: - # Collect on_ kwargs on_kwargs = {} for keyword, _ in kwargs.copy().items(): @@ -625,8 +622,8 @@ def grant_on_all_fqn(data: _GrantOnAll): @dataclass(unsafe_hash=True) class _RoleGrant(ResourceSpec): - role: RoleRef - to_role: RoleRef = None + role: Role + to_role: Role = None to_user: User = None def __post_init__(self): @@ -758,3 +755,92 @@ def role_grant_fqn(role_grant: _RoleGrant): name=role_grant.role.name, params={subject: name}, ) + + +@dataclass(unsafe_hash=True) +class _DatabaseRoleGrant(ResourceSpec): + database_role: DatabaseRole + to_role: Role = None + to_database_role: DatabaseRole = None + + def __post_init__(self): + super().__post_init__() + if self.to_role is not None and self.to_database_role is not None: + raise ValueError("You can only grant to a role or a database role, not both") + if self.to_role is None and self.to_database_role is None: + raise ValueError("You must specify a role or a database role to grant to") + + +class DatabaseRoleGrant(Resource): + + resource_type = ResourceType.DATABASE_ROLE_GRANT + props = Props( + database_role=IdentifierProp("database role", eq=False), + to_role=IdentifierProp("to role", eq=False), + ) + scope = AccountScope() + spec = _DatabaseRoleGrant + + def __init__( + self, + database_role: str, + to_role: str = None, + to_database_role: str = None, + **kwargs, + ): + + to = kwargs.pop("to", None) + if to: + if to_role or to_database_role: + raise ValueError("You cant specify both to_role and to_database_role") + if isinstance(to, Role): + to_role = to + elif isinstance(to, DatabaseRole): + to_database_role = to + else: + raise ValueError("You can only grant to a role") + + super().__init__(**kwargs) + self._data: _DatabaseRoleGrant = _DatabaseRoleGrant( + database_role=database_role, + to_role=to_role, + to_database_role=to_database_role, + ) + self.requires( + self._data.database_role, + self._data.to_role, + self._data.to_database_role, + ) + + def __repr__(self): # pragma: no cover + database_role = getattr(self._data, "database_role", "") + to_role = getattr(self._data, "to_role", "") + to_database_role = getattr(self._data, "to_database_role", "") + to = to_role or to_database_role + return f"DatabaseRoleGrant(database_role={database_role}, to={to})" + + @property + def fqn(self): + return database_role_grant_fqn(self._data) + + @property + def database_role(self) -> DatabaseRole: + return self._data.database_role + + @property + def to(self) -> Union[Role, DatabaseRole]: + return self._data.to_role or self._data.to_database_role + + +def database_role_grant_fqn(database_role_grant: _DatabaseRoleGrant): + subject = "role" if database_role_grant.to_role else "database_role" + name = ( + database_role_grant.to_role.name + if database_role_grant.to_role + else str(database_role_grant.to_database_role.fqn) + ) + return FQN( + name=database_role_grant.database_role.name, + database=database_role_grant.database_role.database, + params={subject: name}, + ) diff --git a/titan/resources/masking_policy.py b/titan/resources/masking_policy.py new file mode 100644 index 00000000..017d3ca4 --- /dev/null +++ b/titan/resources/masking_policy.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass + +from titan.enums import AccountEdition, ResourceType +from titan.props import Props, StringProp, BoolProp, ArgsProp, ReturnsProp, QueryProp +from titan.scope import SchemaScope +from titan.resource_name import ResourceName +from titan.resources.resource import Arg, NamedResource, Resource, ResourceSpec +from titan.role_ref import RoleRef +from titan.data_types import convert_to_canonical_data_type + + +@dataclass(unsafe_hash=True) +class _MaskingPolicy(ResourceSpec): + name: ResourceName + args: list[Arg] + returns: str + body: str + comment: str = None + exempt_other_policies: bool = False + owner: RoleRef = "SYSADMIN" + + def __post_init__(self): + super().__post_init__() + if len(self.args) == 0: + raise ValueError("At least one argument is required") + self.returns = convert_to_canonical_data_type(self.returns) + + +class MaskingPolicy(NamedResource, Resource): + edition = {AccountEdition.ENTERPRISE, AccountEdition.BUSINESS_CRITICAL} + resource_type = ResourceType.MASKING_POLICY + props = Props( + args=ArgsProp(), + returns=ReturnsProp("returns", eq=False), + body=QueryProp("->"), + comment=StringProp("comment"), + exempt_other_policies=BoolProp("exempt_other_policies"), + ) + scope = SchemaScope() + spec = _MaskingPolicy + + def __init__( + self, + name: str, + args: list[dict], + returns: str, + body: str, + comment: str = None, + exempt_other_policies: bool = False, + owner: str = "SYSADMIN", + **kwargs, + ): + super().__init__(name, **kwargs) + self._data: _MaskingPolicy = _MaskingPolicy( + name=self._name, + args=args, + returns=returns, + body=body, + comment=comment, + exempt_other_policies=exempt_other_policies, + owner=owner, + ) diff --git a/titan/resources/resource.py b/titan/resources/resource.py index 0005ce3f..2143a9bc 100644 --- a/titan/resources/resource.py +++ b/titan/resources/resource.py @@ -1,4 +1,5 @@ import difflib +import logging import sys import types from dataclasses import dataclass, field, fields @@ -9,6 +10,8 @@ import pyparsing as pp +from titan.data_types import convert_to_simple_data_type + from ..enums import AccountEdition, DataType, ParseableEnum, ResourceType from ..exceptions import ResourceHasContainerException, WrongContainerException, WrongEditionException from ..identifiers import FQN, URN, parse_identifier, resource_label_for_type @@ -28,6 +31,8 @@ ) from ..var import VarString, string_contains_var +logger = logging.getLogger("titan") + def _suggest_correct_kwargs(expected_kwargs, passed_kwargs): suggestions = {} @@ -110,7 +115,7 @@ def _coerce_resource_field(field_value, field_type): elif field_type is Arg: arg_dict = { "name": field_value["name"].upper(), - "data_type": DataType(field_value["data_type"]), + "data_type": convert_to_simple_data_type(field_value["data_type"]), } if "default" in field_value: arg_dict["default"] = field_value["default"] @@ -321,6 +326,7 @@ def __init__( @classmethod def from_sql(cls, sql): + logger.warning("Resource.from_sql will be deprecated in a future release") resource_cls = cls if resource_cls == Resource: # FIXME: we need to change the way we handle polymorphic resources @@ -611,7 +617,6 @@ def __init__(self, name: Union[str, ResourceName], resource_type: ResourceType): # If this points to a database, assume it includes a PUBLIC schema if self._resource_type == ResourceType.DATABASE and self._name != "SNOWFLAKE": self.add(ResourcePointer(name="PUBLIC", resource_type=ResourceType.SCHEMA)) - # self.add(ResourcePointer(name="INFORMATION_SCHEMA", resource_type=ResourceType.SCHEMA)) def __repr__(self): # pragma: no cover resource_type = getattr(self, "resource_type", None) @@ -631,6 +636,13 @@ def __hash__(self): def container(self): return self._container + @property + def database(self): + if isinstance(self.scope, DatabaseScope): + return self.container.name + else: + raise ValueError("ResourcePointer does not have a database") + @property def fqn(self): return self.scope.fully_qualified_name(self.container, self.name) @@ -699,6 +711,7 @@ def convert_role_ref(role_ref: RoleRef) -> Resource: ResourceType.ROLE, ): return role_ref + elif isinstance(role_ref, str) or isinstance(role_ref, ResourceName): return ResourcePointer(name=role_ref, resource_type=infer_role_type_from_name(role_ref)) else: diff --git a/titan/resources/role.py b/titan/resources/role.py index df4575e3..a30d0d3e 100644 --- a/titan/resources/role.py +++ b/titan/resources/role.py @@ -154,3 +154,7 @@ def __init__( comment=comment, ) self.set_tags(tags) + + @property + def database(self): + return self._data.database diff --git a/titan/resources/task.py b/titan/resources/task.py index 4ce36ca9..c65e4ea7 100644 --- a/titan/resources/task.py +++ b/titan/resources/task.py @@ -16,7 +16,7 @@ from ..resource_name import ResourceName from ..role_ref import RoleRef from ..scope import SchemaScope -from .resource import NamedResource, Resource, ResourceSpec +from .resource import NamedResource, Resource, ResourceSpec, convert_to_resource from .warehouse import Warehouse @@ -54,6 +54,9 @@ def __post_init__(self): # Set default only if non-child task self.suspend_task_after_num_failures = 10 + if self.after is not None: + self.after = [convert_to_resource(Task, task) for task in self.after] + class Task(NamedResource, Resource): """ @@ -162,3 +165,5 @@ def __init__( as_=as_, state=state, ) + if self._data.after is not None: + self.requires(*self._data.after) diff --git a/tools/test_account_configs/base.yml b/tools/test_account_configs/base.yml index c825c27f..f834d707 100644 --- a/tools/test_account_configs/base.yml +++ b/tools/test_account_configs/base.yml @@ -4,12 +4,12 @@ allowlist: - "account parameter" - "catalog integration" - "database role" + - "database role grant" - "database" - "external volume" - "future grant" - "grant" - "iceberg table" - - "network policy" - "network rule" - "resource monitor" - "role grant" @@ -110,10 +110,10 @@ role_grants: roles: - SYSADMIN -# database_role_grants: -# - role: static_database_role -# roles: -# - SYSADMIN +database_role_grants: + - database_role: static_database.static_database_role + roles: + - SYSADMIN network_rules: - name: static_network_rule @@ -330,3 +330,4 @@ secrets: # - name: THREAT_INTELLIGENCE # enabled: true # schedule: "0 0 * * * UTC" + diff --git a/tools/test_account_configs/enterprise.yml b/tools/test_account_configs/enterprise.yml index 28fce0eb..afa987ec 100644 --- a/tools/test_account_configs/enterprise.yml +++ b/tools/test_account_configs/enterprise.yml @@ -1,6 +1,7 @@ allowlist: - "tag" - "tag reference" + - "masking policy" grants: - GRANT APPLY AGGREGATION POLICY ON ACCOUNT TO ROLE EVERY_PRIVILEGE @@ -21,3 +22,16 @@ tags: comment: This is a static tag allowed_values: - STATIC_TAG_VALUE + +masking_policies: + - name: static_masking_policy + database: static_database + schema: public + args: + - name: email + data_type: VARCHAR + returns: VARCHAR(16777216) + body: "CASE WHEN current_role() IN ('ANALYST') THEN email ELSE '*********' END" + comment: Masks email addresses + exempt_other_policies: false + owner: SYSADMIN diff --git a/version.md b/version.md index 9e78f6ef..e87ebf6e 100644 --- a/version.md +++ b/version.md @@ -1 +1 @@ -# version 0.10.22 +# version 0.11.0