diff --git a/designate/api/__init__.py b/designate/api/__init__.py index c0a60284f..94ce519b0 100644 --- a/designate/api/__init__.py +++ b/designate/api/__init__.py @@ -68,6 +68,9 @@ api_v2_opts = [ 'means show all results by default'), cfg.IntOpt('max-limit-v2', default=1000, help='Max per-page limit for the V2 API'), + cfg.BoolOpt('quotas-verify-project-id', default=False, + help='Verify that the requested Project ID for quota target ' + 'is a valid project in Keystone.'), ] api_admin_opts = [ diff --git a/designate/api/v2/controllers/quotas.py b/designate/api/v2/controllers/quotas.py index 837274eef..07882a816 100644 --- a/designate/api/v2/controllers/quotas.py +++ b/designate/api/v2/controllers/quotas.py @@ -14,9 +14,11 @@ # License for the specific language governing permissions and limitations # under the License. import pecan +from oslo_config import cfg from oslo_log import log as logging from designate.api.v2.controllers import rest +from designate.common import keystone from designate.objects.adapters import DesignateAdapter from designate.objects import QuotaList @@ -52,6 +54,13 @@ class QuotasController(rest.RestController): context = request.environ['context'] body = request.body_dict + # NOTE(pas-ha) attempting to verify the validity of the project-id + # on a best effort basis + # this will raise only if KeystoneV3 endpoint is not found at all, + # or the creds are passing but the project is not found + if cfg.CONF['service:api'].quotas_verify_project_id: + keystone.verify_project_id(context, tenant_id) + quotas = DesignateAdapter.parse('API_v2', body, QuotaList()) for quota in quotas: diff --git a/designate/cmd/api.py b/designate/cmd/api.py index ac87f1eb8..7f5874d7c 100644 --- a/designate/cmd/api.py +++ b/designate/cmd/api.py @@ -19,6 +19,7 @@ from oslo_config import cfg from oslo_log import log as logging from oslo_reports import guru_meditation_report as gmr +from designate.common import keystone from designate import hookpoints from designate import service from designate import utils @@ -30,6 +31,7 @@ CONF = cfg.CONF CONF.import_opt('workers', 'designate.api', group='service:api') CONF.import_opt('threads', 'designate.api', group='service:api') cfg.CONF.import_group('keystone_authtoken', 'keystonemiddleware.auth_token') +keystone.register_keystone_opts(CONF) def main(): diff --git a/designate/common/keystone.py b/designate/common/keystone.py new file mode 100644 index 000000000..e7ca03617 --- /dev/null +++ b/designate/common/keystone.py @@ -0,0 +1,95 @@ +# +# 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. + +from keystoneauth1 import exceptions as kse +from keystoneauth1 import loading as ksa_loading +from oslo_config import cfg +from oslo_log import log as logging + +from designate import exceptions +from designate.i18n import _ + + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + +keystone_group = cfg.OptGroup( + name='keystone', title='Access to Keystone API') + + +def register_keystone_opts(conf): + conf.register_group(keystone_group) + ksa_loading.register_adapter_conf_options(conf, keystone_group) + ksa_loading.register_session_conf_options(conf, keystone_group) + conf.set_default('service_type', 'identity', group=keystone_group) + + +def list_opts(): + opts = ksa_loading.get_adapter_conf_options() + opts.extend(ksa_loading.get_session_conf_options()) + return opts + + +def verify_project_id(context, project_id): + """verify that a project_id exists. + + This attempts to verify that a project id exists. If it does not, + an HTTPBadRequest is emitted. + + """ + session = ksa_loading.load_session_from_conf_options( + CONF, 'keystone', auth=context.get_auth_plugin()) + adap = ksa_loading.load_adapter_from_conf_options( + CONF, 'keystone', + session=session, min_version=(3, 0), max_version=(3, 'latest')) + try: + resp = adap.get('/projects/%s' % project_id, raise_exc=False) + except kse.EndpointNotFound: + LOG.error( + "Keystone identity service version 3.0 was not found. This might " + "be because your endpoint points to the v2.0 versioned endpoint " + "which is not supported. Please fix this.") + raise exceptions.KeystoneCommunicationFailure( + _("KeystoneV3 endpoint not found")) + except kse.ClientException: + # something is wrong, like there isn't a keystone v3 endpoint, + # or nova isn't configured for the interface to talk to it; + # we'll take the pass and default to everything being ok. + LOG.info("Unable to contact keystone to verify project_id") + return True + + if resp: + # All is good with this 20x status + return True + elif resp.status_code == 404: + # we got access, and we know this project is not there + raise exceptions.InvalidProject( + _("%s is not a valid project ID.") % project_id) + + elif resp.status_code == 403: + # we don't have enough permission to verify this, so default + # to "it's ok". + LOG.info( + "Insufficient permissions for user %(user)s to verify " + "existence of project_id %(pid)s", + {"user": context.user_id, "pid": project_id}) + return True + else: + LOG.warning( + "Unexpected response from keystone trying to " + "verify project_id %(pid)s - resp: %(code)s %(content)s", + {"pid": project_id, + "code": resp.status_code, + "content": resp.content}) + # realize we did something wrong, but move on with a warning + return True diff --git a/designate/context.py b/designate/context.py index f21265240..45c0b86aa 100644 --- a/designate/context.py +++ b/designate/context.py @@ -16,6 +16,8 @@ import itertools import copy +from keystoneauth1.access import service_catalog as ksa_service_catalog +from keystoneauth1 import plugin from oslo_context import context from oslo_log import log as logging @@ -40,10 +42,11 @@ class DesignateContext(context.RequestContext): def __init__(self, service_catalog=None, all_tenants=False, abandon=None, tsigkey_id=None, original_tenant=None, edit_managed_records=False, hide_counts=False, - client_addr=None, **kwargs): + client_addr=None, user_auth_plugin=None, **kwargs): super(DesignateContext, self).__init__(**kwargs) + self.user_auth_plugin = user_auth_plugin self.service_catalog = service_catalog self.tsigkey_id = tsigkey_id @@ -193,6 +196,49 @@ class DesignateContext(context.RequestContext): def client_addr(self, value): self._client_addr = value + def get_auth_plugin(self): + if self.user_auth_plugin: + return self.user_auth_plugin + else: + return _ContextAuthPlugin(self.auth_token, self.service_catalog) + + +class _ContextAuthPlugin(plugin.BaseAuthPlugin): + """A keystoneauth auth plugin that uses the values from the Context. + Ideally we would use the plugin provided by auth_token middleware however + this plugin isn't serialized yet so we construct one from the serialized + auth data. + """ + def __init__(self, auth_token, sc): + super(_ContextAuthPlugin, self).__init__() + + self.auth_token = auth_token + self.service_catalog = ksa_service_catalog.ServiceCatalogV2(sc) + + def get_token(self, *args, **kwargs): + return self.auth_token + + def get_endpoint(self, session, **kwargs): + endpoint_data = self.get_endpoint_data(session, **kwargs) + if not endpoint_data: + return None + return endpoint_data.url + + def get_endpoint_data(self, session, + endpoint_override=None, + discover_versions=True, + **kwargs): + urlkw = {} + for k in ('service_type', 'service_name', 'service_id', 'endpoint_id', + 'region_name', 'interface'): + if k in kwargs: + urlkw[k] = kwargs[k] + + endpoint = endpoint_override or self.service_catalog.url_for(**urlkw) + return super(_ContextAuthPlugin, self).get_endpoint_data( + session, endpoint_override=endpoint, + discover_versions=discover_versions, **kwargs) + def get_current(): return context.get_current() diff --git a/designate/exceptions.py b/designate/exceptions.py index 7462c1416..245ca3dd6 100644 --- a/designate/exceptions.py +++ b/designate/exceptions.py @@ -84,6 +84,13 @@ class CommunicationFailure(Base): error_type = 'communication_failure' +class KeystoneCommunicationFailure(CommunicationFailure): + """ + Raised in case one of the alleged Keystone endpoints fails. + """ + error_type = 'keystone_communication_failure' + + class NeutronCommunicationFailure(CommunicationFailure): """ Raised in case one of the alleged Neutron endpoints fails. @@ -138,6 +145,10 @@ class EmptyRequestBody(BadRequest): expected = True +class InvalidProject(BadRequest): + error_type = 'invalid_project' + + class InvalidUUID(BadRequest): error_type = 'invalid_uuid' diff --git a/designate/opts.py b/designate/opts.py index aef85c3c4..14b8eaf15 100644 --- a/designate/opts.py +++ b/designate/opts.py @@ -17,6 +17,7 @@ from oslo_db import options from designate import central +from designate.common import keystone import designate import designate.network_api from designate.network_api import neutron @@ -57,3 +58,4 @@ def list_opts(): yield utils.proxy_group, utils.proxy_opts yield None, service.wsgi_socket_opts yield stt.heartbeat_group, stt.heartbeat_opts + yield keystone.keystone_group, keystone.list_opts() diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 9fc0eae00..85ea2396a 100755 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -119,6 +119,7 @@ function configure_designate { if is_service_enabled tls-proxy; then # Set the service port for a proxy to take the original iniset $DESIGNATE_CONF service:api listen ${DESIGNATE_SERVICE_HOST}:${DESIGNATE_SERVICE_PORT_INT} + iniset $DESIGNATE_CONF keystone cafile $SSL_BUNDLE_FILE else iniset $DESIGNATE_CONF service:api listen ${DESIGNATE_SERVICE_HOST}:${DESIGNATE_SERVICE_PORT} fi @@ -127,6 +128,8 @@ function configure_designate { if is_service_enabled keystone; then iniset $DESIGNATE_CONF service:api auth_strategy keystone configure_auth_token_middleware $DESIGNATE_CONF designate $DESIGNATE_AUTH_CACHE_DIR + iniset $DESIGNATE_CONF keystone region_name $REGION_NAME + iniset $DESIGNATE_CONF service:api quotas_verify_project_id True fi # Logging Configuration @@ -161,6 +164,7 @@ function configure_designate_tempest() { iniset $TEMPEST_CONFIG dns_feature_enabled api_admin $DESIGNATE_ENABLE_API_ADMIN iniset $TEMPEST_CONFIG dns_feature_enabled api_v2_root_recordsets True iniset $TEMPEST_CONFIG dns_feature_enabled api_v2_quotas True + iniset $TEMPEST_CONFIG dns_feature_enabled api_v2_quotas_verify_project True iniset $TEMPEST_CONFIG dns_feature_enabled bug_1573141_fixed True # Tell tempest where are nameservers are. diff --git a/releasenotes/notes/quotas-validate-project-36a2a88b66bc6d63.yaml b/releasenotes/notes/quotas-validate-project-36a2a88b66bc6d63.yaml new file mode 100644 index 000000000..d6b65f7fc --- /dev/null +++ b/releasenotes/notes/quotas-validate-project-36a2a88b66bc6d63.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Designate can verify validity of the project id when setting quotas for it. + This feature is enabled by setting a new configuration option + ``[service:api]quotas_verify_project_id`` to ``True`` (default is ``False`` + for backward compatibility).