Repository: feast-dev/feast

Issue:
Allow for connecting to Snowflake with a private key
When connecting to Snowflake with an account that requires MFA its does not work (or at least requires extra work to use MFA devices) to connect with just the username and password credentials. 

Instead of using username and password we should be able to use a private key and a private key passphrase to connect to Snowflake. Snowflake already supports this method of authentication. See [here](https://docs.snowflake.com/en/user-guide/python-connector-example.html#label-python-key-pair-authn-rotation). Feast should add this as an option to the feature_store.yaml as part of the Snowflake connection config.

When trying to use a private_key_path and passphrase this error was raised:
```
raise FeastConfigError(e, config_path)
feast.repo_config.FeastConfigError: 2 validation errors for RepoConfig
__root__ -> offline_store -> private_key_passphrase
  extra fields not permitted (type=value_error.extra)
__root__ -> offline_store -> private_key_path
  extra fields not permitted (type=value_error.extra)
```
It seems like fields related to a passphrase are not permitted [here](https://github.com/feast-dev/feast/blob/b95f4410ee91069ff84e81d2d5f3e9329edc8626/sdk/python/feast/infra/offline_stores/snowflake.py#L56)

cc @sfc-gh-madkins 




Discussion:
After a Slack discussion it was concluded this could be done using the ~/.snowsql/config file and passing in the private key there. Unfortunately, the python connector  doesn’t take the private key path instead it takes a byte object of an unecrypted rsa private key. To create this private key we should allow a user to set the private key path and private key passcode in the config file and then convert that to the private key in code using the method detailed [here]( python connector doesn’t take the private key path instead it takes a byte object of an unecrypted rsa private key).

Resolving patch:
diff --git a/sdk/python/feast/infra/utils/snowflake_utils.py b/sdk/python/feast/infra/utils/snowflake_utils.py
--- a/sdk/python/feast/infra/utils/snowflake_utils.py
+++ b/sdk/python/feast/infra/utils/snowflake_utils.py
@@ -4,9 +4,11 @@
 import string
 from logging import getLogger
 from tempfile import TemporaryDirectory
-from typing import Dict, Iterator, List, Optional, Tuple, cast
+from typing import Any, Dict, Iterator, List, Optional, Tuple, cast
 
 import pandas as pd
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import serialization
 from tenacity import (
     retry,
     retry_if_exception_type,
@@ -40,18 +42,17 @@ def execute_snowflake_statement(conn: SnowflakeConnection, query) -> SnowflakeCu
 
 
 def get_snowflake_conn(config, autocommit=True) -> SnowflakeConnection:
-    if config.type == "snowflake.offline":
-        config_header = "connections.feast_offline_store"
+    assert config.type == "snowflake.offline"
+    config_header = "connections.feast_offline_store"
 
     config_dict = dict(config)
 
     # read config file
     config_reader = configparser.ConfigParser()
     config_reader.read([config_dict["config_path"]])
+    kwargs: Dict[str, Any] = {}
     if config_reader.has_section(config_header):
         kwargs = dict(config_reader[config_header])
-    else:
-        kwargs = {}
 
     if "schema" in kwargs:
         kwargs["schema_"] = kwargs.pop("schema")
@@ -67,6 +68,13 @@ def get_snowflake_conn(config, autocommit=True) -> SnowflakeConnection:
     else:
         kwargs["schema"] = '"PUBLIC"'
 
+    # https://docs.snowflake.com/en/user-guide/python-connector-example.html#using-key-pair-authentication-key-pair-rotation
+    # https://docs.snowflake.com/en/user-guide/key-pair-auth.html#configuring-key-pair-authentication
+    if "private_key" in kwargs:
+        kwargs["private_key"] = parse_private_key_path(
+            kwargs["private_key"], kwargs["private_key_passphrase"]
+        )
+
     try:
         conn = snowflake.connector.connect(
             application="feast", autocommit=autocommit, **kwargs
@@ -288,3 +296,21 @@ def chunk_helper(lst: pd.DataFrame, n: int) -> Iterator[Tuple[int, pd.DataFrame]
     """Helper generator to chunk a sequence efficiently with current index like if enumerate was called on sequence."""
     for i in range(0, len(lst), n):
         yield int(i / n), lst[i : i + n]
+
+
+def parse_private_key_path(key_path: str, private_key_passphrase: str) -> bytes:
+
+    with open(key_path, "rb") as key:
+        p_key = serialization.load_pem_private_key(
+            key.read(),
+            password=private_key_passphrase.encode(),
+            backend=default_backend(),
+        )
+
+    pkb = p_key.private_bytes(
+        encoding=serialization.Encoding.DER,
+        format=serialization.PrivateFormat.PKCS8,
+        encryption_algorithm=serialization.NoEncryption(),
+    )
+
+    return pkb


Test patch: