Skip to content

Commit

Permalink
Support tlsmode (#555)
Browse files Browse the repository at this point in the history
  • Loading branch information
sitingren authored Jul 19, 2024
1 parent f28ed9b commit 07f7a9b
Show file tree
Hide file tree
Showing 5 changed files with 326 additions and 52 deletions.
63 changes: 51 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ with vertica_python.connect(**conn_info) as connection:
| oauth_access_token | See [OAuth Authentication](#oauth-authentication). <br>**_Default_**: "" |
| request_complex_types | See [SQL Data conversion to Python objects](#sql-data-conversion-to-python-objects). <br>**_Default_**: True |
| session_label | Sets a label for the connection on the server. This value appears in the client_label column of the _v_monitor.sessions_ system table. <br>**_Default_**: an auto-generated label with format of `vertica-python-{version}-{random_uuid}` |
| ssl | See [TLS/SSL](#tlsssl). <br>**_Default_**: False (disabled) |
| ssl | See [TLS/SSL](#tlsssl). <br>**_Default_**: None (tlsmode="prefer") |
| tlsmode | Controls whether the connection to the server uses TLS encryption. <br>See [TLS/SSL](#tlsssl). <br>**_Default_**: "prefer" |
| tls_cafile | The name of a file containing trusted SSL certificate authority (CA) certificate(s). <br>See [TLS/SSL](#tlsssl). |
| tls_certfile | The name of a file containing client's certificate(s). <br>See [TLS/SSL](#tlsssl). |
| tls_keyfile | The name of a file containing client's private key. <br>See [TLS/SSL](#tlsssl). |
| unicode_error | See [UTF-8 encoding issues](#utf-8-encoding-issues). <br>**_Default_**: 'strict' (throw error on invalid UTF-8 results) |
| use_prepared_statements | See [Passing parameters to SQL queries](#passing-parameters-to-sql-queries). <br>**_Default_**: False |
| workload | Sets the workload name associated with this session. Valid values are workload names that already exist in a workload routing rule on the server. If a workload name that doesn't exist is entered, the server will reject it and it will be set to the default. <br>**_Default_**: "" |
Expand Down Expand Up @@ -141,22 +145,61 @@ with vertica_python.connect(dsn=connection_str, **additional_info) as conn:
```

#### TLS/SSL
You can pass `True` to `ssl` to enable TLS/SSL connection (equivalent to TLSMode=require).

There are two options to control client-server TLS: `tlsmode` and `ssl`. If both are set, `tlsmode` takes precedence.

`ssl` can be a bool or a `ssl.SSLContext` object. Here is the value mapping between `ssl` (exclude `ssl.SSLContext`) and `tlsmode`:

| `tlsmode` | `ssl` | Description |
| ------------- | ------------- | ---|
| 'disable' | False | only try a non-TLS connection. |
| 'prefer' | (not set) | (Default) first try a TLS connection; if TLS is disabled on the server, then fallback to a non-TLS connection. <br>Note: If TLS is enabled on the server and TLS connection fails, the client rejects the connection. |
| 'require' | True | connects using TLS without verifying certificates. If the TLS connection attempt fails, the client rejects the connection. |
| 'verify-ca' || connects using TLS and confirms that the server certificate has been signed by a trusted certificate authority. |
| 'verify-full' || connects using TLS, confirms that the server certificate has been signed by a trusted certificate authority, and verifies that the host name matches the name provided in the server certificate. |

When `tlsmode` is 'verify-ca' or 'verify-full', these options take certificate/key files: `tls_cafile`, `tls_certfile` and `tls_keyfile`. Otherwise, these options are ignored.

`tlsmode` example:
```python
# [TLSMode: require]
import vertica_python
conn_info = {'host': '127.0.0.1',
'user': 'some_user',
'database': 'a_database',
'tlsmode': 'require'}
connection = vertica_python.connect(**conn_info)
```
```python
# [TLSMode: verify-ca]
import vertica_python
conn_info = {'host': '127.0.0.1',
'user': 'some_user',
'database': 'a_database',
'tlsmode': 'verify-ca',
'tls_cafile': '/path/to/ca_file.pem' # CA certificate used to verify server certificate
}
connection = vertica_python.connect(**conn_info)
```

```python
# [TLSMode: verify-full] + Mutual Mode
import vertica_python

# [TLSMode: require]
conn_info = {'host': '127.0.0.1',
'port': 5433,
'user': 'some_user',
'password': 'some_password',
'database': 'a_database',
'ssl': True}
'tlsmode': 'verify-full',
'tls_cafile' = '/path/to/ca_file.pem' # CA certificate used to verify server certificate
'tls_certfile' = '/path/to/client.pem', # (for mutual mode) client certificate
'tls_keyfile' = '/path/to/client.key' # (for mutual mode) client private key
}
connection = vertica_python.connect(**conn_info)
```

You can pass an `ssl.SSLContext` to `ssl` to customize the SSL connection options. Server mode TLS examples:
You can pass an `ssl.SSLContext` object to `ssl` to customize the underlying SSL connection options. See more on SSL options [here](https://docs.python.org/3/library/ssl.html).

Server mode TLS examples:

```python
import vertica_python
Expand All @@ -171,7 +214,6 @@ ssl_context.verify_mode = ssl.CERT_NONE
conn_info = {'host': '127.0.0.1',
'port': 5433,
'user': 'some_user',
'password': 'some_password',
'database': 'a_database',
'ssl': ssl_context}
connection = vertica_python.connect(**conn_info)
Expand All @@ -187,7 +229,6 @@ ssl_context.load_verify_locations(cafile='/path/to/ca_file.pem') # CA certificat
conn_info = {'host': '127.0.0.1',
'port': 5433,
'user': 'some_user',
'password': 'some_password',
'database': 'a_database',
'ssl': ssl_context}
connection = vertica_python.connect(**conn_info)
Expand All @@ -204,7 +245,6 @@ ssl_context.load_verify_locations(cafile='/path/to/ca_file.pem') # CA certificat
conn_info = {'host': '127.0.0.1',
'port': 5433,
'user': 'some_user',
'password': 'some_password',
'database': 'a_database',
'ssl': ssl_context}
connection = vertica_python.connect(**conn_info)
Expand All @@ -230,13 +270,12 @@ ssl_context.load_cert_chain(certfile='/path/to/client.pem', keyfile='/path/to/cl
conn_info = {'host': '127.0.0.1',
'port': 5433,
'user': 'some_user',
'password': 'some_password',
'database': 'a_database',
'ssl': ssl_context}
connection = vertica_python.connect(**conn_info)
```

See more on SSL options [here](https://docs.python.org/3/library/ssl.html).


#### Kerberos Authentication
In order to use Kerberos authentication, install [dependencies](#using-kerberos-authentication) first, and it is the user's responsibility to ensure that an Ticket-Granting Ticket (TGT) is available and valid. Whether a TGT is available can be easily determined by running the `klist` command. If no TGT is available, then it first must be obtained by running the `kinit` command or by logging in. You can pass in optional arguments to customize the authentication. The arguments are `kerberos_service_name`, which defaults to "vertica", and `kerberos_host_name`, which defaults to the value of argument `host`. For example,
Expand Down
169 changes: 151 additions & 18 deletions vertica_python/tests/integration_tests/test_tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@


class TlsTestCase(VerticaPythonIntegrationTestCase):
SSL_STATE_SQL = 'SELECT ssl_state FROM sessions WHERE session_id=current_session()'

def tearDown(self):
if 'ssl' in self._conn_info:
del self._conn_info['ssl']
# Use a non-TLS connection here so cleanup can happen
# even if mutual mode is configured
self._conn_info['tlsmode'] = 'disable'
with self._connect() as conn:
cur = conn.cursor()
cur.execute("ALTER TLS CONFIGURATION server CERTIFICATE NULL TLSMODE 'DISABLE'")
Expand All @@ -36,8 +39,14 @@ def tearDown(self):
if hasattr(self, 'client_key'):
os.remove(self.client_key.name)
cur.execute("DROP KEY IF EXISTS vp_client_key CASCADE")
if hasattr(self, 'CA_cert'):
os.remove(self.CA_cert.name)
cur.execute("DROP KEY IF EXISTS vp_server_key CASCADE")
cur.execute("DROP KEY IF EXISTS vp_CA_key CASCADE")

for key in ('tlsmode', 'ssl', 'tls_cafile', 'tls_certfile', 'tls_keyfile'):
if key in self._conn_info:
del self._conn_info[key]
super(TlsTestCase, self).tearDown()

def _generate_and_set_certificates(self, mutual_mode=False):
Expand All @@ -52,6 +61,8 @@ def _generate_and_set_certificates(self, mutual_mode=False):
"VALID FOR 3650 EXTENSIONS 'nsComment' = 'Self-signed root CA cert' KEY vp_CA_key")
cur.execute("SELECT certificate_text FROM CERTIFICATES WHERE name='vp_CA_cert'")
vp_CA_cert = cur.fetchone()[0]
with NamedTemporaryFile(delete=False) as self.CA_cert:
self.CA_cert.write(vp_CA_cert.encode())

# Generate a server private key
cur.execute("CREATE KEY vp_server_key TYPE 'RSA' LENGTH 4096")
Expand Down Expand Up @@ -84,7 +95,7 @@ def _generate_and_set_certificates(self, mutual_mode=False):
# This CA certificate is used to verify client certificates
cur.execute('ALTER TLS CONFIGURATION server CERTIFICATE vp_server_cert ADD CA CERTIFICATES vp_CA_cert')
# Enable TLS. Connection succeeds if Vertica verifies that the client certificate is from a trusted CA.
# If the client does not present a client certificate, the connection uses plaintext.
# If the client does not present a client certificate, the connection is rejected.
cur.execute("ALTER TLS CONFIGURATION server TLSMODE 'VERIFY_CA'")

else:
Expand All @@ -100,42 +111,164 @@ def _generate_and_set_certificates(self, mutual_mode=False):

return vp_CA_cert

######################################################
#### Test 'ssl' and 'tlsmode' options are not set ####
######################################################

def test_TLSMode_disable(self):
def test_option_default_server_disable(self):
# TLS is disabled on the server
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'None')

def test_option_default_server_enable(self):
# Setting certificates with TLS configuration
self._generate_and_set_certificates()

# TLS is enabled on the server
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Server')

#######################################################
#### Test 'ssl' and 'tlsmode' options are both set ####
#######################################################

def test_tlsmode_over_ssl(self):
self._conn_info['tlsmode'] = 'disable'
self._conn_info['ssl'] = True
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'None')

###############################################
#### Test 'ssl' option with boolean values ####
###############################################

def test_ssl_false(self):
self._conn_info['ssl'] = False
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone('SELECT ssl_state FROM sessions WHERE session_id=(SELECT current_session())')
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'None')

def test_TLSMode_require_server_disable(self):
def test_ssl_true_server_disable(self):
# Requires that the server use TLS. If the TLS connection attempt fails, the client rejects the connection.
self._conn_info['ssl'] = True
self.assertConnectionFail(err_type=errors.SSLNotSupported,
err_msg='SSL requested but not supported by server')
err_msg='SSL requested but disabled on the server')

def test_TLSMode_require(self):
def test_ssl_true_server_enable(self):
# Setting certificates with TLS configuration
self._generate_and_set_certificates()

# Option 1
self._conn_info['ssl'] = True
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone('SELECT ssl_state FROM sessions WHERE session_id=(SELECT current_session())')
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Server')

###############################
#### Test 'tlsmode' option ####
###############################

def test_TLSMode_disable(self):
self._conn_info['tlsmode'] = 'disable'
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'None')

def test_TLSMode_prefer_server_disable(self):
# TLS is disabled on the server
self._conn_info['tlsmode'] = 'prefer'
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'None')

def test_TLSMode_prefer_server_enable(self):
# Setting certificates with TLS configuration
self._generate_and_set_certificates()

self._conn_info['tlsmode'] = 'prefer'
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Server')

# Option 2
def test_TLSMode_require_server_disable(self):
# Requires that the server use TLS. If the TLS connection attempt fails, the client rejects the connection.
self._conn_info['tlsmode'] = 'require'
self.assertConnectionFail(err_type=errors.SSLNotSupported,
err_msg='SSL requested but disabled on the server')

def test_TLSMode_require_server_enable(self):
# Setting certificates with TLS configuration
self._generate_and_set_certificates()

self._conn_info['tlsmode'] = 'require'
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Server')

def test_TLSMode_verify_ca(self):
# Setting certificates with TLS configuration
CA_cert = self._generate_and_set_certificates()

self._conn_info['tlsmode'] = 'verify-ca'
self._conn_info['tls_cafile'] = self.CA_cert.name
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Server')

def test_TLSMode_verify_full(self):
# Setting certificates with TLS configuration
CA_cert = self._generate_and_set_certificates()

self._conn_info['tlsmode'] = 'verify-full'
self._conn_info['tls_cafile'] = self.CA_cert.name
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Server')

def test_TLSMode_mutual_TLS(self):
# Setting certificates with TLS configuration
CA_cert = self._generate_and_set_certificates(mutual_mode=True)

self._conn_info['tlsmode'] = 'verify-full'
self._conn_info['tls_cafile'] = self.CA_cert.name # CA certificate used to verify server certificate
self._conn_info['tls_certfile'] = self.client_cert.name # client certificate
self._conn_info['tls_keyfile'] = self.client_key.name # private key used for the client certificate
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Mutual')

######################################################
#### Test 'ssl' option with ssl.SSLContext object ####
######################################################

def test_sslcontext_require(self):
# Setting certificates with TLS configuration
self._generate_and_set_certificates()

ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
self._conn_info['ssl'] = ssl_context
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone('SELECT ssl_state FROM sessions WHERE session_id=(SELECT current_session())')
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Server')

def test_TLSMode_verify_ca(self):
def test_sslcontext_verify_ca(self):
# Setting certificates with TLS configuration
CA_cert = self._generate_and_set_certificates()

Expand All @@ -147,10 +280,10 @@ def test_TLSMode_verify_ca(self):

with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone('SELECT ssl_state FROM sessions WHERE session_id=(SELECT current_session())')
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Server')

def test_TLSMode_verify_full(self):
def test_sslcontext_verify_full(self):
# Setting certificates with TLS configuration
CA_cert = self._generate_and_set_certificates()

Expand All @@ -162,10 +295,10 @@ def test_TLSMode_verify_full(self):
self._conn_info['ssl'] = ssl_context
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone('SELECT ssl_state FROM sessions WHERE session_id=(SELECT current_session())')
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Server')

def test_mutual_TLS(self):
def test_sslcontext_mutual_TLS(self):
# Setting certificates with TLS configuration
CA_cert = self._generate_and_set_certificates(mutual_mode=True)

Expand All @@ -178,7 +311,7 @@ def test_mutual_TLS(self):
self._conn_info['ssl'] = ssl_context
with self._connect() as conn:
cur = conn.cursor()
res = self._query_and_fetchone('SELECT ssl_state FROM sessions WHERE session_id=(SELECT current_session())')
res = self._query_and_fetchone(self.SSL_STATE_SQL)
self.assertEqual(res[0], 'Mutual')


Expand Down
8 changes: 7 additions & 1 deletion vertica_python/tests/unit_tests/test_parsedsn.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,20 @@ def test_str_arguments(self):
'session_label=vpclient&unicode_error=strict&'
'log_path=/home/admin/vClient.log&log_level=DEBUG&'
'oauth_access_token=GciOiJSUzI1NiI&'
'workload=python_test_workload&'
'workload=python_test_workload&tlsmode=verify-ca&'
'tls_cafile=tls/ca_cert.pem&tls_certfile=tls/cert.pem&'
'tls_keyfile=tls/key.pem&'
'kerberos_service_name=krb_service&kerberos_host_name=krb_host')
expected = {'database': 'db1', 'host': 'localhost', 'user': 'john',
'password': 'pwd', 'port': 5433, 'log_level': 'DEBUG',
'session_label': 'vpclient', 'unicode_error': 'strict',
'log_path': '/home/admin/vClient.log',
'oauth_access_token': 'GciOiJSUzI1NiI',
'workload': 'python_test_workload',
'tlsmode': 'verify-ca',
'tls_cafile': 'tls/ca_cert.pem',
'tls_certfile': 'tls/cert.pem',
'tls_keyfile': 'tls/key.pem',
'kerberos_service_name': 'krb_service',
'kerberos_host_name': 'krb_host'}
parsed = parse_dsn(dsn)
Expand Down
Loading

0 comments on commit 07f7a9b

Please sign in to comment.