Skip to content

Commit

Permalink
[FEATURE] scanner package resource (#139)
Browse files Browse the repository at this point in the history
* scanner package resource

---------

Co-authored-by: TJ Murphy <[email protected]>
  • Loading branch information
teej and teej authored Oct 30, 2024
1 parent 29b4cf8 commit 59ee51f
Show file tree
Hide file tree
Showing 12 changed files with 153 additions and 9 deletions.
5 changes: 5 additions & 0 deletions tests/fixtures/json/scanner_package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "CIS_BENCHMARKS",
"enabled": true,
"schedule": "0 0 0 * * UTC"
}
11 changes: 6 additions & 5 deletions tests/integration/data_provider/test_list_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

from tests.helpers import get_json_fixtures
from titan import data_provider
from titan import resources as res
from titan.client import UNSUPPORTED_FEATURE, reset_cache
from titan.identifiers import resource_label_for_type
from titan.resources import AccountParameter, Database, Resource
from titan.resources import Resource
from titan.scope import DatabaseScope, SchemaScope

pytestmark = pytest.mark.requires_snowflake
Expand All @@ -26,13 +27,13 @@
)
def resource(request, suffix):
resource_cls, data = request.param
if "name" in data and resource_cls != AccountParameter:
if "name" in data and resource_cls not in (res.AccountParameter, res.ScannerPackage):
data["name"] += f"_{suffix}_list_resources"
if "login_name" in data:
data["login_name"] += f"_{suffix}_list_resources"
res = resource_cls(**data)
resource = resource_cls(**data)

yield res
yield resource


def create(cursor, resource: Resource):
Expand All @@ -48,7 +49,7 @@ def create(cursor, resource: Resource):

@pytest.fixture(scope="session")
def list_resources_database(cursor, suffix, marked_for_cleanup):
db = Database(name=f"list_resources_test_database_{suffix}")
db = res.Database(name=f"list_resources_test_database_{suffix}")
cursor.execute(db.create_sql(if_not_exists=True))
marked_for_cleanup.append(db)
yield db
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from titan.blueprint import Blueprint, CreateResource
from titan.client import FEATURE_NOT_ENABLED_ERR, UNSUPPORTED_FEATURE
from titan.data_provider import fetch_session
from titan.enums import AccountEdition
from titan.client import reset_cache
from titan.scope import DatabaseScope, SchemaScope

JSON_FIXTURES = list(get_json_fixtures())
Expand Down Expand Up @@ -67,6 +67,7 @@ def test_create_drop_from_json(resource, cursor, suffix):
database.public_schema.add(resource)

fetch_session.cache_clear()
reset_cache()
blueprint = Blueprint()
blueprint.add(resource)
plan = blueprint.plan(cursor.connection)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_identities.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ def test_data_identity(resource):

def test_sql_identity(resource: tuple[type[Resource], dict]):
resource_cls, data = resource
if resource_cls.__name__ == "ScannerPackage":
pytest.skip("Skipping scanner package")
instance = resource_cls(**data)
sql = instance.create_sql(AccountEdition.ENTERPRISE)
new = resource_cls.from_sql(sql)
Expand Down
8 changes: 8 additions & 0 deletions titan/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,11 @@ def execution_strategy_for_change(
return ResourceName("ACCOUNTADMIN"), False
raise MissingPrivilegeException("ACCOUNTADMIN role is required to work with account parameters")

elif change.urn.resource_type == ResourceType.SCANNER_PACKAGE:
if "ACCOUNTADMIN" in available_roles:
return ResourceName("ACCOUNTADMIN"), False
raise MissingPrivilegeException("ACCOUNTADMIN role is required to work with scanner packages")

elif isinstance(change, (UpdateResource, DropResource, TransferOwnership)):
if change_owner:
return change_owner, False
Expand Down Expand Up @@ -1125,6 +1130,9 @@ def sql_commands_for_change(
copy_current_grants=True,
)
)

if change.urn.resource_type == ResourceType.SCANNER_PACKAGE:
after_change_cmd.append(lifecycle.update_resource(change.urn, {}, change.resource_cls.props))
elif isinstance(change, UpdateResource):
props = Resource.props_for_resource_type(change.urn.resource_type, change.after)
change_cmd = lifecycle.update_resource(change.urn, change.delta, props)
Expand Down
30 changes: 30 additions & 0 deletions titan/data_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,26 @@ def fetch_role_grant(session: SnowflakeConnection, fqn: FQN):
return None


def fetch_scanner_package(session: SnowflakeConnection, fqn: FQN):
scanner_packages = execute(
session,
f"select * from snowflake.trust_center.scanner_packages where ID = '{fqn.name}' and STATE = 'TRUE'",
cacheable=True,
)
if len(scanner_packages) == 0:
return None
if len(scanner_packages) > 1:
raise Exception(f"Found multiple scanner packages matching {fqn}")

data = scanner_packages[0]

return {
"name": _quote_snowflake_identifier(data["ID"]),
"enabled": data["STATE"] == "TRUE",
"schedule": data["SCHEDULE"][11:],
}


def fetch_schema(session: SnowflakeConnection, fqn: FQN):
if fqn.database is None:
raise Exception(f"Schema {fqn} is missing a database name")
Expand Down Expand Up @@ -2465,6 +2485,16 @@ def list_role_grants(session: SnowflakeConnection) -> list[FQN]:
return grants


def list_scanner_packages(session: SnowflakeConnection) -> list[FQN]:
scanner_packages = execute(session, "select * from snowflake.trust_center.scanner_packages WHERE state = 'TRUE'")
user_packages = []
for pkg in scanner_packages:
if pkg["ID"] == "SECURITY_ESSENTIALS":
continue
user_packages.append(FQN(name=resource_name_from_snowflake_metadata(pkg["ID"])))
return user_packages


def list_schemas(session: SnowflakeConnection, database=None) -> list[FQN]:
if database:
in_ctx = f"DATABASE {database}"
Expand Down
1 change: 1 addition & 0 deletions titan/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ class ResourceType(ParseableEnum):
RESOURCE_MONITOR = "RESOURCE MONITOR"
ROLE = "ROLE"
ROLE_GRANT = "ROLE GRANT"
SCANNER_PACKAGE = "SCANNER PACKAGE"
SCHEMA = "SCHEMA"
SECRET = "SECRET"
SECURITY_INTEGRATION = "SECURITY INTEGRATION"
Expand Down
44 changes: 43 additions & 1 deletion titan/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,24 @@ 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):
def create_role_grant(urn: URN, data: dict, props: Props, if_not_exists: bool = False):
return tidy_sql(
"GRANT",
props.render(data),
)


def create_scanner_package(urn: URN, data: dict, props: Props, if_not_exists: bool = False) -> str:
package_name = f"'{urn.fqn.name}'"
return tidy_sql(
"CALL SNOWFLAKE.TRUST_CENTER.SET_CONFIGURATION(",
"'ENABLED',",
"'TRUE',",
package_name,
")",
)


def create_schema(urn: URN, data: dict, props: Props, if_not_exists: bool = False) -> str:
data = data.copy()
transient = data.pop("transient", None)
Expand Down Expand Up @@ -261,6 +272,23 @@ def update_role_grant(urn: URN, data: dict, props: Props) -> str:
raise NotImplementedError


def update_scanner_package(urn: URN, data: dict, props: Props) -> str:
package_name = f"'{urn.fqn.name}'"
attr, new_value = data.popitem()
if attr == "schedule":
new_value = f"'USING CRON {new_value}'"
else:
new_value = f"'{new_value}'"
return tidy_sql(
"CALL SNOWFLAKE.TRUST_CENTER.SET_CONFIGURATION(",
f"'{attr}',",
new_value,
",",
package_name,
")",
)


def update_schema(urn: URN, data: dict, props: Props) -> str:
attr, new_value = data.popitem()
attr = attr.lower()
Expand Down Expand Up @@ -403,6 +431,20 @@ def drop_role_grant(urn: URN, data: dict, **kwargs):
)


def drop_scanner_package(urn: URN, data: dict, **kwargs) -> str:
package_name = f"'{urn.fqn.name}'"
return tidy_sql(
"CALL SNOWFLAKE.TRUST_CENTER.SET_CONFIGURATION(",
"'ENABLED',",
"'FALSE',",
package_name,
")",
)


################ Transfer functions


def transfer_resource(
urn: URN,
owner: str,
Expand Down
2 changes: 2 additions & 0 deletions titan/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
from .table import Table # , CreateTableAsSelect
from .tag import Tag, TagReference
from .task import Task
from .scanner_package import ScannerPackage
from .user import User
from .view import View
from .warehouse import Warehouse
Expand Down Expand Up @@ -122,6 +123,7 @@
"Role",
"RoleGrant",
"S3StorageIntegration",
"ScannerPackage",
"Schema",
"Sequence",
"Service",
Expand Down
5 changes: 4 additions & 1 deletion titan/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,10 @@ def to_dict(self, _=None):
}


def convert_to_resource(cls: Resource, resource_or_descriptor: Union[str, dict, Resource, ResourceName]) -> Resource:
def convert_to_resource(
cls: type[Resource],
resource_or_descriptor: Union[str, dict, Resource, ResourceName],
) -> Resource:
"""
This function helps provide flexibility to users on how Resource fields are set.
The most common use case is to allow a user to pass in a string of the name of a resource without
Expand Down
43 changes: 43 additions & 0 deletions titan/resources/scanner_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from dataclasses import dataclass

from ..enums import ResourceType
from ..props import (
Props,
)
from ..resource_name import ResourceName
from ..scope import AccountScope
from .resource import NamedResource, Resource, ResourceSpec


@dataclass(unsafe_hash=True)
class _ScannerPackage(ResourceSpec):
name: ResourceName
enabled: bool = True
schedule: str = "0 0 * * * UTC"

def __post_init__(self):
super().__post_init__()
if self.name == "SECURITY_ESSENTIALS":
raise ValueError("SECURITY_ESSENTIALS is a system scanner package and cannot be used")


class ScannerPackage(NamedResource, Resource):

resource_type = ResourceType.SCANNER_PACKAGE
props = Props()
scope = AccountScope()
spec = _ScannerPackage

def __init__(
self,
name: str,
enabled: bool = True,
schedule: str = "0 0 * * * UTC",
**kwargs,
):
super().__init__(name, **kwargs)
self._data: _ScannerPackage = _ScannerPackage(
name=self._name,
enabled=enabled,
schedule=schedule,
)
8 changes: 7 additions & 1 deletion tools/test_account.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ allowlist:
- "resource monitor"
- "role grant"
- "role"
- "scanner package"
- "schema"
- "secret"
- "security integration"
Expand Down Expand Up @@ -283,4 +284,9 @@ secrets:
username: someuser
password: somepass
database: static_database
schema: public
schema: public

scanner_packages:
- name: THREAT_INTELLIGENCE
enabled: true
schedule: "0 0 * * * UTC"

0 comments on commit 59ee51f

Please sign in to comment.