Skip to content

Commit d734074

Browse files
authored
Merge pull request #27 from datamasque/dm-3656-sfpat-from-ait-to-dmpython
feat: add spcs_pat for Snowflake SPCS gateway auth (DM-3656)
2 parents b74da57 + a644ab5 commit d734074

13 files changed

Lines changed: 460 additions & 6 deletions

File tree

HISTORY.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22
History
33
=======
44

5+
1.1.5 (2026-06-29)
6+
------------------
7+
8+
* Added support for DataMasque deployments on Snowpark Container Services (SPCS):
9+
10+
* Added ``spcs_pat`` to ``DataMasqueInstanceConfig`` for authenticating through the SPCS app gateway.
11+
* Added ``SpcsGatewayAuthError``, raised when the gateway rejects the PAT.
12+
* Added the ``spcs`` option to ``SnowflakeStageLocation``.
13+
* Made several ``SnowflakeConnectionConfig`` fields optional, since SPCS-staged connections leave them unset.
14+
515
1.1.4 (2026-06-29)
616
------------------
717

README.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ Authentication is performed on the first request if ``authenticate()`` is not ca
4242
and is automatically retried once on a 401 response.
4343
``client.healthcheck()`` is available as a lightweight readiness probe that does not consume credentials.
4444

45+
For a DataMasque instance hosted on Snowpark Container Services (SPCS)
46+
(a ``*.snowflakecomputing.app`` ``base_url``),
47+
pass a Snowflake Programmatic Access Token as ``spcs_pat`` on ``DataMasqueInstanceConfig``.
48+
See the `usage docs <https://datamasque-python.readthedocs.io/en/latest/usage.html>`_ for details.
49+
4550
Error handling
4651
==============
4752

datamasque/client/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
DataMasqueTransportError,
2222
)
2323
from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
24+
from datamasque.client.spcs import install_spcs_gateway_auth
2425

2526
logger = logging.getLogger(__name__)
2627

@@ -137,6 +138,8 @@ def __init__(self, connection_config: DataMasqueInstanceConfig) -> None:
137138
self.verify_ssl = connection_config.verify_ssl
138139
self.token_source = connection_config.token_source
139140
self._session = _build_session(self.verify_ssl)
141+
if connection_config.spcs_pat:
142+
install_spcs_gateway_auth(self._session, connection_config.spcs_pat)
140143

141144
@contextmanager
142145
def _maybe_suppress_insecure_warning(self) -> Iterator[None]:

datamasque/client/exceptions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ class IfmAuthError(DataMasqueIfmError):
8484
"""Raised when the IFM client cannot obtain or refresh a JWT (e.g. invalid credentials, missing scope)."""
8585

8686

87+
class SpcsGatewayAuthError(DataMasqueException):
88+
"""
89+
Raised when a Snowflake SPCS app gateway rejects the configured `spcs_pat`.
90+
91+
The message includes the Snowflake-provided detail, request id,
92+
and a hint at the likely cause
93+
(for example an expired token, or a network policy that excludes your IP).
94+
95+
Deliberately a direct subclass of `DataMasqueException` rather than
96+
`DataMasqueApiError`:
97+
the client's 401 re-authenticate-and-retry path keys off `DataMasqueApiError`,
98+
so keeping this outside that subtree
99+
ensures a gateway rejection aborts immediately instead of looping.
100+
"""
101+
102+
87103
class RunNotCancellableError(DataMasqueUserError):
88104
"""
89105
Raised when `cancel_run` is called against a run that is no longer eligible for cancellation.

datamasque/client/models/connection.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class SnowflakeStageLocation(str, Enum):
5757
local = "local" # Not supported for production use
5858
aws_s3 = "aws_s3"
5959
azure_blob_storage = "azure_blob_storage"
60+
spcs = "spcs" # DataMasque running inside Snowflake SPCS; staged on the container's own storage
6061

6162

6263
class SseSelection(Enum):
@@ -234,10 +235,14 @@ class SnowflakeConnectionConfig(ConnectionConfig):
234235
"""
235236

236237
database: str
237-
user: str
238-
snowflake_account_id: str
239-
snowflake_warehouse: str
240-
snowflake_storage_integration_name: str
238+
# Optional because DataMasque-in-SPCS connections leave these unset: the agent uses the
239+
# container's OAuth token + SNOWFLAKE_HOST/SNOWFLAKE_ACCOUNT env and the app-owned QUERY_WAREHOUSE,
240+
# so user/account/storage-integration/warehouse are null for stage_location=spcs. Mirrors the app's
241+
# canonical model (agent .../schemas/connection/connection.py), which types these `| None = None`.
242+
user: Optional[str] = None
243+
snowflake_account_id: Optional[str] = None
244+
snowflake_warehouse: Optional[str] = None
245+
snowflake_storage_integration_name: Optional[str] = None
241246
host: str = ""
242247
port: Optional[int] = None
243248
db_schema: Optional[str] = Field(default=None, alias="schema")

datamasque/client/models/dm_instance.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ class DataMasqueInstanceConfig(BaseModel):
2020
the client prepends it with `Token ` when sending the `Authorization` header.
2121
The client calls `token_source` on each authentication attempt,
2222
so the callable is free to fetch and refresh tokens out-of-band (e.g. from a secrets manager).
23+
24+
`spcs_pat` is an optional Snowflake Programmatic Access Token
25+
for reaching a DataMasque instance hosted on Snowpark Container Services (SPCS).
26+
It layers underneath whichever DataMasque auth method
27+
(`password` or `token_source`) you choose.
2328
"""
2429

2530
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -29,6 +34,14 @@ class DataMasqueInstanceConfig(BaseModel):
2934
password: Optional[str] = None
3035
verify_ssl: bool = True
3136
token_source: Optional[Callable[[], str]] = None
37+
spcs_pat: Optional[str] = None
38+
"""Snowflake Programmatic Access Token
39+
for a DataMasque instance hosted on Snowpark Container Services (SPCS),
40+
where `base_url` ends in `.snowflakecomputing.app`.
41+
42+
Create the token in Snowsight (User profile → Programmatic access tokens)
43+
for an account that can reach the SPCS app.
44+
Leave unset for instances that are not hosted on SPCS."""
3245

3346
@model_validator(mode="after")
3447
def _validate_auth_source(self) -> "DataMasqueInstanceConfig":

datamasque/client/spcs.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
"""
2+
Snowflake SPCS app gateway authentication for `DataMasqueClient`.
3+
4+
When a DataMasque instance is hosted on Snowpark Container Services (SPCS),
5+
its app ingress (`*.snowflakecomputing.app`) fronts every request
6+
with a Snowflake gateway that must be cleared first.
7+
We authenticate to the gateway with a Programmatic Access Token (PAT),
8+
sent on `X-SF-SPCS-Authorization: Snowflake Token="<PAT>"`.
9+
The gateway accepts the PAT on this alternate header
10+
and strips it before forwarding to the container,
11+
so DataMasque's own `Authorization: Token <key>` flow rides through untouched.
12+
13+
`install_spcs_gateway_auth` attaches this behaviour to a client's `requests.Session`:
14+
it sets the header on the session
15+
(so it is sent on every request, including the unauthenticated login)
16+
and registers a response hook
17+
that turns a gateway-originated rejection into a clear `SpcsGatewayAuthError`.
18+
"""
19+
20+
import re
21+
from typing import Any, Optional
22+
23+
import requests
24+
25+
from datamasque.client.exceptions import SpcsGatewayAuthError
26+
27+
SPCS_GATEWAY_AUTH_HEADER = "X-SF-SPCS-Authorization"
28+
29+
# Body-shape discriminators for SPCS gateway error responses.
30+
# The gateway emits JSON with `responseType` (ERROR_<UPPER_SNAKE>), `requestId`
31+
# (canonical UUID), and `detail` (free text). All three must be present and
32+
# match these patterns for the body to count as gateway-originated.
33+
_GATEWAY_RESPONSE_TYPE_RE = re.compile(r"^ERROR_[A-Z][A-Z0-9_]+$")
34+
_UUID_RE = re.compile(
35+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
36+
re.IGNORECASE,
37+
)
38+
39+
# Header-shape discriminators for "this response transited a Snowflake SPCS
40+
# gateway". The Server header and the `sfc-ss-` cookie name prefix both appear
41+
# on every gateway-handled response (success and error alike) and aren't
42+
# plausible to spoof by accident.
43+
_SPCS_GATEWAY_SERVER_VALUE = "_"
44+
_SPCS_COOKIE_PREFIX = "sfc-ss-"
45+
46+
47+
def _has_spcs_gateway_header_signature(response: requests.Response) -> bool:
48+
"""
49+
True if at least one header-level Snowflake gateway marker is present.
50+
51+
Looks for either `Server: _` (the gateway's literal Server header value)
52+
or any `Set-Cookie` carrying the `sfc-ss-` cookie name prefix.
53+
Either is sufficient — both indicate the response was emitted by,
54+
or transited, Snowflake's SPCS ingress.
55+
"""
56+
if response.headers.get("server", "").strip() == _SPCS_GATEWAY_SERVER_VALUE:
57+
return True
58+
# `Set-Cookie` may appear multiple times; `requests` flattens duplicates
59+
# via a comma-separated value in `.headers`, but our prefix substring
60+
# check is order- and count-insensitive.
61+
return _SPCS_COOKIE_PREFIX in response.headers.get("set-cookie", "")
62+
63+
64+
def _is_spcs_gateway_error_body(response: requests.Response) -> Optional[dict]:
65+
"""
66+
Return the parsed body iff it is a structurally-valid gateway error.
67+
68+
All four conditions must hold:
69+
1. The body parses as JSON and is a dict.
70+
2. Keys `responseType`, `requestId`, `detail` are all present and string-typed.
71+
3. `responseType` matches `^ERROR_<UPPER_SNAKE>$`.
72+
4. `requestId` is a canonical 8-4-4-4-12 UUID.
73+
74+
Returns the parsed dict (truthy) on match, `None` on miss.
75+
"""
76+
try:
77+
data = response.json()
78+
except ValueError:
79+
return None
80+
if not isinstance(data, dict):
81+
return None
82+
response_type = data.get("responseType")
83+
request_id = data.get("requestId")
84+
detail = data.get("detail")
85+
if not (isinstance(response_type, str) and isinstance(request_id, str) and isinstance(detail, str)):
86+
return None
87+
if _GATEWAY_RESPONSE_TYPE_RE.match(response_type) and _UUID_RE.match(request_id):
88+
return data
89+
return None
90+
91+
92+
def _hint_for_gateway_detail(detail: str) -> str:
93+
"""Map common Snowflake gateway `detail` strings to a one-line cause hint."""
94+
d = (detail or "").lower()
95+
if "network policy" in d:
96+
return (
97+
"PAT requires a network policy attached to the user (or account) "
98+
"that permits your current public IP. Run `CREATE NETWORK POLICY "
99+
"... ALLOWED_IP_LIST = ('<your.ip>/32')` and `ALTER USER <pat-user> "
100+
"SET NETWORK_POLICY = <policy>`."
101+
)
102+
if "invalid" in d and "token" in d:
103+
return (
104+
"PAT is malformed, expired, or revoked. Create a new PAT in Snowsight "
105+
"(User profile -> Programmatic access tokens) and update `spcs_pat`."
106+
)
107+
if "expired" in d:
108+
return "PAT has expired. Create a fresh one in Snowsight and update `spcs_pat`."
109+
if "authentication" in d or "unauthorized" in d:
110+
return (
111+
"Generic auth rejection. Verify the PAT was created by a user that "
112+
"has access to this SPCS app, and that any account-level network "
113+
"policy includes your current public IP."
114+
)
115+
return "Unknown gateway rejection — see the Snowflake `detail` string above and the Snowflake PAT docs."
116+
117+
118+
def _check_spcs_gateway_response(response: requests.Response) -> None:
119+
"""
120+
Raise `SpcsGatewayAuthError` iff `response` is a gateway-originated rejection.
121+
122+
Two-layer discriminator — both must hold:
123+
* **Body originated at the gateway**:
124+
strict shape match on the JSON body
125+
(multiple fields, typed, with format constraints)
126+
via `_is_spcs_gateway_error_body`.
127+
* **Response transited an SPCS gateway**:
128+
header signature confirms via `_has_spcs_gateway_header_signature`.
129+
130+
Either layer alone could in principle false-positive on an unrelated upstream
131+
that happened to emit one of those signals;
132+
the conjunction is what makes the check robust.
133+
Legitimate DataMasque 401s (DRF `{"detail": "..."}`)
134+
have the gateway header signature but fail the body shape —
135+
so they correctly flow through
136+
to the client's normal re-auth-and-retry path untouched.
137+
"""
138+
if response.status_code not in (401, 403):
139+
return
140+
if not _has_spcs_gateway_header_signature(response):
141+
return
142+
data = _is_spcs_gateway_error_body(response)
143+
if data is None:
144+
return
145+
146+
response_type = data["responseType"]
147+
request_id = data["requestId"]
148+
detail = data["detail"]
149+
hint = _hint_for_gateway_detail(detail)
150+
raise SpcsGatewayAuthError(
151+
f"SPCS gateway rejected the PAT (HTTP {response.status_code}, "
152+
f"{response_type}). The request never reached DataMasque.\n"
153+
f' Snowflake said: "{detail}"\n'
154+
f" Snowflake reqId: {request_id}\n"
155+
f" Likely cause: {hint}\n"
156+
f" Fix in Snowsight on the account hosting this SPCS app, then retry."
157+
)
158+
159+
160+
def _spcs_gateway_response_hook(response: requests.Response, *args: Any, **kwargs: Any) -> None:
161+
"""`requests` response hook: raise on a gateway-originated auth rejection."""
162+
_check_spcs_gateway_response(response)
163+
164+
165+
def install_spcs_gateway_auth(session: requests.Session, pat: str) -> None:
166+
"""
167+
Configure `session` to authenticate to a Snowflake SPCS app gateway.
168+
169+
Sets the `X-SF-SPCS-Authorization` header on the session
170+
(so it rides on every request, including the unauthenticated login)
171+
and registers a response hook
172+
that raises `SpcsGatewayAuthError` on a gateway rejection.
173+
174+
Scoping is automatic:
175+
the client's session only ever talks to its own `base_url`,
176+
so there is no need to match per-request hosts.
177+
"""
178+
session.headers[SPCS_GATEWAY_AUTH_HEADER] = f'Snowflake Token="{pat}"'
179+
session.hooks["response"].append(_spcs_gateway_response_hook)

docs/client.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ datamasque.client.files module
6060
:undoc-members:
6161
:show-inheritance:
6262

63+
datamasque.client.spcs module
64+
-----------------------------
65+
66+
.. automodule:: datamasque.client.spcs
67+
:members:
68+
:undoc-members:
69+
:show-inheritance:
70+
6371
datamasque.client.ifm module
6472
----------------------------
6573

docs/usage.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,31 @@ To use DataMasque Python in a project:
1919
2020
for connection in client.list_connections():
2121
print(connection.name)
22+
23+
Connecting to an SPCS-hosted instance
24+
=====================================
25+
26+
When DataMasque is hosted on Snowpark Container Services (SPCS),
27+
its `base_url` ends in `.snowflakecomputing.app`
28+
and requests must first clear the Snowflake gateway.
29+
Pass a Snowflake Programmatic Access Token (PAT) as `spcs_pat`
30+
and the client clears the gateway for you,
31+
independently of your DataMasque `username`/`password` (or `token_source`) auth.
32+
33+
.. code-block:: python
34+
35+
config = DataMasqueInstanceConfig(
36+
base_url="https://my-app.snowflakecomputing.app",
37+
username="api_user",
38+
password="api_password",
39+
spcs_pat="<snowflake-programmatic-access-token>",
40+
)
41+
client = DataMasqueClient(config)
42+
client.authenticate()
43+
44+
Create the PAT in Snowsight (User profile → Programmatic access tokens)
45+
for an account that can reach the SPCS app.
46+
If the gateway rejects the PAT
47+
(for example it has expired, or a network policy excludes your IP),
48+
the client raises `SpcsGatewayAuthError`
49+
with the Snowflake-provided detail and a hint at the likely cause.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "datamasque-python"
3-
version = "1.1.4"
3+
version = "1.1.5"
44
description = "Official Python client for the DataMasque data-masking API."
55
authors = [
66
{ name = "DataMasque Ltd" },

0 commit comments

Comments
 (0)