Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8f2adc0
Add AWS dependencies
sfc-gh-pmansour Feb 28, 2025
605c636
add basic attestation loading
sfc-gh-pmansour Mar 1, 2025
a0eb05c
improve the contract for fetching WIF attestations
sfc-gh-pmansour Mar 1, 2025
d6d8bdd
Add explicit attestation provider type, support for OIDC tokens
sfc-gh-pmansour Mar 4, 2025
2d6efc1
add associated error codes
sfc-gh-pmansour Mar 4, 2025
cd49eaf
fix small bugs, clean-up data structures, add auto-detect provider
sfc-gh-pmansour Mar 4, 2025
06d74bd
fix docstrings
sfc-gh-pmansour Mar 4, 2025
36b7e20
add an entry point
sfc-gh-pmansour Mar 4, 2025
0c17d31
add WLID authenticator plugin
sfc-gh-pmansour Mar 4, 2025
e245c9c
Add glue for WLID authenticator
sfc-gh-pmansour Mar 4, 2025
4c6159f
Remove explicit handling of token_file_path since this is done in con…
sfc-gh-pmansour Mar 4, 2025
a3f9f60
Add unit tests for connection plumbing
sfc-gh-pmansour Mar 4, 2025
2ee89bb
fix bug with default value of workload_identity_provider
sfc-gh-pmansour Mar 4, 2025
2601335
small bug fix in HTTP request
sfc-gh-pmansour Mar 4, 2025
ca1f0a6
update logic for assertion_content
sfc-gh-pmansour Mar 4, 2025
22d3cd7
fix whitespace
sfc-gh-pmansour Mar 4, 2025
8b2d45f
update error message
sfc-gh-pmansour Mar 4, 2025
e850268
wrap metadata server requests in a try..except block
sfc-gh-pmansour Mar 5, 2025
aac04ed
include issuer in Azure identifier string
sfc-gh-pmansour Mar 5, 2025
bae5987
add unit tests
sfc-gh-pmansour Mar 5, 2025
e6c6a22
change order of constants
sfc-gh-pmansour Mar 5, 2025
5ed7ad7
Slight refactor of metadata calls, added debug logs for missing crede…
sfc-gh-pmansour Mar 5, 2025
fc1577d
refactor token parsing
sfc-gh-pmansour Mar 5, 2025
5ccc5c6
gate the feature on the preview env variable
sfc-gh-pmansour Mar 5, 2025
85b57b4
update autodetect logic for OIDC
sfc-gh-pmansour Mar 5, 2025
d8739d1
allow explicit setting of entra resource, don't ask for account
sfc-gh-pmansour Mar 5, 2025
fd38e2f
add tests for Entra resource plumbing
sfc-gh-pmansour Mar 5, 2025
1a0e3af
add support for AWS region and arn loading
sfc-gh-pmansour Mar 6, 2025
c4570e3
add support for azure functions
sfc-gh-pmansour Mar 6, 2025
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
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ python_requires = >=3.8
packages = find_namespace:
install_requires =
asn1crypto>0.24.0,<2.0.0
boto3>=1.0
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have risk assesment for these new libraries?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do not -- what's the process for getting that?

FWIW they're the standard AWS client libraries and are maintained by AWS, so I suspect it'll be fine, but if you can point me at a process I'd be happy to follow that.

botocore>=1.0
cffi>=1.9,<2.0.0
cryptography>=3.1.0
pyOpenSSL>=22.0.0,<26.0.0
Expand Down
3 changes: 3 additions & 0 deletions src/snowflake/connector/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .pat import AuthByPAT
from .usrpwdmfa import AuthByUsrPwdMfa
from .webbrowser import AuthByWebBrowser
from .workload_identity import AuthByWorkloadIdentity

FIRST_PARTY_AUTHENTICATORS = frozenset(
(
Expand All @@ -26,6 +27,7 @@
AuthByWebBrowser,
AuthByIdToken,
AuthByPAT,
AuthByWorkloadIdentity,
AuthNoAuth,
)
)
Expand All @@ -39,6 +41,7 @@
"AuthByOkta",
"AuthByUsrPwdMfa",
"AuthByWebBrowser",
"AuthByWorkloadIdentity",
"AuthNoAuth",
"Auth",
"AuthType",
Expand Down
1 change: 1 addition & 0 deletions src/snowflake/connector/auth/by_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class AuthType(Enum):
OKTA = "OKTA"
PAT = "PROGRAMMATIC_ACCESS_TOKEN'"
NO_AUTH = "NO_AUTH"
WORKLOAD_IDENTITY = "WORKLOAD_IDENTITY"


class AuthByPlugin(ABC):
Expand Down
84 changes: 84 additions & 0 deletions src/snowflake/connector/auth/workload_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#
# Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved.
#

from __future__ import annotations

from enum import Enum, unique
import json
import typing

from ..network import WORKLOAD_IDENTITY_AUTHENTICATOR
from ..wif_util import AttestationProvider, WorkloadIdentityAttestation, create_attestation
from .by_plugin import AuthByPlugin, AuthType


@unique
class ApiFederatedAuthenticationType(Enum):
"""An API-specific enum of the WIF authentication type."""
AWS = "AWS"
AZURE = "AZURE"
GCP = "GCP"
OIDC = "OIDC"

@staticmethod
def from_attestation(attestation: WorkloadIdentityAttestation) -> ApiFederatedAuthenticationType:
"""Maps the internal / driver-specific attestation providers to API authenticator types.

The AttestationProvider is related to how the driver fetches the credential, while the API authenticator
type is related to how the credential is verified. In most current cases these may be the same, though
in the future we could have, for example, multiple AttestationProviders that all fetch an OIDC token.
"""
if attestation.provider == AttestationProvider.AWS:
return ApiFederatedAuthenticationType.AWS
if attestation.provider == AttestationProvider.AZURE:
return ApiFederatedAuthenticationType.AZURE
if attestation.provider == AttestationProvider.GCP:
return ApiFederatedAuthenticationType.GCP
if attestation.provider == AttestationProvider.OIDC:
return ApiFederatedAuthenticationType.OIDC
return ValueError(f"Unknown attestation provider '{attestation.provider}'")


class AuthByWorkloadIdentity(AuthByPlugin):
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this class meant to be used in multithreaded environment? It is not thread safe.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please elaborate on this part? I believe it's using the same pattern as all the other authenticator plugins.

I am not sure if authenticator plugins generally need to be thread-safe, or if the other ones are (or aren't), or if this one is different.

"""Plugin to authenticate via workload identity."""

def __init__(self, provider: AttestationProvider | None = None, token: str | None = None, entra_resource: str | None = None, **kwargs) -> None:
super().__init__(**kwargs)
self.provider = provider
self.token = token
self.entra_resource = entra_resource

self.attestation: WorkloadIdentityAttestation | None = None

def type_(self) -> AuthType:
return AuthType.WORKLOAD_IDENTITY

def reset_secrets(self) -> None:
self.attestation = None

def update_body(self, body: dict[typing.Any, typing.Any]) -> None:
body["data"]["AUTHENTICATOR"] = WORKLOAD_IDENTITY_AUTHENTICATOR
body["data"]["PROVIDER"] = ApiFederatedAuthenticationType.from_attestation(self.attestation).value
body["data"]["TOKEN"] = self.attestation.credential

def prepare(
self,
**kwargs: typing.Any,
) -> None:
"""Fetch the token."""
self.attestation = create_attestation(self.provider, self.entra_resource, self.token)

def reauthenticate(self, **kwargs: typing.Any) -> dict[str, bool]:
self.reset_secrets()
self.prepare()
return {"success": True}

@property
def assertion_content(self) -> str:
"""Returns the CSP provider name and an identifier. Used for logging purposes."""
if not self.attestation:
return ""
properties = self.attestation.user_identifier_components
properties["_provider"] = self.attestation.provider.value
return json.dumps(properties, sort_keys=True, separators=(',', ':'))
47 changes: 43 additions & 4 deletions src/snowflake/connector/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
AuthByPlugin,
AuthByUsrPwdMfa,
AuthByWebBrowser,
AuthByWorkloadIdentity,
AuthNoAuth,
)
from .auth.idtoken import AuthByIdToken
Expand All @@ -56,6 +57,7 @@
_CONNECTIVITY_ERR_MSG,
_DOMAIN_NAME_MAP,
ENV_VAR_PARTNER,
ENV_VAR_EXPERIMENTAL_AUTHENTICATION,
PARAMETER_AUTOCOMMIT,
PARAMETER_CLIENT_PREFETCH_THREADS,
PARAMETER_CLIENT_REQUEST_MFA_TOKEN,
Expand Down Expand Up @@ -92,6 +94,7 @@
ER_NO_PASSWORD,
ER_NO_USER,
ER_NOT_IMPLICITY_SNOWFLAKE_DATATYPE,
ER_INVALID_WIF_SETTINGS,
)
from .errors import DatabaseError, Error, OperationalError, ProgrammingError
from .log_configuration import EasyLoggingConfigPython
Expand All @@ -104,6 +107,7 @@
PROGRAMMATIC_ACCESS_TOKEN,
REQUEST_ID,
USR_PWD_MFA_AUTHENTICATOR,
WORKLOAD_IDENTITY_AUTHENTICATOR,
ReauthenticationRequest,
SnowflakeRestful,
)
Expand All @@ -112,6 +116,7 @@
from .time_util import HeartBeatTimer, get_time_millis
from .url_util import extract_top_level_domain_from_hostname
from .util_text import construct_hostname, parse_account, split_statements
from .wif_util import AttestationProvider

DEFAULT_CLIENT_PREFETCH_THREADS = 4
MAX_CLIENT_PREFETCH_THREADS = 10
Expand Down Expand Up @@ -188,12 +193,14 @@ def _get_private_bytes_from_file(
"private_key": (None, (type(None), bytes, str, RSAPrivateKey)),
"private_key_file": (None, (type(None), str)),
"private_key_file_pwd": (None, (type(None), str, bytes)),
"token": (None, (type(None), str)), # OAuth/JWT/PAT Token
"token": (None, (type(None), str)), # OAuth/JWT/PAT/OIDC Token
"token_file_path": (
None,
(type(None), str, bytes),
), # OAuth/JWT/PAT Token file path
), # OAuth/JWT/PAT/OIDC Token file path
"authenticator": (DEFAULT_AUTHENTICATOR, (type(None), str)),
"workload_identity_provider": {None, (type(None), AttestationProvider)},
"workload_identity_entra_resource": {None, (type(None), str)},
"mfa_callback": (None, (type(None), Callable)),
"password_callback": (None, (type(None), Callable)),
"auth_class": (None, (type(None), AuthByPlugin)),
Expand Down Expand Up @@ -1113,6 +1120,23 @@ def __open_connection(self):
)
elif self._authenticator == PROGRAMMATIC_ACCESS_TOKEN:
self.auth_class = AuthByPAT(self._token)
elif self._authenticator == WORKLOAD_IDENTITY_AUTHENTICATOR:
if not ENV_VAR_EXPERIMENTAL_AUTHENTICATION in os.environ:
Error.errorhandler_wrapper(
self,
None,
ProgrammingError,
{
"msg":
f"Please set the '{ENV_VAR_EXPERIMENTAL_AUTHENTICATION}' environment variable to use the '{WORKLOAD_IDENTITY_AUTHENTICATOR}' authenticator.",
"errno": ER_INVALID_WIF_SETTINGS,
},
)
self.auth_class = AuthByWorkloadIdentity(
provider=self._workload_identity_provider,
token=self._token,
entra_resource=self._workload_identity_entra_resource,
)
else:
# okta URL, e.g., https://<account>.okta.com/
self.auth_class = AuthByOkta(
Expand Down Expand Up @@ -1241,6 +1265,7 @@ def __config(self, **kwargs):
KEY_PAIR_AUTHENTICATOR,
OAUTH_AUTHENTICATOR,
USR_PWD_MFA_AUTHENTICATOR,
WORKLOAD_IDENTITY_AUTHENTICATOR,
]:
self._authenticator = auth_tmp

Expand All @@ -1251,14 +1276,14 @@ def __config(self, **kwargs):
self._token = f.read()

# Set of authenticators allowing empty user.
empty_user_allowed_authenticators = {OAUTH_AUTHENTICATOR, NO_AUTH_AUTHENTICATOR}
empty_user_allowed_authenticators = {OAUTH_AUTHENTICATOR, NO_AUTH_AUTHENTICATOR, WORKLOAD_IDENTITY_AUTHENTICATOR}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, I believe external browser and PAT also don't need user... @sfc-gh-mmishchenko wdyt?

Copy link
Contributor

@sfc-gh-mmishchenko sfc-gh-mmishchenko Mar 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth checking, but that's not probably a subject of this review.


if not (self._master_token and self._session_token):
if (
not self.user
and self._authenticator not in empty_user_allowed_authenticators
):
# OAuth and NoAuth Authentications does not require a username
# Some authenticators do not require a username
Error.errorhandler_wrapper(
self,
None,
Expand All @@ -1269,6 +1294,19 @@ def __config(self, **kwargs):
if self._private_key or self._private_key_file:
self._authenticator = KEY_PAIR_AUTHENTICATOR

workload_identity_dependent_options = ["workload_identity_provider", "workload_identity_entra_resource"]
for dependent_option in workload_identity_dependent_options:
if self.__getattribute__(f"_{dependent_option}") is not None and self._authenticator != WORKLOAD_IDENTITY_AUTHENTICATOR:
Error.errorhandler_wrapper(
self,
None,
ProgrammingError,
{
"msg": f"{dependent_option} was set but authenticator was not set to {WORKLOAD_IDENTITY_AUTHENTICATOR}",
"errno": ER_INVALID_WIF_SETTINGS
},
)

if (
self.auth_class is None
and self._authenticator
Expand All @@ -1277,6 +1315,7 @@ def __config(self, **kwargs):
OAUTH_AUTHENTICATOR,
KEY_PAIR_AUTHENTICATOR,
PROGRAMMATIC_ACCESS_TOKEN,
WORKLOAD_IDENTITY_AUTHENTICATOR,
)
and not self._password
):
Expand Down
1 change: 1 addition & 0 deletions src/snowflake/connector/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ class IterUnit(Enum):
# TODO: all env variables definitions should be here
ENV_VAR_PARTNER = "SF_PARTNER"
ENV_VAR_TEST_MODE = "SNOWFLAKE_TEST_MODE"
ENV_VAR_EXPERIMENTAL_AUTHENTICATION = "SF_ENABLE_EXPERIMENTAL_AUTHENTICATION" # Needed to enable new strong auth features during the private preview.


_DOMAIN_NAME_MAP = {_DEFAULT_HOSTNAME_TLD: "GLOBAL", _CHINA_HOSTNAME_TLD: "CHINA"}
Expand Down
2 changes: 2 additions & 0 deletions src/snowflake/connector/errorcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
ER_JWT_RETRY_EXPIRED = 251010
ER_CONNECTION_TIMEOUT = 251011
ER_RETRYABLE_CODE = 251012
ER_INVALID_WIF_SETTINGS = 251013
ER_WIF_CREDENTIALS_NOT_FOUND = 251014

# cursor
ER_FAILED_TO_REWRITE_MULTI_ROW_INSERT = 252001
Expand Down
1 change: 1 addition & 0 deletions src/snowflake/connector/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@
USR_PWD_MFA_AUTHENTICATOR = "USERNAME_PASSWORD_MFA"
PROGRAMMATIC_ACCESS_TOKEN = "PROGRAMMATIC_ACCESS_TOKEN"
NO_AUTH_AUTHENTICATOR = "NO_AUTH"
WORKLOAD_IDENTITY_AUTHENTICATOR = "WORKLOAD_IDENTITY"


def is_retryable_http_code(code: int) -> bool:
Expand Down
Loading
Loading