|
| 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) |
0 commit comments