diff --git a/docs/reference/feature-servers/python-feature-server.md b/docs/reference/feature-servers/python-feature-server.md index 255b85e606a..19e0426022f 100644 --- a/docs/reference/feature-servers/python-feature-server.md +++ b/docs/reference/feature-servers/python-feature-server.md @@ -200,6 +200,26 @@ requests.post( data=json.dumps(push_data)) ``` +## Starting feature server in SSL mode + +### Obtaining a self-signed SSL certificate and key +In development mode we can generate self-signed certificate for testing purpose. In actual production environment it is always recommended to get it from the popular SSL certificate providers. + +```shell +openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes +``` + +The above command will generate two files +* `key.pem` : certificate private key +* `cert.pem`: certificate public key + +### Starting Online Server in SSL Mode +To start the feature server in SSL mode, you need to provide the private and public keys using the `--ssl-key-path` and `--ssl-cert-path` arguments with the `feast serve` command. + +```shell +feast serve --ssl-key-path key.pem --ssl-cert-path cert.pem +``` + # Online Feature Server Permissions and Access Control ## API Endpoints and Permissions diff --git a/docs/reference/online-stores/remote.md b/docs/reference/online-stores/remote.md index 4dd4fb65b5d..200ce3ce1cf 100644 --- a/docs/reference/online-stores/remote.md +++ b/docs/reference/online-stores/remote.md @@ -16,12 +16,15 @@ provider: local online_store: path: http://localhost:6566 type: remote + ssl_cert_path: /path/to/cert.pem entity_key_serialization_version: 2 auth: type: no_auth ``` {% endcode %} +`ssl_cert_path` is optional configuration. Path to the public certificate path in case if the online server starts in SSL mode. This may be needed especially if online server started with a self-signed certificate, typically this file ends with .crt, .cer, or .pem. + ## How to configure Authentication and Authorization Please refer the [page](./../../../docs/getting-started/concepts/permission.md) for more details on how to configure authentication and authorization. diff --git a/sdk/python/feast/cli.py b/sdk/python/feast/cli.py index 010493f01cb..62657ebb111 100644 --- a/sdk/python/feast/cli.py +++ b/sdk/python/feast/cli.py @@ -911,6 +911,22 @@ def init_command(project_directory, minimal: bool, template: str): default=5, show_default=True, ) +@click.option( + "--ssl-key-path", + "-k", + type=click.STRING, + default="", + show_default=False, + help="path to SSL certificate private key. You need to pass ssl-cert-path as well to start server in SSL mode", +) +@click.option( + "--ssl-cert-path", + "-c", + type=click.STRING, + default="", + show_default=False, + help="path to SSL certificate public key. You need to pass ssl-key-path as well to start server in SSL mode", +) @click.option( "--metrics", "-m", @@ -928,9 +944,16 @@ def serve_command( workers: int, metrics: bool, keep_alive_timeout: int, + ssl_key_path: str, + ssl_cert_path: str, registry_ttl_sec: int = 5, ): """Start a feature server locally on a given port.""" + if (ssl_key_path and not ssl_cert_path) or (not ssl_key_path and ssl_cert_path): + raise click.BadParameter( + "Please send ssl-cert-path and ssl-key-path args to start feature server in SSL mode." + ) + store = create_feature_store(ctx) store.serve( @@ -941,6 +964,8 @@ def serve_command( workers=workers, metrics=metrics, keep_alive_timeout=keep_alive_timeout, + ssl_key_path=ssl_key_path, + ssl_cert_path=ssl_cert_path, registry_ttl_sec=registry_ttl_sec, ) diff --git a/sdk/python/feast/feature_server.py b/sdk/python/feast/feature_server.py index f485d874e14..c4f383779ff 100644 --- a/sdk/python/feast/feature_server.py +++ b/sdk/python/feast/feature_server.py @@ -339,6 +339,8 @@ def start_server( workers: int, keep_alive_timeout: int, registry_ttl_sec: int, + ssl_key_path: str, + ssl_cert_path: str, metrics: bool, ): if metrics: @@ -364,16 +366,32 @@ def start_server( logger.debug("Auth manager initialized successfully") if sys.platform != "win32": - FeastServeApplication( - store=store, - bind=f"{host}:{port}", - accesslog=None if no_access_log else "-", - workers=workers, - keepalive=keep_alive_timeout, - registry_ttl_sec=registry_ttl_sec, - ).run() + options = { + "store": store, + "bind": f"{host}:{port}", + "accesslog": None if no_access_log else "-", + "workers": workers, + "keepalive": keep_alive_timeout, + "registry_ttl_sec": registry_ttl_sec, + } + + # Add SSL options if the paths exist + if ssl_key_path and ssl_cert_path: + options["keyfile"] = ssl_key_path + options["certfile"] = ssl_cert_path + FeastServeApplication(**options).run() else: import uvicorn app = get_app(store, registry_ttl_sec) - uvicorn.run(app, host=host, port=port, access_log=(not no_access_log)) + if ssl_key_path and ssl_cert_path: + uvicorn.run( + app, + host=host, + port=port, + access_log=(not no_access_log), + ssl_keyfile=ssl_key_path, + ssl_certfile=ssl_cert_path, + ) + else: + uvicorn.run(app, host=host, port=port, access_log=(not no_access_log)) diff --git a/sdk/python/feast/feature_store.py b/sdk/python/feast/feature_store.py index 033f39e1f22..876345c8bbb 100644 --- a/sdk/python/feast/feature_store.py +++ b/sdk/python/feast/feature_store.py @@ -1896,6 +1896,8 @@ def serve( workers: int = 1, metrics: bool = False, keep_alive_timeout: int = 30, + ssl_key_path: str = "", + ssl_cert_path: str = "", registry_ttl_sec: int = 2, ) -> None: """Start the feature consumption server locally on a given port.""" @@ -1913,6 +1915,8 @@ def serve( workers=workers, metrics=metrics, keep_alive_timeout=keep_alive_timeout, + ssl_key_path=ssl_key_path, + ssl_cert_path=ssl_cert_path, registry_ttl_sec=registry_ttl_sec, ) diff --git a/sdk/python/feast/infra/online_stores/remote.py b/sdk/python/feast/infra/online_stores/remote.py index 8a7e299516b..caa65dffb4a 100644 --- a/sdk/python/feast/infra/online_stores/remote.py +++ b/sdk/python/feast/infra/online_stores/remote.py @@ -41,6 +41,10 @@ class RemoteOnlineStoreConfig(FeastConfigBaseModel): """ str: Path to metadata store. If type is 'remote', then this is a URL for registry server """ + ssl_cert_path: StrictStr = "" + """ str: Path to the public certificate in case if the online server starts in SSL mode. This may be needed especially if online server started with a self-signed certificate, typically this file ends with .crt, .cer, or .pem. + If type is 'remote', then this configuration is needed to connect to remote online server in SSL mode. """ + class RemoteOnlineStore(OnlineStore): """ @@ -170,6 +174,13 @@ def teardown( def get_remote_online_features( session: requests.Session, config: RepoConfig, req_body: str ) -> requests.Response: - return session.post( - f"{config.online_store.path}/get-online-features", data=req_body - ) + if config.online_store.ssl_cert_path: + return session.post( + f"{config.online_store.path}/get-online-features", + data=req_body, + verify=config.online_store.ssl_cert_path, + ) + else: + return session.post( + f"{config.online_store.path}/get-online-features", data=req_body + ) diff --git a/sdk/python/tests/integration/online_store/test_remote_online_store.py b/sdk/python/tests/integration/online_store/test_remote_online_store.py index d8c92077db9..bfca6a4f99d 100644 --- a/sdk/python/tests/integration/online_store/test_remote_online_store.py +++ b/sdk/python/tests/integration/online_store/test_remote_online_store.py @@ -15,11 +15,13 @@ start_feature_server, ) from tests.utils.cli_repo_creator import CliRunner +from tests.utils.generate_self_signed_certifcate_util import generate_self_signed_cert from tests.utils.http_server import free_port +@pytest.mark.parametrize("ssl_mode", [True, False]) @pytest.mark.integration -def test_remote_online_store_read(auth_config): +def test_remote_online_store_read(auth_config, ssl_mode): with tempfile.TemporaryDirectory() as remote_server_tmp_dir, tempfile.TemporaryDirectory() as remote_client_tmp_dir: permissions_list = [ Permission( @@ -41,11 +43,12 @@ def test_remote_online_store_read(auth_config): actions=[AuthzedAction.READ_ONLINE], ), ] - server_store, server_url, registry_path = ( + server_store, server_url, registry_path, ssl_cert_path = ( _create_server_store_spin_feature_server( temp_dir=remote_server_tmp_dir, auth_config=auth_config, permissions_list=permissions_list, + ssl_mode=ssl_mode, ) ) assert None not in (server_store, server_url, registry_path) @@ -54,6 +57,7 @@ def test_remote_online_store_read(auth_config): server_registry_path=str(registry_path), feature_server_url=server_url, auth_config=auth_config, + ssl_cert_path=ssl_cert_path, ) assert client_store is not None _assert_non_existing_entity_feature_views_entity( @@ -159,21 +163,46 @@ def _assert_client_server_online_stores_are_matching( def _create_server_store_spin_feature_server( - temp_dir, auth_config: str, permissions_list + temp_dir, auth_config: str, permissions_list, ssl_mode: bool ): store = default_store(str(temp_dir), auth_config, permissions_list) feast_server_port = free_port() + if ssl_mode: + certificates_path = tempfile.mkdtemp() + ssl_key_path = os.path.join(certificates_path, "key.pem") + ssl_cert_path = os.path.join(certificates_path, "cert.pem") + generate_self_signed_cert(cert_path=ssl_cert_path, key_path=ssl_key_path) + else: + ssl_key_path = "" + ssl_cert_path = "" + server_url = next( start_feature_server( - repo_path=str(store.repo_path), server_port=feast_server_port + repo_path=str(store.repo_path), + server_port=feast_server_port, + ssl_key_path=ssl_key_path, + ssl_cert_path=ssl_cert_path, ) ) - print(f"Server started successfully, {server_url}") - return store, server_url, os.path.join(store.repo_path, "data", "registry.db") + if ssl_cert_path and ssl_key_path: + print(f"Online Server started successfully in SSL mode, {server_url}") + else: + print(f"Server started successfully, {server_url}") + + return ( + store, + server_url, + os.path.join(store.repo_path, "data", "registry.db"), + ssl_cert_path, + ) def _create_remote_client_feature_store( - temp_dir, server_registry_path: str, feature_server_url: str, auth_config: str + temp_dir, + server_registry_path: str, + feature_server_url: str, + auth_config: str, + ssl_cert_path: str = "", ) -> FeatureStore: project_name = "REMOTE_ONLINE_CLIENT_PROJECT" runner = CliRunner() @@ -185,27 +214,50 @@ def _create_remote_client_feature_store( registry_path=server_registry_path, feature_server_url=feature_server_url, auth_config=auth_config, + ssl_cert_path=ssl_cert_path, ) return FeatureStore(repo_path=repo_path) def _overwrite_remote_client_feature_store_yaml( - repo_path: str, registry_path: str, feature_server_url: str, auth_config: str + repo_path: str, + registry_path: str, + feature_server_url: str, + auth_config: str, + ssl_cert_path: str = "", ): repo_config = os.path.join(repo_path, "feature_store.yaml") with open(repo_config, "w") as repo_config: - repo_config.write( - dedent( - f""" - project: {PROJECT_NAME} - registry: {registry_path} - provider: local - online_store: - path: {feature_server_url} - type: remote - entity_key_serialization_version: 2 - """ + if ssl_cert_path: + repo_config.write( + dedent( + f""" + project: {PROJECT_NAME} + registry: {registry_path} + provider: local + online_store: + path: {feature_server_url} + type: remote + ssl_cert_path: {ssl_cert_path} + entity_key_serialization_version: 2 + """ + ) + + auth_config + ) + + else: + repo_config.write( + dedent( + f""" + project: {PROJECT_NAME} + registry: {registry_path} + provider: local + online_store: + path: {feature_server_url} + type: remote + entity_key_serialization_version: 2 + """ + ) + + auth_config ) - + auth_config - ) diff --git a/sdk/python/tests/utils/auth_permissions_util.py b/sdk/python/tests/utils/auth_permissions_util.py index 49ddd1b530d..1147e66a0d1 100644 --- a/sdk/python/tests/utils/auth_permissions_util.py +++ b/sdk/python/tests/utils/auth_permissions_util.py @@ -54,7 +54,13 @@ def default_store( return fs -def start_feature_server(repo_path: str, server_port: int, metrics: bool = False): +def start_feature_server( + repo_path: str, + server_port: int, + metrics: bool = False, + ssl_key_path: str = "", + ssl_cert_path: str = "", +): host = "0.0.0.0" cmd = [ "feast", @@ -65,6 +71,13 @@ def start_feature_server(repo_path: str, server_port: int, metrics: bool = False "--port", str(server_port), ] + + if ssl_cert_path and ssl_cert_path: + cmd.append("--ssl-key-path") + cmd.append(ssl_key_path) + cmd.append("--ssl-cert-path") + cmd.append(ssl_cert_path) + feast_server_process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) @@ -91,7 +104,13 @@ def start_feature_server(repo_path: str, server_port: int, metrics: bool = False "localhost", 8000 ), "Prometheus server is running when it should be disabled." - yield f"http://localhost:{server_port}" + online_server_url = ( + f"https://localhost:{server_port}" + if ssl_key_path and ssl_cert_path + else f"http://localhost:{server_port}" + ) + + yield (online_server_url) if feast_server_process is not None: feast_server_process.kill() diff --git a/sdk/python/tests/utils/generate_self_signed_certifcate_util.py b/sdk/python/tests/utils/generate_self_signed_certifcate_util.py new file mode 100644 index 00000000000..1b0b212818c --- /dev/null +++ b/sdk/python/tests/utils/generate_self_signed_certifcate_util.py @@ -0,0 +1,73 @@ +import logging +from datetime import datetime, timedelta + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID + +logger = logging.getLogger(__name__) + + +def generate_self_signed_cert( + cert_path="cert.pem", key_path="key.pem", common_name="localhost" +): + """ + Generate a self-signed certificate and save it to the specified paths. + + :param cert_path: Path to save the certificate (PEM format) + :param key_path: Path to save the private key (PEM format) + :param common_name: Common name (CN) for the certificate, defaults to 'localhost' + """ + # Generate private key + key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + + # Create a self-signed certificate + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"), + x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Feast"), + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ] + ) + + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.utcnow()) + .not_valid_after( + # Certificate valid for 1 year + datetime.utcnow() + timedelta(days=365) + ) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName(common_name)]), + critical=False, + ) + .sign(key, hashes.SHA256(), default_backend()) + ) + + # Write the private key to a file + with open(key_path, "wb") as f: + f.write( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + # Write the certificate to a file + with open(cert_path, "wb") as f: + f.write(certificate.public_bytes(serialization.Encoding.PEM)) + + logger.info( + f"Self-signed certificate and private key have been generated at {cert_path} and {key_path}." + )