diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 96c3b65658..077f807acb 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -246,6 +246,22 @@ cert-expiration: in: body required: true type: string +client_authentication: + description: | + The TLS client authentication mode. One of the options ``NONE``, + ``OPTIONAL`` or ``MANDATORY``. + in: body + min_version: 2.8 + required: true + type: string +client_authentication-optional: + description: | + The TLS client authentication mode. One of the options ``NONE``, + ``OPTIONAL`` or ``MANDATORY``. + in: body + min_version: 2.8 + required: false + type: string client_ca_tls_container_ref: description: | The ref of the `key manager service diff --git a/api-ref/source/v2/examples/listener-create-curl b/api-ref/source/v2/examples/listener-create-curl index a04d86dea1..1b5994432a 100644 --- a/api-ref/source/v2/examples/listener-create-curl +++ b/api-ref/source/v2/examples/listener-create-curl @@ -1 +1 @@ -curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"listener": {"protocol": "TERMINATED_HTTPS", "description": "A great TLS listener", "admin_state_up": true, "connection_limit": 200, "protocol_port": "443", "loadbalancer_id": "607226db-27ef-4d41-ae89-f2a800e9c2db", "name": "great_tls_listener", "insert_headers": {"X-Forwarded-For": "true", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 50000, "timeout_member_connect": 5000, "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5"}}' http://198.51.100.10:9876/v2/lbaas/listeners +curl -X POST -H "Content-Type: application/json" -H "X-Auth-Token: " -d '{"listener": {"protocol": "TERMINATED_HTTPS", "description": "A great TLS listener", "admin_state_up": true, "connection_limit": 200, "protocol_port": "443", "loadbalancer_id": "607226db-27ef-4d41-ae89-f2a800e9c2db", "name": "great_tls_listener", "insert_headers": {"X-Forwarded-For": "true", "X-Forwarded-Port": "true"}, "default_tls_container_ref": "http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "sni_container_refs": ["http://198.51.100.10:9311/v1/containers/a570068c-d295-4780-91d4-3046a325db51", "http://198.51.100.10:9311/v1/containers/aaebb31e-7761-4826-8cb4-2b829caca3ee"], "timeout_client_data": 50000, "timeout_member_connect": 5000, "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5", "client_authentication": "MANDATORY"}}' http://198.51.100.10:9876/v2/lbaas/listeners diff --git a/api-ref/source/v2/examples/listener-create-request.json b/api-ref/source/v2/examples/listener-create-request.json index de7a795c64..d79f1f8494 100644 --- a/api-ref/source/v2/examples/listener-create-request.json +++ b/api-ref/source/v2/examples/listener-create-request.json @@ -21,6 +21,7 @@ "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], - "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5" + "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5", + "client_authentication": "MANDATORY" } } diff --git a/api-ref/source/v2/examples/listener-create-response.json b/api-ref/source/v2/examples/listener-create-response.json index 0f867d5ef1..2ace3262a9 100644 --- a/api-ref/source/v2/examples/listener-create-response.json +++ b/api-ref/source/v2/examples/listener-create-response.json @@ -36,6 +36,7 @@ "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], - "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5" + "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5", + "client_authentication": "MANDATORY" } } diff --git a/api-ref/source/v2/examples/listener-show-response.json b/api-ref/source/v2/examples/listener-show-response.json index ce6afbf6a0..f691c79a79 100644 --- a/api-ref/source/v2/examples/listener-show-response.json +++ b/api-ref/source/v2/examples/listener-show-response.json @@ -36,6 +36,7 @@ "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], - "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5" + "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5", + "client_authentication": "MANDATORY" } } diff --git a/api-ref/source/v2/examples/listener-update-response.json b/api-ref/source/v2/examples/listener-update-response.json index f14e8d2c88..4817bed182 100644 --- a/api-ref/source/v2/examples/listener-update-response.json +++ b/api-ref/source/v2/examples/listener-update-response.json @@ -36,6 +36,7 @@ "timeout_member_data": 100000, "timeout_tcp_inspect": 5, "tags": ["updated_tag"], - "client_ca_tls_container_ref": null + "client_ca_tls_container_ref": null, + "client_authentication": "NONE" } } diff --git a/api-ref/source/v2/examples/listeners-list-response.json b/api-ref/source/v2/examples/listeners-list-response.json index 2b4eabd3a8..c1aa255df2 100644 --- a/api-ref/source/v2/examples/listeners-list-response.json +++ b/api-ref/source/v2/examples/listeners-list-response.json @@ -38,7 +38,8 @@ "timeout_member_data": 50000, "timeout_tcp_inspect": 0, "tags": ["test_tag"], - "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5" + "client_ca_tls_container_ref": "http://198.51.100.10:9311/v1/containers/35649991-49f3-4625-81ce-2465fe8932e5", + "client_authentication": "NONE" } ] } diff --git a/api-ref/source/v2/listener.inc b/api-ref/source/v2/listener.inc index 0f688a4eec..48ce1d9d4f 100644 --- a/api-ref/source/v2/listener.inc +++ b/api-ref/source/v2/listener.inc @@ -46,6 +46,7 @@ Response Parameters .. rest_parameters:: ../parameters.yaml - admin_state_up: admin_state_up + - client_authentication: client_authentication - client_ca_tls_container_ref: client_ca_tls_container_ref - connection_limit: connection_limit - created_at: created_at @@ -137,6 +138,7 @@ Request .. rest_parameters:: ../parameters.yaml - admin_state_up: admin_state_up-default-optional + - client_authentication: client_authentication-optional - client_ca_tls_container_ref: client_ca_tls_container_ref-optional - connection_limit: connection_limit-optional - default_pool: pool-optional @@ -206,6 +208,7 @@ Response Parameters .. rest_parameters:: ../parameters.yaml - admin_state_up: admin_state_up + - client_authentication: client_authentication - client_ca_tls_container_ref: client_ca_tls_container_ref - connection_limit: connection_limit - created_at: created_at @@ -281,6 +284,7 @@ Response Parameters .. rest_parameters:: ../parameters.yaml - admin_state_up: admin_state_up + - client_authentication: client_authentication - client_ca_tls_container_ref: client_ca_tls_container_ref - connection_limit: connection_limit - created_at: created_at @@ -346,6 +350,7 @@ Request .. rest_parameters:: ../parameters.yaml - admin_state_up: admin_state_up-default-optional + - client_authentication: client_authentication-optional - client_ca_tls_container_ref: client_ca_tls_container_ref-optional - connection_limit: connection_limit-optional - default_pool_id: default_pool_id-optional @@ -379,6 +384,7 @@ Response Parameters .. rest_parameters:: ../parameters.yaml - admin_state_up: admin_state_up + - client_authentication: client_authentication - client_ca_tls_container_ref: client_ca_tls_container_ref - connection_limit: connection_limit - created_at: created_at diff --git a/doc/source/contributor/guides/providers.rst b/doc/source/contributor/guides/providers.rst index 3ce85e3ad5..02f2f91114 100644 --- a/doc/source/contributor/guides/providers.rst +++ b/doc/source/contributor/guides/providers.rst @@ -364,9 +364,13 @@ contain the following: | admin_state_up | bool | Admin state: True if up, False if | | | | down. | +------------------------------+--------+-------------------------------------+ -|client_ca_tls_container_data | string | A PEM encoded certificate. | +| client_authentication | string | The TLS client authentication mode. | +| | | One of the options ``NONE``, | +| | | ``OPTIONAL`` or ``MANDATORY``. | +------------------------------+--------+-------------------------------------+ -|client_ca_tls_container_ref | string | The reference to the secrets | +| client_ca_tls_container_data | string | A PEM encoded certificate. | ++------------------------------+--------+-------------------------------------+ +| client_ca_tls_container_ref | string | The reference to the secrets | | | | container. | +------------------------------+--------+-------------------------------------+ | connection_limit | int | The max number of connections | diff --git a/octavia/api/drivers/data_models.py b/octavia/api/drivers/data_models.py index 046dd0b180..133fe88d7e 100644 --- a/octavia/api/drivers/data_models.py +++ b/octavia/api/drivers/data_models.py @@ -134,7 +134,8 @@ class Listener(BaseDataModel): sni_container_data=Unset, timeout_client_data=Unset, timeout_member_connect=Unset, timeout_member_data=Unset, timeout_tcp_inspect=Unset, client_ca_tls_container_ref=Unset, - client_ca_tls_container_data=Unset): + client_ca_tls_container_data=Unset, + client_authentication=Unset): self.admin_state_up = admin_state_up self.connection_limit = connection_limit @@ -158,6 +159,7 @@ class Listener(BaseDataModel): self.timeout_tcp_inspect = timeout_tcp_inspect self.client_ca_tls_container_ref = client_ca_tls_container_ref self.client_ca_tls_container_data = client_ca_tls_container_data + self.client_authentication = client_authentication class Pool(BaseDataModel): diff --git a/octavia/api/v2/controllers/listener.py b/octavia/api/v2/controllers/listener.py index 59bc890b88..4c8f06198e 100644 --- a/octavia/api/v2/controllers/listener.py +++ b/octavia/api/v2/controllers/listener.py @@ -230,6 +230,15 @@ class ListenersController(base.BaseController): "be provided for %s protocol listeners.") % constants.PROTOCOL_TERMINATED_HTTPS) + # Make sure we have a client CA cert if they enable client auth + if (listener_dict.get('client_authentication') != + constants.CLIENT_AUTH_NONE and not + listener_dict.get('client_ca_tls_certificate_id')): + raise exceptions.ValidationException(detail=_( + "Client authentication setting %s requires a client CA " + "container reference.") % + listener_dict.get('client_authentication')) + try: sni_containers = listener_dict.pop('sni_containers', []) tls_refs = [sni['tls_container_id'] for sni in sni_containers] @@ -382,7 +391,16 @@ class ListenersController(base.BaseController): "%s protocol listeners.") % constants.PROTOCOL_TERMINATED_HTTPS) - # Make sure the refs are valid + # Make sure we have a client CA cert if they enable client auth + if ((listener.client_authentication != wtypes.Unset and + listener.client_authentication != constants.CLIENT_AUTH_NONE) + and not (db_listener.client_ca_tls_certificate_id or + listener.client_ca_tls_container_ref)): + raise exceptions.ValidationException(detail=_( + "Client authentication setting %s requires a client CA " + "container reference.") % + listener.client_authentication) + sni_containers = listener.sni_container_refs or [] tls_refs = [sni for sni in sni_containers] if listener.default_tls_container_ref: diff --git a/octavia/api/v2/types/listener.py b/octavia/api/v2/types/listener.py index 0074be9017..b9193b876b 100644 --- a/octavia/api/v2/types/listener.py +++ b/octavia/api/v2/types/listener.py @@ -58,6 +58,7 @@ class ListenerResponse(BaseListenerType): timeout_tcp_inspect = wtypes.wsattr(wtypes.IntegerType()) tags = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType())) client_ca_tls_container_ref = wtypes.StringType() + client_authentication = wtypes.wsattr(wtypes.StringType()) @classmethod def from_data_model(cls, data_model, children=False): @@ -138,6 +139,9 @@ class ListenerPOST(BaseListenerType): default=CONF.haproxy_amphora.timeout_tcp_inspect) tags = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(max_length=255))) client_ca_tls_container_ref = wtypes.StringType(max_length=255) + client_authentication = wtypes.wsattr( + wtypes.Enum(str, *constants.SUPPORTED_CLIENT_AUTH_MODES), + default=constants.CLIENT_AUTH_NONE) class ListenerRootPOST(types.BaseType): @@ -171,6 +175,8 @@ class ListenerPUT(BaseListenerType): maximum=constants.MAX_TIMEOUT)) tags = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(max_length=255))) client_ca_tls_container_ref = wtypes.StringType(max_length=255) + client_authentication = wtypes.wsattr( + wtypes.Enum(str, *constants.SUPPORTED_CLIENT_AUTH_MODES)) class ListenerRootPUT(types.BaseType): @@ -215,6 +221,9 @@ class ListenerSingleCreate(BaseListenerType): default=CONF.haproxy_amphora.timeout_tcp_inspect) tags = wtypes.wsattr(wtypes.ArrayType(wtypes.StringType(max_length=255))) client_ca_tls_container_ref = wtypes.StringType(max_length=255) + client_authentication = wtypes.wsattr( + wtypes.Enum(str, *constants.SUPPORTED_CLIENT_AUTH_MODES), + default=constants.CLIENT_AUTH_NONE) class ListenerStatusResponse(BaseListenerType): diff --git a/octavia/common/constants.py b/octavia/common/constants.py index 2e3e090333..c0a2518332 100644 --- a/octavia/common/constants.py +++ b/octavia/common/constants.py @@ -580,3 +580,11 @@ FLAVOR_DATA = 'flavor_data' # Flavor metadata LOADBALANCER_TOPOLOGY = 'loadbalancer_topology' COMPUTE_FLAVOR = 'compute_flavor' + +# TODO(johnsom) move to octavia_lib +# client certification authorization option +CLIENT_AUTH_NONE = 'NONE' +CLIENT_AUTH_OPTIONAL = 'OPTIONAL' +CLIENT_AUTH_MANDATORY = 'MANDATORY' +SUPPORTED_CLIENT_AUTH_MODES = [CLIENT_AUTH_NONE, CLIENT_AUTH_OPTIONAL, + CLIENT_AUTH_MANDATORY] diff --git a/octavia/common/data_models.py b/octavia/common/data_models.py index 4be7c4feb4..bde6ea81de 100644 --- a/octavia/common/data_models.py +++ b/octavia/common/data_models.py @@ -368,7 +368,8 @@ class Listener(BaseDataModel): created_at=None, updated_at=None, timeout_client_data=None, timeout_member_connect=None, timeout_member_data=None, timeout_tcp_inspect=None, - tags=None, client_ca_tls_certificate_id=None): + tags=None, client_ca_tls_certificate_id=None, + client_authentication=None): self.id = id self.project_id = project_id self.name = name @@ -398,6 +399,7 @@ class Listener(BaseDataModel): self.timeout_tcp_inspect = timeout_tcp_inspect self.tags = tags self.client_ca_tls_certificate_id = client_ca_tls_certificate_id + self.client_authentication = client_authentication def update(self, update_dict): for key, value in update_dict.items(): diff --git a/octavia/common/jinja/haproxy/jinja_cfg.py b/octavia/common/jinja/haproxy/jinja_cfg.py index ad44c01022..ef09802e82 100644 --- a/octavia/common/jinja/haproxy/jinja_cfg.py +++ b/octavia/common/jinja/haproxy/jinja_cfg.py @@ -36,6 +36,10 @@ BALANCE_MAP = { constants.LB_ALGORITHM_SOURCE_IP: 'source' } +CLIENT_AUTH_MAP = {constants.CLIENT_AUTH_NONE: 'none', + constants.CLIENT_AUTH_OPTIONAL: 'optional', + constants.CLIENT_AUTH_MANDATORY: 'required'} + ACTIVE_PENDING_STATUSES = constants.SUPPORTED_PROVISIONING_STATUSES + ( constants.DEGRADED,) @@ -239,6 +243,9 @@ class JinjaTemplater(object): ret_value['client_ca_tls_path'] = '%s' % ( os.path.join(self.base_crt_dir, listener.id, client_ca_filename)) + ret_value['client_auth'] = CLIENT_AUTH_MAP.get( + listener.client_authentication) + if listener.default_pool: ret_value['default_pool'] = self._transform_pool( listener.default_pool, feature_compatibility) diff --git a/octavia/common/jinja/haproxy/templates/macros.j2 b/octavia/common/jinja/haproxy/templates/macros.j2 index 41a6b90ce9..10dffdc59f 100644 --- a/octavia/common/jinja/haproxy/templates/macros.j2 +++ b/octavia/common/jinja/haproxy/templates/macros.j2 @@ -38,8 +38,8 @@ peers {{ "%s_peers"|format(listener.id.replace("-", ""))|trim() }} {% else %} {% set crt_dir_opt = "" %} {% endif %} - {% if listener.client_ca_tls_path %} - {% set client_ca_opt = "ca-file %s"|format(listener.client_ca_tls_path)|trim() %} + {% if listener.client_ca_tls_path and listener.client_auth %} + {% set client_ca_opt = "ca-file %s verify %s"|format(listener.client_ca_tls_path, listener.client_auth)|trim() %} {% else %} {% set client_ca_opt = "" %} {% endif %} diff --git a/octavia/db/migration/alembic_migrations/versions/f21ae3f21adc_add_client_auth_option.py b/octavia/db/migration/alembic_migrations/versions/f21ae3f21adc_add_client_auth_option.py new file mode 100644 index 0000000000..f245899262 --- /dev/null +++ b/octavia/db/migration/alembic_migrations/versions/f21ae3f21adc_add_client_auth_option.py @@ -0,0 +1,61 @@ +# Copyright 2018 Huawei +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +"""Add Client Auth options + +Revision ID: f21ae3f21adc +Revises: 2ad093f6353f +Create Date: 2018-10-01 20:47:52.405865 + +""" + + +from alembic import op +import sqlalchemy as sa + +from octavia.common import constants + +# revision identifiers, used by Alembic. +revision = 'f21ae3f21adc' +down_revision = '2ad093f6353f' + + +def upgrade(): + op.create_table( + u'client_authentication_mode', + sa.Column(u'name', sa.String(10), primary_key=True), + ) + + # Create temporary table for table data seeding + insert_table = sa.table( + u'client_authentication_mode', + sa.column(u'name', sa.String), + ) + + op.bulk_insert( + insert_table, + [ + {'name': constants.CLIENT_AUTH_NONE}, + {'name': constants.CLIENT_AUTH_OPTIONAL}, + {'name': constants.CLIENT_AUTH_MANDATORY} + ] + ) + + op.add_column( + u'listener', + sa.Column(u'client_authentication', sa.String(10), + sa.ForeignKey('client_authentication_mode.name'), + server_default=constants.CLIENT_AUTH_NONE, nullable=False) + ) diff --git a/octavia/db/models.py b/octavia/db/models.py index 9a372ad463..ef362286d3 100644 --- a/octavia/db/models.py +++ b/octavia/db/models.py @@ -33,6 +33,7 @@ from octavia.api.v2.types import load_balancer from octavia.api.v2.types import member from octavia.api.v2.types import pool from octavia.api.v2.types import quotas +from octavia.common import constants from octavia.common import data_models from octavia.db import base_models from octavia.i18n import _ @@ -499,6 +500,11 @@ class Listener(base_models.BASE, base_models.IdMixin, timeout_member_data = sa.Column(sa.Integer, nullable=True) timeout_tcp_inspect = sa.Column(sa.Integer, nullable=True) client_ca_tls_certificate_id = sa.Column(sa.String(255), nullable=True) + client_authentication = sa.Column( + sa.String(10), + sa.ForeignKey("client_authentication_mode.name", + name="fk_listener_client_authentication_mode_name"), + nullable=False, default=constants.CLIENT_AUTH_NONE) _tags = orm.relationship( 'Tags', @@ -762,3 +768,10 @@ class Flavor(base_models.BASE, sa.ForeignKey("flavor_profile.id", name="fk_flavor_flavor_profile_id"), nullable=False) + + +class ClientAuthenticationMode(base_models.BASE): + + __tablename__ = "client_authentication_mode" + + name = sa.Column(sa.String(10), primary_key=True, nullable=False) diff --git a/octavia/db/prepare.py b/octavia/db/prepare.py index 238ed8c2b9..b6fa5dce76 100644 --- a/octavia/db/prepare.py +++ b/octavia/db/prepare.py @@ -101,6 +101,8 @@ def create_listener(listener_dict, lb_id): for sni_container_id in sni_container_ids] listener_dict['sni_containers'] = sni_containers + if 'client_authentication' not in listener_dict: + listener_dict['client_authentication'] = constants.CLIENT_AUTH_NONE return listener_dict diff --git a/octavia/tests/functional/api/v2/test_listener.py b/octavia/tests/functional/api/v2/test_listener.py index 8f2aebc1fb..e3e9d5f123 100644 --- a/octavia/tests/functional/api/v2/test_listener.py +++ b/octavia/tests/functional/api/v2/test_listener.py @@ -957,6 +957,46 @@ class TestListener(base.BaseAPITest): listener_api = self.test_create(**optionals) self.assertEqual(optionals['client_ca_tls_container_ref'], listener_api.get('client_ca_tls_container_ref')) + self.assertEqual(constants.CLIENT_AUTH_NONE, + listener_api.get('client_authentication')) + + def test_create_with_ca_cert_and_option(self): + self.cert_manager_mock().get_secret.return_value = ( + sample_certs.X509_CA_CERT) + optionals = { + 'client_ca_tls_container_ref': uuidutils.generate_uuid(), + 'client_authentication': constants.CLIENT_AUTH_MANDATORY + } + listener_api = self.test_create(**optionals) + self.assertEqual(optionals['client_ca_tls_container_ref'], + listener_api.get('client_ca_tls_container_ref')) + self.assertEqual(optionals['client_authentication'], + listener_api.get('client_authentication')) + + def test_create_with_ca_cert_negative_cases(self): + # create just with option, no client_ca_tls_container_ref specified. + optionals = { + 'client_authentication': constants.CLIENT_AUTH_MANDATORY + } + sni1 = uuidutils.generate_uuid() + sni2 = uuidutils.generate_uuid() + lb_listener = { + 'name': 'listener1', 'default_pool_id': None, + 'description': 'desc1', + 'admin_state_up': False, + 'protocol': constants.PROTOCOL_TERMINATED_HTTPS, + 'protocol_port': 80, + 'default_tls_container_ref': uuidutils.generate_uuid(), + 'sni_container_refs': [sni1, sni2], + 'project_id': self.project_id, + 'loadbalancer_id': self.lb_id} + lb_listener.update(optionals) + body = self._build_body(lb_listener) + response = self.post(self.LISTENERS_PATH, body, status=400).json + self.assertEqual( + "Validation failure: Client authentication setting %s " + "requires a client CA container reference." % + constants.CLIENT_AUTH_MANDATORY, response['faultstring']) def test_create_with_bad_ca_cert_ref(self): sni1 = uuidutils.generate_uuid() @@ -1163,6 +1203,70 @@ class TestListener(base.BaseAPITest): self.assertNotEqual(ori_listener['client_ca_tls_container_ref'], optionals['client_ca_tls_container_ref']) + def test_update_with_only_client_auth_option(self): + optionals = { + 'client_authentication': constants.CLIENT_AUTH_OPTIONAL + } + ori_listener, update_listener = self.test_update(**optionals) + self.assertEqual(optionals['client_authentication'], + update_listener.get('client_authentication')) + self.assertNotEqual(ori_listener['client_authentication'], + optionals['client_authentication']) + + @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') + def test_update_from_nonexist_ca_cert_to_new_ca_cert(self, mock_cert_data): + cert1 = data_models.TLSContainer(certificate='cert 1') + mock_cert_data.return_value = {'tls_cert': cert1} + self.cert_manager_mock().get_secret.return_value = ( + sample_certs.X509_CA_CERT) + tls_uuid = uuidutils.generate_uuid() + listener = self.create_listener( + constants.PROTOCOL_TERMINATED_HTTPS, 80, self.lb_id, + name='listener1', description='desc1', + admin_state_up=False, connection_limit=10, + default_tls_container_ref=tls_uuid, + default_pool_id=None).get(self.root_tag) + self.set_lb_status(self.lb_id) + ca_tls_uuid = uuidutils.generate_uuid() + new_listener = { + 'client_ca_tls_container_ref': ca_tls_uuid} + body = self._build_body(new_listener) + listener_path = self.LISTENER_PATH.format( + listener_id=listener['id']) + api_listener = self.put(listener_path, body).json.get(self.root_tag) + update_expect = {'provisioning_status': constants.PENDING_UPDATE, + 'operating_status': constants.ONLINE} + update_expect.update(new_listener) + listener.update(update_expect) + self.assertEqual(ca_tls_uuid, + api_listener['client_ca_tls_container_ref']) + self.assertEqual(constants.CLIENT_AUTH_NONE, + api_listener['client_authentication']) + + @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') + def test_update_with_ca_cert_negative_cases(self, mock_cert_data): + # update a listener, no ca cert exist + cert1 = data_models.TLSContainer(certificate='cert 1') + mock_cert_data.return_value = {'tls_cert': cert1} + tls_uuid = uuidutils.generate_uuid() + listener = self.create_listener( + constants.PROTOCOL_TERMINATED_HTTPS, 80, self.lb_id, + name='listener1', description='desc1', + admin_state_up=False, connection_limit=10, + default_tls_container_ref=tls_uuid, + default_pool_id=None).get(self.root_tag) + self.set_lb_status(self.lb_id) + lb_listener = { + 'client_authentication': constants.CLIENT_AUTH_OPTIONAL} + body = self._build_body(lb_listener) + listener_path = self.LISTENER_PATH.format( + listener_id=listener['id']) + response = self.put(listener_path, body, status=400).json + self.assertEqual( + "Validation failure: Client authentication setting %s " + "requires a client CA container reference." % + constants.CLIENT_AUTH_OPTIONAL, response['faultstring']) + @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') def test_update_unset_ca_cert(self, mock_cert_data): cert1 = data_models.TLSContainer(certificate='cert 1') @@ -1185,6 +1289,7 @@ class TestListener(base.BaseAPITest): listener_id=listener['id']) api_listener = self.put(listener_path, body).json.get(self.root_tag) self.assertIsNone(api_listener.get('client_ca_tls_container_ref')) + self.assertIsNone(api_listener.get('client_auth_option')) @mock.patch('octavia.common.tls_utils.cert_parser.load_certificates_data') def test_update_with_bad_ca_cert(self, mock_cert_data): diff --git a/octavia/tests/functional/api/v2/test_load_balancer.py b/octavia/tests/functional/api/v2/test_load_balancer.py index 5effa624b0..41e4b83c2f 100644 --- a/octavia/tests/functional/api/v2/test_load_balancer.py +++ b/octavia/tests/functional/api/v2/test_load_balancer.py @@ -2349,16 +2349,16 @@ class TestLoadBalancerGraph(base.BaseAPITest): expected_lb['pools'] = create_pools or [] return create_lb, expected_lb - def _get_listener_bodies(self, name='listener1', protocol_port=80, - create_default_pool_name=None, - create_default_pool_id=None, - create_l7policies=None, - expected_l7policies=None, - create_sni_containers=None, - expected_sni_containers=None, - create_client_ca_tls_container=None, - expected_client_ca_tls_container=None, - create_protocol=constants.PROTOCOL_HTTP): + def _get_listener_bodies( + self, name='listener1', protocol_port=80, + create_default_pool_name=None, create_default_pool_id=None, + create_l7policies=None, expected_l7policies=None, + create_sni_containers=None, expected_sni_containers=None, + create_client_ca_tls_container=None, + expected_client_ca_tls_container=None, + create_protocol=constants.PROTOCOL_HTTP, + create_client_authentication=None, + expected_client_authentication=constants.CLIENT_AUTH_NONE): create_listener = { 'name': name, 'protocol_port': protocol_port, @@ -2379,7 +2379,8 @@ class TestLoadBalancerGraph(base.BaseAPITest): 'timeout_member_data': constants.DEFAULT_TIMEOUT_MEMBER_DATA, 'timeout_tcp_inspect': constants.DEFAULT_TIMEOUT_TCP_INSPECT, 'tags': [], - 'client_ca_tls_container_ref': None + 'client_ca_tls_container_ref': None, + 'client_authentication': constants.CLIENT_AUTH_NONE } if create_sni_containers: create_listener['sni_container_refs'] = create_sni_containers @@ -2398,6 +2399,9 @@ class TestLoadBalancerGraph(base.BaseAPITest): if create_client_ca_tls_container: create_listener['client_ca_tls_container_ref'] = ( create_client_ca_tls_container) + if create_client_authentication: + create_listener['client_authentication'] = ( + create_client_authentication) if expected_sni_containers: expected_listener['sni_container_refs'] = expected_sni_containers if expected_l7policies: @@ -2407,6 +2411,11 @@ class TestLoadBalancerGraph(base.BaseAPITest): if expected_client_ca_tls_container: expected_listener['client_ca_tls_container_ref'] = ( expected_client_ca_tls_container) + expected_listener['client_authentication'] = ( + constants.CLIENT_AUTH_NONE) + if expected_client_authentication: + expected_listener[ + 'client_authentication'] = expected_client_authentication return create_listener, expected_listener def _get_pool_bodies(self, name='pool1', create_members=None, @@ -2670,6 +2679,8 @@ class TestLoadBalancerGraph(base.BaseAPITest): mock_x509_cert.return_value = cert_mock create_client_ca_tls_container = uuidutils.generate_uuid() expected_client_ca_tls_container = create_client_ca_tls_container + create_client_authentication = constants.CLIENT_AUTH_MANDATORY + expected_client_authentication = constants.CLIENT_AUTH_MANDATORY create_sni_containers, expected_sni_containers = ( self._get_sni_container_bodies()) create_listener, expected_listener = self._get_listener_bodies( @@ -2677,7 +2688,9 @@ class TestLoadBalancerGraph(base.BaseAPITest): create_sni_containers=create_sni_containers, expected_sni_containers=expected_sni_containers, create_client_ca_tls_container=create_client_ca_tls_container, - expected_client_ca_tls_container=expected_client_ca_tls_container) + expected_client_ca_tls_container=expected_client_ca_tls_container, + create_client_authentication=create_client_authentication, + expected_client_authentication=expected_client_authentication) create_lb, expected_lb = self._get_lb_bodies( create_listeners=[create_listener], expected_listeners=[expected_listener]) diff --git a/octavia/tests/functional/db/base.py b/octavia/tests/functional/db/base.py index b3081def6a..6e07b0a2dc 100644 --- a/octavia/tests/functional/db/base.py +++ b/octavia/tests/functional/db/base.py @@ -79,6 +79,8 @@ class OctaviaDBTestBase(test_base.DbTestCase): models.L7RuleCompareType) self._seed_lookup_table(session, constants.SUPPORTED_L7POLICY_ACTIONS, models.L7PolicyAction) + self._seed_lookup_table(session, constants.SUPPORTED_CLIENT_AUTH_MODES, + models.ClientAuthenticationMode) def _seed_lookup_table(self, session, name_list, model_cls): for name in name_list: diff --git a/octavia/tests/unit/api/drivers/sample_data_models.py b/octavia/tests/unit/api/drivers/sample_data_models.py index ffd45075fb..0535b7e038 100644 --- a/octavia/tests/unit/api/drivers/sample_data_models.py +++ b/octavia/tests/unit/api/drivers/sample_data_models.py @@ -382,7 +382,8 @@ class SampleDriverDataModels(object): 'timeout_member_connect': 2000, 'timeout_member_data': 3000, 'timeout_tcp_inspect': 4000, - 'client_ca_tls_certificate_id': self.client_ca_tls_certificate_ref + 'client_ca_tls_certificate_id': self.client_ca_tls_certificate_ref, + 'client_authentication': constants.CLIENT_AUTH_NONE } self.test_listener1_dict.update(self._common_test_dict) @@ -439,7 +440,8 @@ class SampleDriverDataModels(object): 'timeout_member_data': 3000, 'timeout_tcp_inspect': 4000, 'client_ca_tls_container_ref': self.client_ca_tls_certificate_ref, - 'client_ca_tls_container_data': ca_cert + 'client_ca_tls_container_data': ca_cert, + 'client_authentication': constants.CLIENT_AUTH_NONE } self.provider_listener2_dict = copy.deepcopy( @@ -452,6 +454,8 @@ class SampleDriverDataModels(object): del self.provider_listener2_dict['l7policies'] self.provider_listener2_dict['client_ca_tls_container_ref'] = None del self.provider_listener2_dict['client_ca_tls_container_data'] + self.provider_listener2_dict['client_authentication'] = ( + constants.CLIENT_AUTH_NONE) self.provider_listener1 = driver_dm.Listener( **self.provider_listener1_dict) diff --git a/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py b/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py index d34bfdab43..4bc62ef058 100644 --- a/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py +++ b/octavia/tests/unit/common/jinja/haproxy/test_jinja_cfg.py @@ -40,7 +40,7 @@ class TestHaproxyCfg(base.TestCase): "sample_listener_id_1/tls_container_id.pem " "crt /var/lib/octavia/certs/sample_listener_id_1 " "ca-file /var/lib/octavia/certs/sample_listener_id_1/" - "client_ca.pem\n" + "client_ca.pem verify required\n" " mode http\n" " default_backend sample_pool_id_1\n" " timeout client 50000\n\n").format( diff --git a/octavia/tests/unit/common/sample_configs/sample_configs.py b/octavia/tests/unit/common/sample_configs/sample_configs.py index ed8ffe6a01..7d6534b6ca 100644 --- a/octavia/tests/unit/common/sample_configs/sample_configs.py +++ b/octavia/tests/unit/common/sample_configs/sample_configs.py @@ -528,7 +528,7 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True, 'l7policies, enabled, insert_headers, timeout_client_data,' 'timeout_member_connect, timeout_member_data, ' 'timeout_tcp_inspect, client_ca_tls_certificate_id,' - 'client_ca_tls_certificate') + 'client_ca_tls_certificate, client_authentication') if l7: pools = [ sample_pool_tuple( @@ -611,7 +611,10 @@ def sample_listener_tuple(proto=None, monitor=True, alloc_default_pool=True, client_ca_tls_certificate=sample_tls_container_tuple( id='cont_id_ca', certificate=sample_certs.X509_CA_CERT, primary_cn=sample_certs.X509_CA_CERT_CN - ) if client_ca_cert else '' + ) if client_ca_cert else '', + client_authentication=( + constants.CLIENT_AUTH_MANDATORY if client_ca_cert else + constants.CLIENT_AUTH_NONE) ) diff --git a/releasenotes/notes/Add-TLS-client-auth-option-15d868d1009fc130.yaml b/releasenotes/notes/Add-TLS-client-auth-option-15d868d1009fc130.yaml new file mode 100644 index 0000000000..babbf36955 --- /dev/null +++ b/releasenotes/notes/Add-TLS-client-auth-option-15d868d1009fc130.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + You can now enable TLS client authentication on listeners.