Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optional SSH Support for DSR Processing #3374

Merged
merged 21 commits into from
Jun 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7b64256
adds bool option to use ssh via connection secrets
SteveDMurphy May 24, 2023
52757af
adds security settings for bastion host
SteveDMurphy May 24, 2023
e6b75a3
[skip ci] initial changes to query config
SteveDMurphy May 24, 2023
9472a7a
include sshtunnel in requirements.txt
SteveDMurphy May 25, 2023
2d32b89
mypy and pytest error fixes
SteveDMurphy May 26, 2023
11baeda
bug fix: pin paramiko to specific version
SteveDMurphy May 30, 2023
10b1b72
bug fix: handle M1 specific errors with postgres
SteveDMurphy May 30, 2023
a395d9e
improve: server and attribute handling
SteveDMurphy May 30, 2023
5a7e196
Merge branch 'main' of github.com:ethyca/fides into SteveDMurphy-3329…
SteveDMurphy May 30, 2023
d9f146f
fix: further required test update for ssh_required
SteveDMurphy May 30, 2023
74d2e38
[skip ci] remove M1 specific Docker change for tag
SteveDMurphy May 31, 2023
790c224
move ssh tunnel creation to SQLConnector
SteveDMurphy Jun 1, 2023
7b65ffb
include ssh support for RedShift
SteveDMurphy Jun 2, 2023
b132bb0
Merge branch 'main' of github.com:ethyca/fides into SteveDMurphy-3329…
SteveDMurphy Jun 9, 2023
181f421
[skip ci] changelog
SteveDMurphy Jun 13, 2023
0186616
increase timeout to 10 seconds for ssh tunnel
SteveDMurphy Jun 13, 2023
aa4fbed
throw exception when bastion is triggered without any config
Jun 13, 2023
3f64970
be explicit about private key being private
Jun 13, 2023
a3ec2f2
Merge branch 'main' of github.com:ethyca/fides into SteveDMurphy-3329…
SteveDMurphy Jun 13, 2023
d787c43
allow timeouts for ssh tunnel to be configurable
SteveDMurphy Jun 13, 2023
5764332
Merge branch 'main' into SteveDMurphy-3329-dsr-ssh-option
SteveDMurphy Jun 14, 2023
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ The types of changes are:
## [Unreleased](https://github.com/ethyca/fides/compare/2.15.0...main)

### Added

- Included optional env vars to have postgres or Redshift connected via bastion host [#3374](https://github.com/ethyca/fides/pull/3374/)
- Support for acknowledge button for notice-only Privacy Notices and to disable toggling them off [#3546](https://github.com/ethyca/fides/pull/3546)

### Fixed

- Fix race condition with consent modal link rendering [#3521](https://github.com/ethyca/fides/pull/3521)
- Remove the `fides-js` banner from tab order when it is hidden and move the overlay components to the top of the tab order. [#3510](https://github.com/ethyca/fides/pull/3510)
- Disable connector dropdown in integration tab on save [#3552](https://github.com/ethyca/fides/pull/3552)
Expand Down
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pytest==7.2.2
requests-mock==1.10.0
setuptools>=64.0.2
sqlalchemy-stubs
types-paramiko==3.0.0.10
types-PyYAML==6.0.11
types-redis==4.3.4
types-requests
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ services:
FIDES__DEV_MODE: "True"
FIDES__USER__ANALYTICS_OPT_OUT: "True"
FIDES__SECURITY__ALLOW_CUSTOM_CONNECTOR_FUNCTIONS: "True"
FIDES__SECURITY__BASTION_SERVER_HOST: ${FIDES__SECURITY__BASTION_SERVER_HOST-}
FIDES__SECURITY__BASTION_SERVER_SSH_USERNAME: ${FIDES__SECURITY__BASTION_SERVER_SSH_USERNAME-}
FIDES__SECURITY__BASTION_SERVER_SSH_PRIVATE_KEY: ${FIDES__SECURITY__BASTION_SERVER_SSH_PRIVATE_KEY-}
VAULT_ADDR: ${VAULT_ADDR-}
VAULT_NAMESPACE: ${VAULT_NAMESPACE-}
VAULT_TOKEN: ${VAULT_TOKEN-}
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ okta==2.7.0
openpyxl==3.0.9
packaging==23.0
pandas==1.4.3
paramiko==3.1.0
passlib[bcrypt]==1.7.4
plotly==5.13.1
pyarrow==6.0.0
Expand All @@ -49,6 +50,7 @@ sqlalchemy-bigquery==1.4.4
sqlalchemy-redshift==0.8.11
sqlalchemy-stubs==0.4
SQLAlchemy-Utils==0.38.3
sshtunnel==0.4.0
toml>=0.10.1
twilio==7.15.0
typing_extensions==4.5.0 # pinned to work around https://github.com/pydantic/pydantic/issues/5821
Expand Down
4 changes: 4 additions & 0 deletions src/fides/api/common_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ class NoSuchConnectionTypeSecretSchemaError(Exception):
"""Exception for when a connection type secret schema is not found."""


class SSHTunnelConfigNotFoundException(Exception):
"""Exception for when Fides is configured to use an SSH tunnel without config provided."""


class AuthenticationError(HTTPException):
"""To be raised when attempting to fetch an access token using
invalid credentials.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class PostgreSQLSchema(ConnectionConfigSecretsSchema):
str
] = None # Either the entire "url" *OR* the "host" should be supplied.
port: Optional[int] = None
ssh_required: bool = False
SteveDMurphy marked this conversation as resolved.
Show resolved Hide resolved

_required_components: List[str] = ["host"]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class RedshiftSchema(ConnectionConfigSecretsSchema):
user: Optional[str] = None
password: Optional[str] = None
db_schema: Optional[str] = None
ssh_required: bool = False

_required_components: List[str] = ["host", "user", "password"]

Expand Down
101 changes: 100 additions & 1 deletion src/fides/api/service/connectors/sql_connector.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import io
from abc import abstractmethod
from typing import Any, Dict, List, Optional, Type

import paramiko
import sshtunnel # type: ignore
from loguru import logger
from snowflake.sqlalchemy import URL as Snowflake_URL
from sqlalchemy import Column, text
Expand All @@ -16,7 +19,10 @@
from sqlalchemy.sql import Executable # type: ignore
from sqlalchemy.sql.elements import TextClause

from fides.api.common_exceptions import ConnectionException
from fides.api.common_exceptions import (
ConnectionException,
SSHTunnelConfigNotFoundException,
)
from fides.api.graph.traversal import TraversalNode
from fides.api.models.connectionconfig import ConnectionConfig, ConnectionTestStatus
from fides.api.models.policy import Policy
Expand Down Expand Up @@ -46,6 +52,12 @@
SQLQueryConfig,
)
from fides.api.util.collection_util import Row
from fides.core.config import get_config

CONFIG = get_config()

sshtunnel.SSH_TIMEOUT = CONFIG.security.bastion_server_ssh_timeout
sshtunnel.TUNNEL_TIMEOUT = CONFIG.security.bastion_server_ssh_tunnel_timeout


class SQLConnector(BaseConnector[Engine]):
Expand All @@ -61,6 +73,7 @@ def __init__(self, configuration: ConnectionConfig):
raise NotImplementedError(
"SQL Connectors must define their secrets schema class"
)
self.ssh_server: sshtunnel._ForwardServer = None

@staticmethod
def cursor_result_to_rows(results: CursorResult) -> List[Row]:
Expand Down Expand Up @@ -161,6 +174,8 @@ def close(self) -> None:
if self.db_client:
logger.debug(" disposing of {}", self.__class__)
self.db_client.dispose()
if self.ssh_server:
self.ssh_server.stop()

def create_client(self) -> Engine:
"""Returns a SQLAlchemy Engine that can be used to interact with a database"""
Expand All @@ -176,6 +191,29 @@ def set_schema(self, connection: Connection) -> None:
"""Optionally override to set the schema for a given database that
persists through the entire session"""

def create_ssh_tunnel(self, host: Optional[str], port: Optional[int]) -> None:
"""Creates an SSH Tunnel to forward ports as configured."""
if not CONFIG.security.bastion_server_ssh_private_key:
raise SSHTunnelConfigNotFoundException(
"Fides is configured to use an SSH tunnel without config provided."
)

with io.BytesIO(
CONFIG.security.bastion_server_ssh_private_key.encode("utf8")
) as binary_file:
with io.TextIOWrapper(binary_file, encoding="utf8") as file_obj:
private_key = paramiko.RSAKey.from_private_key(file_obj=file_obj)

self.ssh_server = sshtunnel.SSHTunnelForwarder(
(CONFIG.security.bastion_server_host),
ssh_username=CONFIG.security.bastion_server_ssh_username,
ssh_pkey=private_key,
remote_bind_address=(
host,
port,
),
)


class PostgreSQLConnector(SQLConnector):
"""Connector specific to postgresql"""
Expand All @@ -197,6 +235,38 @@ def build_uri(self) -> str:
dbname = f"/{config.dbname}" if config.dbname else ""
return f"postgresql://{user_password}{netloc}{port}{dbname}"

def build_ssh_uri(self, local_address: tuple) -> str:
"""Build URI of format postgresql://[user[:password]@][ssh_host][:ssh_port][/dbname]"""
config = self.secrets_schema(**self.configuration.secrets or {})

user_password = ""
if config.username:
user = config.username
password = f":{config.password}" if config.password else ""
user_password = f"{user}{password}@"

local_host, local_port = local_address
netloc = local_host
port = f":{local_port}" if local_port else ""
dbname = f"/{config.dbname}" if config.dbname else ""
return f"postgresql://{user_password}{netloc}{port}{dbname}"

# Overrides SQLConnector.create_client
def create_client(self) -> Engine:
"""Returns a SQLAlchemy Engine that can be used to interact with a database"""
config = self.secrets_schema(**self.configuration.secrets or {})
if config.ssh_required and CONFIG.security.bastion_server_ssh_private_key:
self.create_ssh_tunnel(host=config.host, port=config.port)
self.ssh_server.start()
uri = self.build_ssh_uri(local_address=self.ssh_server.local_bind_address)
else:
uri = config.url or self.build_uri()
seanpreston marked this conversation as resolved.
Show resolved Hide resolved
return create_engine(
uri,
hide_parameters=self.hide_parameters,
echo=not self.hide_parameters,
)

def set_schema(self, connection: Connection) -> None:
"""Sets the schema for a postgres database if applicable"""
config = self.secrets_schema(**self.configuration.secrets or {})
Expand Down Expand Up @@ -270,6 +340,19 @@ class RedshiftConnector(SQLConnector):

secrets_schema = RedshiftSchema

def build_ssh_uri(self, local_address: tuple) -> str:
"""Build SSH URI of format redshift+psycopg2://[user[:password]@][ssh_host][:ssh_port][/dbname]"""
config = self.secrets_schema(**self.configuration.secrets or {})

local_host, local_port = local_address

config = self.secrets_schema(**self.configuration.secrets or {})

port = f":{local_port}" if local_port else ""
database = f"/{config.database}" if config.database else ""
url = f"redshift+psycopg2://{config.user}:{config.password}@{local_host}{port}{database}"
return url

# Overrides BaseConnector.build_uri
def build_uri(self) -> str:
"""Build URI of format redshift+psycopg2://user:password@[host][:port][/database]"""
Expand All @@ -280,6 +363,22 @@ def build_uri(self) -> str:
url = f"redshift+psycopg2://{config.user}:{config.password}@{config.host}{port}{database}"
return url

# Overrides SQLConnector.create_client
def create_client(self) -> Engine:
"""Returns a SQLAlchemy Engine that can be used to interact with a database"""
config = self.secrets_schema(**self.configuration.secrets or {})
if config.ssh_required and CONFIG.security.bastion_server_ssh_private_key:
self.create_ssh_tunnel(host=config.host, port=config.port)
self.ssh_server.start()
uri = self.build_ssh_uri(local_address=self.ssh_server.local_bind_address)
else:
uri = config.url or self.build_uri()
return create_engine(
uri,
hide_parameters=self.hide_parameters,
echo=not self.hide_parameters,
)

def set_schema(self, connection: Connection) -> None:
"""Sets the search_path for the duration of the session"""
config = self.secrets_schema(**self.configuration.secrets or {})
Expand Down
20 changes: 20 additions & 0 deletions src/fides/core/config/security_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,26 @@ class SecuritySettings(FidesSettings):
description="Either enables the collection of audit log resource data or bypasses the middleware",
)

bastion_server_host: Optional[str] = Field(
default=None, description="An optional field to store the bastion server host"
)
bastion_server_ssh_username: Optional[str] = Field(
default=None,
description="An optional field to store the username used to access the bastion server",
)
bastion_server_ssh_private_key: Optional[str] = Field(
default=None,
description="An optional field to store the key used to SSH into the bastion server.",
)
bastion_server_ssh_timeout: float = Field(
default=0.1,
description="The timeout in seconds for the transport socket (``socket.settimeout``)",
)
bastion_server_ssh_tunnel_timeout: float = Field(
default=10,
description="The timeout in seconds for tunnel connection (open_channel timeout)",
)

@validator("app_encryption_key")
@classmethod
def validate_encryption_key_length(
Expand Down
10 changes: 9 additions & 1 deletion tests/ops/api/v1/endpoints/test_connection_config_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,11 @@ def test_put_connection_config_secrets(
) -> None:
"""Note: this test does not attempt to actually connect to the db, via use of verify query param."""
auth_header = generate_auth_header(scopes=[CONNECTION_CREATE_OR_UPDATE])
payload = {"host": "localhost", "port": "1234", "dbname": "my_test_db"}
payload = {
"host": "localhost",
"port": "1234",
"dbname": "my_test_db",
}
resp = api_client.put(
url + "?verify=False",
headers=auth_header,
Expand All @@ -1361,6 +1365,7 @@ def test_put_connection_config_secrets(
"password": None,
"url": None,
"db_schema": None,
"ssh_required": False,
}

payload = {"url": "postgresql://test_user:test_pass@localhost:1234/my_test_db"}
Expand All @@ -1383,6 +1388,7 @@ def test_put_connection_config_secrets(
"password": None,
"url": payload["url"],
"db_schema": None,
"ssh_required": False,
}
assert connection_config.last_test_timestamp is None
assert connection_config.last_test_succeeded is None
Expand Down Expand Up @@ -1477,6 +1483,7 @@ def test_put_connection_config_redshift_secrets(
"user": "awsuser",
"password": "test_password",
"db_schema": "test",
"ssh_required": False,
}
resp = api_client.put(
url + "?verify=False",
Expand All @@ -1497,6 +1504,7 @@ def test_put_connection_config_redshift_secrets(
"password": "test_password",
"db_schema": "test",
"url": None,
"ssh_required": False,
}
assert redshift_connection_config.last_test_timestamp is None
assert redshift_connection_config.last_test_succeeded is None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def test_postgres_db_connection_incorrect_secrets(
"password": None,
"url": None,
"db_schema": None,
"ssh_required": False,
}
assert connection_config.last_test_timestamp is not None
assert connection_config.last_test_succeeded is False
Expand Down Expand Up @@ -114,6 +115,7 @@ def test_postgres_db_connection_connect_with_components(
"password": "postgres",
"url": None,
"db_schema": None,
"ssh_required": False,
}
assert connection_config.last_test_timestamp is not None
assert connection_config.last_test_succeeded is True
Expand Down Expand Up @@ -155,6 +157,7 @@ def test_postgres_db_connection_connect_with_url(
"password": None,
"url": payload["url"],
"db_schema": None,
"ssh_required": False,
}
assert connection_config.last_test_timestamp is not None
assert connection_config.last_test_succeeded is True
Expand Down