diff --git a/CHANGELOG.md b/CHANGELOG.md index e454e6248..c16bccc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ Use `ResultSummary.server.agent`, `ResultSummary.server.protocol_version`, or call the `dbms.components` procedure instead. - SSL configuration options have been changed: - - `trust` has been removed. + - `trust` has been deprecated and will be removed in a future release. Use `trusted_certificates` instead which expects `None` or a `list`. See the API documentation for more details. - `neo4j.time` module: diff --git a/docs/source/api.rst b/docs/source/api.rst index be219ff26..e0a7c58d7 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -164,6 +164,7 @@ Additional configuration can be provided via the :class:`neo4j.Driver` construct + :ref:`max-connection-pool-size-ref` + :ref:`max-transaction-retry-time-ref` + :ref:`resolver-ref` ++ :ref:`trust-ref` + :ref:`ssl-context-ref` + :ref:`trusted-certificates-ref` + :ref:`user-agent-ref` @@ -276,6 +277,36 @@ For example: :Default: :const:`None` +.. _trust-ref: + +``trust`` +--------- +Specify how to determine the authenticity of encryption certificates provided by the Neo4j instance on connection. + +This setting does not have any effect if ``encrypted`` is set to ``False``. + +:Type: ``neo4j.TRUST_SYSTEM_CA_SIGNED_CERTIFICATES``, ``neo4j.TRUST_ALL_CERTIFICATES`` + +.. py:attribute:: neo4j.TRUST_ALL_CERTIFICATES + + Trust any server certificate (default). This ensures that communication + is encrypted but does not verify the server certificate against a + certificate authority. This option is primarily intended for use with + the default auto-generated server certificate. + +.. py:attribute:: neo4j.TRUST_SYSTEM_CA_SIGNED_CERTIFICATES + + Trust server certificates that can be verified against the system + certificate authority. This option is primarily intended for use with + full certificates. + +:Default: ``neo4j.TRUST_SYSTEM_CA_SIGNED_CERTIFICATES``. + +.. deprecated:: 5.0 + This configuration option is deprecated and will be removed in a future + release. Please use :ref:`trusted-certificates-ref` instead. + + .. _ssl-context-ref: ``ssl_context`` @@ -287,6 +318,8 @@ If give, ``encrypted`` and ``trusted_certificates`` have no effect. :Type: :class:`ssl.SSLContext` or :const:`None` :Default: :const:`None` +.. versionadded:: 5.0 + .. _trusted-certificates-ref: @@ -317,6 +350,8 @@ custom ``ssl_context`` is configured. :Default: :const:`None` +.. versionadded:: 5.0 + .. _user-agent-ref: diff --git a/neo4j/__init__.py b/neo4j/__init__.py index 46a39f66e..6aeb2f56c 100644 --- a/neo4j/__init__.py +++ b/neo4j/__init__.py @@ -54,6 +54,8 @@ "SessionConfig", "SummaryCounters", "Transaction", + "TRUST_ALL_CERTIFICATES", + "TRUST_SYSTEM_CA_SIGNED_CERTIFICATES", "unit_of_work", "Version", "WorkspaceConfig", @@ -105,6 +107,8 @@ READ_ACCESS, ServerInfo, SYSTEM_DATABASE, + TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, Version, WRITE_ACCESS, ) diff --git a/neo4j/_async/driver.py b/neo4j/_async/driver.py index eeda26bc4..d0dd8fad3 100644 --- a/neo4j/_async/driver.py +++ b/neo4j/_async/driver.py @@ -20,7 +20,11 @@ from .._async_compat.util import AsyncUtil from ..addressing import Address -from ..api import READ_ACCESS +from ..api import ( + READ_ACCESS, + TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, +) from ..conf import ( Config, PoolConfig, @@ -71,20 +75,47 @@ def driver(cls, uri, *, auth=None, **config): driver_type, security_type, parsed = parse_neo4j_uri(uri) - if security_type in [SECURITY_TYPE_SELF_SIGNED_CERTIFICATE, SECURITY_TYPE_SECURE] and ("encrypted" in config.keys() or "trusted_certificates" in config.keys()): + # TODO: 6.0 remove "trust" config option + if "trust" in config.keys(): + if config["trust"] not in (TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES): + from neo4j.exceptions import ConfigurationError + raise ConfigurationError( + "The config setting `trust` values are {!r}" + .format( + [ + TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, + ] + ) + ) + + if (security_type in [SECURITY_TYPE_SELF_SIGNED_CERTIFICATE, SECURITY_TYPE_SECURE] + and ("encrypted" in config.keys() + or "trust" in config.keys() + or "trusted_certificates" in config.keys() + or "ssl_context" in config.keys())): from neo4j.exceptions import ConfigurationError - raise ConfigurationError("The config settings 'encrypted' and 'trust' can only be used with the URI schemes {!r}. Use the other URI schemes {!r} for setting encryption settings.".format( - [ - URI_SCHEME_BOLT, - URI_SCHEME_NEO4J, - ], - [ - URI_SCHEME_BOLT_SELF_SIGNED_CERTIFICATE, - URI_SCHEME_BOLT_SECURE, - URI_SCHEME_NEO4J_SELF_SIGNED_CERTIFICATE, - URI_SCHEME_NEO4J_SECURE, - ] - )) + + # TODO: 6.0 remove "trust" from error message + raise ConfigurationError( + 'The config settings "encrypted", "trust", ' + '"trusted_certificates", and "ssl_context" can only be used ' + "with the URI schemes {!r}. Use the other URI schemes {!r} " + "for setting encryption settings." + .format( + [ + URI_SCHEME_BOLT, + URI_SCHEME_NEO4J, + ], + [ + URI_SCHEME_BOLT_SELF_SIGNED_CERTIFICATE, + URI_SCHEME_BOLT_SECURE, + URI_SCHEME_NEO4J_SELF_SIGNED_CERTIFICATE, + URI_SCHEME_NEO4J_SECURE, + ] + ) + ) if security_type == SECURITY_TYPE_SECURE: config["encrypted"] = True diff --git a/neo4j/_sync/driver.py b/neo4j/_sync/driver.py index 3dc41c958..339653d3b 100644 --- a/neo4j/_sync/driver.py +++ b/neo4j/_sync/driver.py @@ -20,7 +20,11 @@ from .._async_compat.util import Util from ..addressing import Address -from ..api import READ_ACCESS +from ..api import ( + READ_ACCESS, + TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, +) from ..conf import ( Config, PoolConfig, @@ -71,20 +75,47 @@ def driver(cls, uri, *, auth=None, **config): driver_type, security_type, parsed = parse_neo4j_uri(uri) - if security_type in [SECURITY_TYPE_SELF_SIGNED_CERTIFICATE, SECURITY_TYPE_SECURE] and ("encrypted" in config.keys() or "trusted_certificates" in config.keys()): + # TODO: 6.0 remove "trust" config option + if "trust" in config.keys(): + if config["trust"] not in (TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES): + from neo4j.exceptions import ConfigurationError + raise ConfigurationError( + "The config setting `trust` values are {!r}" + .format( + [ + TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, + ] + ) + ) + + if (security_type in [SECURITY_TYPE_SELF_SIGNED_CERTIFICATE, SECURITY_TYPE_SECURE] + and ("encrypted" in config.keys() + or "trust" in config.keys() + or "trusted_certificates" in config.keys() + or "ssl_context" in config.keys())): from neo4j.exceptions import ConfigurationError - raise ConfigurationError("The config settings 'encrypted' and 'trust' can only be used with the URI schemes {!r}. Use the other URI schemes {!r} for setting encryption settings.".format( - [ - URI_SCHEME_BOLT, - URI_SCHEME_NEO4J, - ], - [ - URI_SCHEME_BOLT_SELF_SIGNED_CERTIFICATE, - URI_SCHEME_BOLT_SECURE, - URI_SCHEME_NEO4J_SELF_SIGNED_CERTIFICATE, - URI_SCHEME_NEO4J_SECURE, - ] - )) + + # TODO: 6.0 remove "trust" from error message + raise ConfigurationError( + 'The config settings "encrypted", "trust", ' + '"trusted_certificates", and "ssl_context" can only be used ' + "with the URI schemes {!r}. Use the other URI schemes {!r} " + "for setting encryption settings." + .format( + [ + URI_SCHEME_BOLT, + URI_SCHEME_NEO4J, + ], + [ + URI_SCHEME_BOLT_SELF_SIGNED_CERTIFICATE, + URI_SCHEME_BOLT_SECURE, + URI_SCHEME_NEO4J_SELF_SIGNED_CERTIFICATE, + URI_SCHEME_NEO4J_SECURE, + ] + ) + ) if security_type == SECURITY_TYPE_SECURE: config["encrypted"] = True diff --git a/neo4j/api.py b/neo4j/api.py index 0c920311d..b9d4de07c 100644 --- a/neo4j/api.py +++ b/neo4j/api.py @@ -48,6 +48,10 @@ URI_SCHEME_BOLT_ROUTING = "bolt+routing" +# TODO: 6.0 - remove TRUST constants +TRUST_SYSTEM_CA_SIGNED_CERTIFICATES = "TRUST_SYSTEM_CA_SIGNED_CERTIFICATES" # Default +TRUST_ALL_CERTIFICATES = "TRUST_ALL_CERTIFICATES" + SYSTEM_DATABASE = "system" DEFAULT_DATABASE = None # Must be a non string hashable value diff --git a/neo4j/conf.py b/neo4j/conf.py index b5c0cfd34..57bef3dfe 100644 --- a/neo4j/conf.py +++ b/neo4j/conf.py @@ -22,10 +22,15 @@ from .api import ( DEFAULT_DATABASE, + TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, WRITE_ACCESS, ) from .exceptions import ConfigurationError -from .meta import get_user_agent +from .meta import ( + deprecation_warn, + get_user_agent, +) def iter_items(iterable): @@ -44,50 +49,75 @@ def iter_items(iterable): class DeprecatedAlias: + """Used when a config option has been renamed.""" def __init__(self, new): self.new = new +class DeprecatedAlternative: + """Used for deprecated config options that have a similar alternative.""" + + def __init__(self, new, converter=None): + self.new = new + self.converter = converter + + class ConfigType(ABCMeta): def __new__(mcs, name, bases, attributes): fields = [] deprecated_aliases = {} + deprecated_alternatives = {} for base in bases: if type(base) is mcs: fields += base.keys() deprecated_aliases.update(base._deprecated_aliases()) + deprecated_alternatives.update(base._deprecated_alternatives()) for k, v in attributes.items(): if isinstance(v, DeprecatedAlias): deprecated_aliases[k] = v.new + elif isinstance(v, DeprecatedAlternative): + deprecated_alternatives[k] = v.new, v.converter elif not (k.startswith("_") or callable(v) or isinstance(v, (staticmethod, classmethod))): fields.append(k) def keys(_): - return fields - - def _deprecated_aliases(_): - return deprecated_aliases + return set(fields) def _deprecated_keys(_): - return list(deprecated_aliases) + return (set(deprecated_aliases.keys()) + | set(deprecated_alternatives.keys())) def _get_new(_, key): - return deprecated_aliases.get(key) + return deprecated_aliases.get( + key, deprecated_alternatives.get(key, (None,))[0] + ) + + def _deprecated_aliases(_): + return deprecated_aliases + + def _deprecated_alternatives(_): + return deprecated_alternatives attributes.setdefault("keys", classmethod(keys)) - attributes.setdefault("_deprecated_aliases", classmethod(_deprecated_aliases)) - attributes.setdefault("_deprecated_keys", classmethod(_deprecated_keys)) - attributes.setdefault("_get_new", classmethod(_get_new)) + attributes.setdefault("_get_new", + classmethod(_get_new)) + attributes.setdefault("_deprecated_keys", + classmethod(_deprecated_keys)) + attributes.setdefault("_deprecated_aliases", + classmethod(_deprecated_aliases)) + attributes.setdefault("_deprecated_alternatives", + classmethod(_deprecated_alternatives)) - return super(ConfigType, mcs).__new__(mcs, name, bases, - {k: v for k, v in attributes.items() - if k not in deprecated_aliases}) + return super(ConfigType, mcs).__new__( + mcs, name, bases, {k: v for k, v in attributes.items() + if k not in _deprecated_keys(None)} + ) class Config(Mapping, metaclass=ConfigType): @@ -114,7 +144,7 @@ def consume(cls, data): def _consume(cls, data): config = {} if data: - for key in list(cls.keys()) + list(cls._deprecated_keys()): + for key in cls.keys() | cls._deprecated_keys(): try: value = data.pop(key) except KeyError: @@ -132,9 +162,19 @@ def set_attr(k, v): elif k in self._deprecated_keys(): k0 = self._get_new(k) if k0 in data_dict: - raise ValueError("Cannot specify both '{}' and '{}' in config".format(k0, k)) - warn("The '{}' config key is deprecated, please use '{}' instead".format(k, k0)) - set_attr(k0, v) + raise ConfigurationError( + "Cannot specify both '{}' and '{}' in config" + .format(k0, k) + ) + deprecation_warn( + "The '{}' config key is deprecated, please use '{}' " + "instead".format(k, k0) + ) + if k in self._deprecated_aliases(): + set_attr(k0, v) + else: # k in self._deprecated_alternatives: + _, converter = self._deprecated_alternatives()[k] + converter(self, v) else: raise AttributeError(k) @@ -163,6 +203,13 @@ def __iter__(self): return iter(self.keys()) +def _trust_to_trusted_certificates(pool_config, trust): + if trust == TRUST_SYSTEM_CA_SIGNED_CERTIFICATES: + pool_config.trusted_certificates = None + elif trust == TRUST_ALL_CERTIFICATES: + pool_config.trusted_certificates = [] + + class PoolConfig(Config): """ Connection pool configuration. """ @@ -179,6 +226,12 @@ class PoolConfig(Config): connection_timeout = 30.0 # seconds # The maximum amount of time to wait for a TCP connection to be established. + #: Trust + trust = DeprecatedAlternative( + "trusted_certificates", _trust_to_trusted_certificates + ) + # Specify how to determine the authenticity of encryption certificates provided by the Neo4j instance on connection. + #: Custom Resolver resolver = None # Custom resolver function, returning list of resolved addresses. diff --git a/tests/unit/async_/test_driver.py b/tests/unit/async_/test_driver.py index a21da33c8..128504133 100644 --- a/tests/unit/async_/test_driver.py +++ b/tests/unit/async_/test_driver.py @@ -16,12 +16,16 @@ # limitations under the License. +import ssl + import pytest from neo4j import ( AsyncBoltDriver, AsyncGraphDatabase, AsyncNeo4jDriver, + TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, ) from neo4j.api import WRITE_ACCESS from neo4j.exceptions import ConfigurationError @@ -77,6 +81,18 @@ async def test_routing_driver_constructor(protocol, host, port, params, auth_tok ( ({"encrypted": False}, ConfigurationError, "The config settings"), ({"encrypted": True}, ConfigurationError, "The config settings"), + ( + {"encrypted": True, "trust": TRUST_ALL_CERTIFICATES}, + ConfigurationError, "The config settings" + ), + ( + {"trust": TRUST_ALL_CERTIFICATES}, + ConfigurationError, "The config settings" + ), + ( + {"trust": TRUST_SYSTEM_CA_SIGNED_CERTIFICATES}, + ConfigurationError, "The config settings" + ), ( {"encrypted": True, "trusted_certificates": []}, ConfigurationError, "The config settings" @@ -89,6 +105,18 @@ async def test_routing_driver_constructor(protocol, host, port, params, auth_tok {"trusted_certificates": None}, ConfigurationError, "The config settings" ), + ( + {"trusted_certificates": ["foo", "bar"]}, + ConfigurationError, "The config settings" + ), + ( + {"ssl_context": None}, + ConfigurationError, "The config settings" + ), + ( + {"ssl_context": ssl.SSLContext(ssl.PROTOCOL_TLSv1)}, + ConfigurationError, "The config settings" + ), ) ) @mark_async_test @@ -115,6 +143,21 @@ def test_invalid_protocol(test_uri): AsyncGraphDatabase.driver(test_uri) +@pytest.mark.parametrize( + ("test_config", "expected_failure", "expected_failure_message"), + ( + ({"trust": 1}, ConfigurationError, "The config setting `trust`"), + ({"trust": True}, ConfigurationError, "The config setting `trust`"), + ({"trust": None}, ConfigurationError, "The config setting `trust`"), + ) +) +def test_driver_trust_config_error( + test_config, expected_failure, expected_failure_message +): + with pytest.raises(expected_failure, match=expected_failure_message): + AsyncGraphDatabase.driver("bolt://127.0.0.1:9001", **test_config) + + @pytest.mark.parametrize("uri", ( "bolt://127.0.0.1:9000", "neo4j://127.0.0.1:9000", diff --git a/tests/unit/common/test_conf.py b/tests/unit/common/test_conf.py index 89e0402f4..4da02da59 100644 --- a/tests/unit/common/test_conf.py +++ b/tests/unit/common/test_conf.py @@ -20,6 +20,8 @@ from neo4j.api import ( READ_ACCESS, + TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, WRITE_ACCESS, ) from neo4j.conf import ( @@ -145,6 +147,36 @@ def test_pool_config_consume_and_then_consume_again(): assert consumed_pool_config.encrypted == "test" +@pytest.mark.parametrize( + ("value_trust", "expected_trusted_certificates"), + ( + (TRUST_ALL_CERTIFICATES, []), + (TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, None), + ) +) +def test_pool_config_deprecated_trust_config(value_trust, + expected_trusted_certificates): + with pytest.warns(DeprecationWarning, match="trust.*trusted_certificates"): + consumed_pool_config = PoolConfig.consume({"trust": value_trust}) + assert (consumed_pool_config.trusted_certificates + == expected_trusted_certificates) + assert not hasattr(consumed_pool_config, "trust") + + +@pytest.mark.parametrize("value_trust", ( + TRUST_ALL_CERTIFICATES, TRUST_SYSTEM_CA_SIGNED_CERTIFICATES +)) +@pytest.mark.parametrize("trusted_certificates", ( + None, [], ["foo"], ["foo", "bar"] +)) +def test_pool_config_deprecated_and_new_trust_config(value_trust, + trusted_certificates): + with pytest.raises(ConfigurationError, + match="trusted_certificates.*trust"): + PoolConfig.consume({"trust": value_trust, + "trusted_certificates": trusted_certificates}) + + def test_config_consume_chain(): test_config = {} diff --git a/tests/unit/sync/test_driver.py b/tests/unit/sync/test_driver.py index c86098b2a..f084c709a 100644 --- a/tests/unit/sync/test_driver.py +++ b/tests/unit/sync/test_driver.py @@ -16,12 +16,16 @@ # limitations under the License. +import ssl + import pytest from neo4j import ( BoltDriver, GraphDatabase, Neo4jDriver, + TRUST_ALL_CERTIFICATES, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, ) from neo4j.api import WRITE_ACCESS from neo4j.exceptions import ConfigurationError @@ -77,6 +81,18 @@ def test_routing_driver_constructor(protocol, host, port, params, auth_token): ( ({"encrypted": False}, ConfigurationError, "The config settings"), ({"encrypted": True}, ConfigurationError, "The config settings"), + ( + {"encrypted": True, "trust": TRUST_ALL_CERTIFICATES}, + ConfigurationError, "The config settings" + ), + ( + {"trust": TRUST_ALL_CERTIFICATES}, + ConfigurationError, "The config settings" + ), + ( + {"trust": TRUST_SYSTEM_CA_SIGNED_CERTIFICATES}, + ConfigurationError, "The config settings" + ), ( {"encrypted": True, "trusted_certificates": []}, ConfigurationError, "The config settings" @@ -89,6 +105,18 @@ def test_routing_driver_constructor(protocol, host, port, params, auth_token): {"trusted_certificates": None}, ConfigurationError, "The config settings" ), + ( + {"trusted_certificates": ["foo", "bar"]}, + ConfigurationError, "The config settings" + ), + ( + {"ssl_context": None}, + ConfigurationError, "The config settings" + ), + ( + {"ssl_context": ssl.SSLContext(ssl.PROTOCOL_TLSv1)}, + ConfigurationError, "The config settings" + ), ) ) @mark_sync_test @@ -115,6 +143,21 @@ def test_invalid_protocol(test_uri): GraphDatabase.driver(test_uri) +@pytest.mark.parametrize( + ("test_config", "expected_failure", "expected_failure_message"), + ( + ({"trust": 1}, ConfigurationError, "The config setting `trust`"), + ({"trust": True}, ConfigurationError, "The config setting `trust`"), + ({"trust": None}, ConfigurationError, "The config setting `trust`"), + ) +) +def test_driver_trust_config_error( + test_config, expected_failure, expected_failure_message +): + with pytest.raises(expected_failure, match=expected_failure_message): + GraphDatabase.driver("bolt://127.0.0.1:9001", **test_config) + + @pytest.mark.parametrize("uri", ( "bolt://127.0.0.1:9000", "neo4j://127.0.0.1:9000",