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
11 changes: 11 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ History
1.4.0 (2026-09-25)
------------------

* **Breaking:** Snowflake masking now stages inside Snowflake, so ``SnowflakeConnectionConfig``
no longer has the external staging fields ``s3_bucket_name``, ``iam_role_arn``,
``snowflake_azure_container_name``, ``snowflake_azure_connection_string``,
``snowflake_azure_connection_string_encrypted`` and ``snowflake_storage_integration_name``.
``snowflake_stage_location`` and the ``SnowflakeStageLocation`` enum are removed: the server
detects a Snowflake SPCS deployment from its own environment, so the client no longer states it.

* Snowflake connections listed from an older server still parse: the removed fields and
``snowflake_stage_location`` are dropped rather than raising a validation error, and are not sent
back on ``create_or_update_connection``.

* Added ``LicenseLock`` (``locked_at`` and ``editable_from``) and a read-only ``license_lock`` field on connection configs.
The server reports it only on instances whose license caps the number of connections
and counts that connection's type against the cap,
Expand Down
2 changes: 0 additions & 2 deletions datamasque/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
MssqlLinkedServerConnectionConfig,
S3ConnectionConfig,
SnowflakeConnectionConfig,
SnowflakeStageLocation,
SseConfig,
SseSelection,
)
Expand Down Expand Up @@ -298,7 +297,6 @@
"SelectedFileData",
"SnowflakeConnectionConfig",
"SnowflakeKeyFile",
"SnowflakeStageLocation",
"SseConfig",
"SseSelection",
"SslZipFile",
Expand Down
42 changes: 20 additions & 22 deletions datamasque/client/models/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,6 @@ class DatabaseType(Enum):
saphana = "saphana"


class SnowflakeStageLocation(str, Enum):
"""Storage backend for a Snowflake connection's external stage."""

local = "local" # Not supported for production use
aws_s3 = "aws_s3"
azure_blob_storage = "azure_blob_storage"
spcs = "spcs" # DataMasque running inside Snowflake SPCS; staged on the container's own storage


class SseSelection(Enum):
"""Mirrors the available options in the AWS console for DynamoDB Server-Side Encryption."""

Expand Down Expand Up @@ -285,6 +276,19 @@ def database_type(self) -> DatabaseType:
return DatabaseType.cosmosdb


# Servers before 3.26.18 still return these on Snowflake connections. With `extra="allow"`
# they would be sent back on create/update unless dropped here.
_SNOWFLAKE_EXTERNAL_STAGE_KEYS = (
"s3_bucket_name",
"iam_role_arn",
"snowflake_azure_container_name",
"snowflake_azure_connection_string",
"snowflake_azure_connection_string_encrypted",
"snowflake_storage_integration_name",
"snowflake_stage_location",
)


class SnowflakeConnectionConfig(ConnectionConfig):
"""
Connection configuration for a Snowflake database.
Expand All @@ -294,14 +298,14 @@ class SnowflakeConnectionConfig(ConnectionConfig):
"""

database: str
# Optional because DataMasque-in-SPCS connections leave these unset: the agent uses the
# container's OAuth token + SNOWFLAKE_HOST/SNOWFLAKE_ACCOUNT env and the app-owned QUERY_WAREHOUSE,
# so user/account/storage-integration/warehouse are null for stage_location=spcs. Mirrors the app's
# canonical model (agent .../schemas/connection/connection.py), which types these `| None = None`.
# Optional because connections leave these unset when DataMasque runs inside Snowflake SPCS:
# the server detects SPCS from its environment and the agent uses the container's OAuth token,
# the SNOWFLAKE_HOST/SNOWFLAKE_ACCOUNT env and the app-owned QUERY_WAREHOUSE, so user/account/
# warehouse are None. Mirrors the app's canonical model
# (agent .../schemas/connection/connection.py), which types these `| None = None`.
user: Optional[str] = None
snowflake_account_id: Optional[str] = None
snowflake_warehouse: Optional[str] = None
snowflake_storage_integration_name: Optional[str] = None
host: str = ""
port: Optional[int] = None
db_schema: Optional[str] = Field(default=None, alias="schema")
Expand All @@ -310,12 +314,6 @@ class SnowflakeConnectionConfig(ConnectionConfig):
password: Optional[str] = None
snowflake_private_key: Optional[FileId] = None
snowflake_private_key_passphrase: Optional[str] = None
snowflake_stage_location: Optional[SnowflakeStageLocation] = None
s3_bucket_name: Optional[str] = None
iam_role_arn: Optional[str] = None
snowflake_azure_container_name: Optional[str] = None
snowflake_azure_connection_string: Optional[str] = None
snowflake_azure_connection_string_encrypted: Optional[str] = None

mask_type: Literal["database"] = "database"
db_type: Literal["snowflake"] = "snowflake"
Expand All @@ -338,9 +336,9 @@ def _serialize(self, handler: Callable) -> dict:

@model_validator(mode="before")
@classmethod
def _strip_encrypted_password(cls, data: dict) -> dict:
def _strip_server_only_fields(cls, data: dict) -> dict:
if isinstance(data, dict):
for key in ("password_encrypted", "dbpassword"):
for key in ("password_encrypted", "dbpassword", *_SNOWFLAKE_EXTERNAL_STAGE_KEYS):
data.pop(key, None)
return data

Expand Down
37 changes: 2 additions & 35 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
DatabaseType,
S3ConnectionConfig,
SnowflakeConnectionConfig,
SnowflakeStageLocation,
)

fake = Faker()
Expand Down Expand Up @@ -117,44 +116,12 @@ def make_ok_response() -> Response:
return response


def snowflake_connection_config_s3():
def snowflake_connection_config():
return SnowflakeConnectionConfig(
name="snowflake_s3",
name="snowflake",
database="test_db",
user="snowflake_user",
snowflake_account_id="ACCOUNT-123",
snowflake_warehouse="test_warehouse",
snowflake_storage_integration_name="test_integration",
password="test_password",
snowflake_stage_location=SnowflakeStageLocation.aws_s3,
s3_bucket_name="test-bucket",
iam_role_arn="arn:aws:iam::123456789012:role/test-role",
)


def snowflake_connection_config_azure():
return SnowflakeConnectionConfig(
name="snowflake_azure",
database="test_db",
user="snowflake_user",
snowflake_account_id="ACCOUNT-456",
snowflake_warehouse="test_warehouse",
snowflake_storage_integration_name="test_integration",
password="test_password",
snowflake_stage_location=SnowflakeStageLocation.azure_blob_storage,
snowflake_azure_container_name="test-container",
snowflake_azure_connection_string="DefaultEndpointsProtocol=https;AccountName=test;AccountKey=test",
)


def snowflake_connection_config_local():
return SnowflakeConnectionConfig(
name="snowflake_local",
database="test_db",
user="snowflake_user",
snowflake_account_id="ACCOUNT-789",
snowflake_warehouse="test_warehouse",
snowflake_storage_integration_name="test_integration",
password="test_password",
snowflake_stage_location=SnowflakeStageLocation.local,
)
Loading
Loading