From 90226c2ae1f3e26914732a206e584a66643b496e Mon Sep 17 00:00:00 2001 From: Felipe Monteiro Date: Wed, 11 Oct 2017 19:50:11 -0400 Subject: [PATCH] Integrate Deckhand with keystone auth This PS integrates Deckhand with keystone auth so that Deckhand can check whether a keystone token is authenticated (by way of keystonemiddleware) before proceeding with any requests. The architecture for this PS is borrowed from [0] which successfully integrates keystone authentication with the falcon web application framework. However, additional Deckhand-specific changes were made for tests to pass. The following changes have been made: - add paste deploy configuration file which adds keystonemiddleware integration to Deckhand; this makes it trivial for keystonemiddleware to determine whether a token in the X-Auth-Token header is authenticated - use paste.deploy to create a web app - update unit tests for testing controllers - update functional test script to ignore keystone authentication because functional tests don't currently support keystone integration [0] https://github.com/stannum-l/nautilus Change-Id: I6eeeb4a4d9ab1f1cc8fb338e5cc21136ab4d5684 --- deckhand/cmd.py | 2 +- deckhand/conf/config.py | 22 ++- deckhand/context.py | 9 ++ deckhand/control/api.py | 71 ++++------ deckhand/control/base.py | 20 +-- deckhand/control/buckets.py | 3 +- deckhand/control/middleware.py | 134 ++++++++++++++++++ deckhand/control/revision_diffing.py | 2 +- deckhand/control/revision_documents.py | 5 +- deckhand/control/revision_tags.py | 6 +- deckhand/control/revisions.py | 4 +- deckhand/control/rollback.py | 2 +- deckhand/service.py | 68 +++++++++ .../document-crud-error-bucket-conflict.yaml | 4 - .../revision-crud-success-single-bucket.yaml | 4 - deckhand/tests/unit/base.py | 2 + deckhand/tests/unit/control/base.py | 12 +- .../unit/control/test_api_initialization.py | 13 +- .../unit/{policy_fixture.py => fixtures.py} | 32 ++++- deckhand/tests/unit/test_policy.py | 4 +- etc/deckhand/deckhand-paste.ini | 35 +++++ etc/deckhand/deckhand.conf.sample | 19 +++ etc/deckhand/policy.yaml.sample | 3 +- requirements.txt | 1 + setup.cfg | 4 +- tools/functional-tests.sh | 22 +++ 26 files changed, 402 insertions(+), 101 deletions(-) create mode 100644 deckhand/control/middleware.py create mode 100644 deckhand/service.py rename deckhand/tests/unit/{policy_fixture.py => fixtures.py} (75%) create mode 100644 etc/deckhand/deckhand-paste.ini diff --git a/deckhand/cmd.py b/deckhand/cmd.py index 6fb40fe6..c682e0b2 100644 --- a/deckhand/cmd.py +++ b/deckhand/cmd.py @@ -16,7 +16,7 @@ from deckhand.control import api def start_deckhand(): - return api.start_api() + return api.init_application() # Callable to be used by uwsgi. diff --git a/deckhand/conf/config.py b/deckhand/conf/config.py index b7e5ce7f..f5128393 100644 --- a/deckhand/conf/config.py +++ b/deckhand/conf/config.py @@ -33,15 +33,35 @@ barbican_opts = [ ] +context_opts = [ + cfg.BoolOpt('allow_anonymous_access', default=False, + help=""" +Allow limited access to unauthenticated users. + +Assign a boolean to determine API access for unathenticated +users. When set to False, the API cannot be accessed by +unauthenticated users. When set to True, unauthenticated users can +access the API with read-only privileges. This however only applies +when using ContextMiddleware. + +Possible values: + * True + * False +"""), +] + + def register_opts(conf): conf.register_group(barbican_group) conf.register_opts(barbican_opts, group=barbican_group) + conf.register_opts(context_opts) ks_loading.register_auth_conf_options(conf, group=barbican_group.name) ks_loading.register_session_conf_options(conf, group=barbican_group.name) def list_opts(): - opts = {barbican_group: barbican_opts + + opts = {None: context_opts, + barbican_group: barbican_opts + ks_loading.get_session_conf_options() + ks_loading.get_auth_common_conf_options() + ks_loading.get_auth_plugin_conf_options( diff --git a/deckhand/context.py b/deckhand/context.py index aa0d484c..7616411b 100644 --- a/deckhand/context.py +++ b/deckhand/context.py @@ -43,3 +43,12 @@ class RequestContext(context.RequestContext): @classmethod def from_dict(cls, values): return cls(**values) + + +def get_context(): + """A helper method to get a blank context (useful for tests).""" + return RequestContext(user_id=None, + project_id=None, + roles=[], + is_admin=False, + overwrite=False) diff --git a/deckhand/control/api.py b/deckhand/control/api.py index 23b064ea..64cb0c9e 100644 --- a/deckhand/control/api.py +++ b/deckhand/control/api.py @@ -12,27 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging as py_logging import os -import falcon from oslo_config import cfg from oslo_log import log as logging +from oslo_policy import policy +from paste import deploy -from deckhand.control import base -from deckhand.control import buckets -from deckhand.control import revision_diffing -from deckhand.control import revision_documents -from deckhand.control import revision_tags -from deckhand.control import revisions -from deckhand.control import rollback -from deckhand.control import versions from deckhand.db.sqlalchemy import api as db_api CONF = cfg.CONF -logging.register_options(CONF) -# TODO(fmontei): Include deckhand-paste.ini later. -CONFIG_FILES = ['deckhand.conf'] +logging.register_options(CONF) +LOG = logging.getLogger(__name__) + +CONFIG_FILES = ['deckhand.conf', 'deckhand-paste.ini'] def _get_config_files(env=None): @@ -42,46 +37,38 @@ def _get_config_files(env=None): return [os.path.join(dirname, config_file) for config_file in CONFIG_FILES] -def start_api(): +def setup_logging(conf): + # Add additional dependent libraries that have unhelp bug levels + extra_log_level_defaults = [] + + logging.set_defaults(default_log_levels=logging.get_default_log_levels() + + extra_log_level_defaults) + logging.setup(conf, 'deckhand') + py_logging.captureWarnings(True) + + +def init_application(): """Main entry point for initializing the Deckhand API service. Create routes for the v1.0 API and sets up logging. """ config_files = _get_config_files() - CONF([], project='deckhand', default_config_files=config_files) - logging.setup(CONF, "deckhand") + paste_file = config_files[-1] - LOG = logging.getLogger(__name__) - LOG.info('Initiated Deckhand logging.') + CONF([], project='deckhand', default_config_files=config_files) + setup_logging(CONF) + + policy.Enforcer(CONF) + + LOG.debug('Starting WSGI application using %s configuration file.', + paste_file) db_api.drop_db() db_api.setup_db() - control_api = falcon.API(request_type=base.DeckhandRequest) - - v1_0_routes = [ - ('bucket/{bucket_name}/documents', buckets.BucketsResource()), - ('revisions', revisions.RevisionsResource()), - ('revisions/{revision_id}', revisions.RevisionsResource()), - ('revisions/{revision_id}/diff/{comparison_revision_id}', - revision_diffing.RevisionDiffingResource()), - ('revisions/{revision_id}/documents', - revision_documents.RevisionDocumentsResource()), - ('revisions/{revision_id}/rendered-documents', - revision_documents.RenderedDocumentsResource()), - ('revisions/{revision_id}/tags', revision_tags.RevisionTagsResource()), - ('revisions/{revision_id}/tags/{tag}', - revision_tags.RevisionTagsResource()), - ('rollback/{revision_id}', rollback.RollbackResource()) - ] - - for path, res in v1_0_routes: - control_api.add_route(os.path.join('/api/v1.0', path), res) - - control_api.add_route('/versions', versions.VersionsResource()) - - return control_api + app = deploy.loadapp('config:%s' % paste_file, name='deckhand_api') + return app if __name__ == '__main__': - start_api() + init_application() diff --git a/deckhand/control/base.py b/deckhand/control/base.py index b44d7c9f..111fe25f 100644 --- a/deckhand/control/base.py +++ b/deckhand/control/base.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import yaml - import falcon from deckhand import context @@ -35,25 +33,9 @@ class BaseResource(object): resp.headers['Allow'] = ','.join(allowed_methods) resp.status = falcon.HTTP_200 - def to_yaml_body(self, dict_body): - """Converts JSON body into YAML response body. - - :param dict_body: response body to be converted to YAML. - :returns: YAML encoding of `dict_body`. - """ - if isinstance(dict_body, dict): - return yaml.safe_dump(dict_body) - elif isinstance(dict_body, list): - return yaml.safe_dump_all(dict_body) - raise TypeError('Unrecognized dict_body type when converting response ' - 'body to YAML format.') - class DeckhandRequest(falcon.Request): - - def __init__(self, env, options=None): - super(DeckhandRequest, self).__init__(env, options) - self.context = context.RequestContext.from_environ(self.env) + context_type = context.RequestContext @property def project_id(self): diff --git a/deckhand/control/buckets.py b/deckhand/control/buckets.py index 2e1dee2a..aae16598 100644 --- a/deckhand/control/buckets.py +++ b/deckhand/control/buckets.py @@ -66,8 +66,7 @@ class BucketsResource(api_base.BaseResource): bucket_name, list(documents_to_create)) if created_documents: - resp.body = self.to_yaml_body( - self.view_builder.list(created_documents)) + resp.body = self.view_builder.list(created_documents) resp.status = falcon.HTTP_200 resp.append_header('Content-Type', 'application/x-yaml') diff --git a/deckhand/control/middleware.py b/deckhand/control/middleware.py new file mode 100644 index 00000000..f8f76e00 --- /dev/null +++ b/deckhand/control/middleware.py @@ -0,0 +1,134 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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. + +import yaml + +import falcon +from oslo_config import cfg +from oslo_log import log as logging +from oslo_serialization import jsonutils as json + +import deckhand.context +from deckhand import errors + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + +class ContextMiddleware(object): + + def process_request(self, req, resp): + """Convert authentication information into a request context. + + Generate a ``deckhand.context.RequestContext`` object from the + available authentication headers and store in the ``context`` attribute + of the ``req`` object. + + :param req: ``falcon`` request object that will be given the context + object. + :raises: falcon.HTTPUnauthorized: when value of the + 'X-Identity-Status' header is not 'Confirmed' and anonymous access + is disallowed. + """ + if req.headers.get('X-IDENTITY-STATUS') == 'Confirmed': + req.context = deckhand.context.RequestContext.from_environ(req.env) + elif CONF.allow_anonymous_access: + req.context = deckhand.context.get_context() + else: + raise falcon.HTTPUnauthorized() + + +class HookableMiddlewareMixin(object): + """Provides methods to extract before and after hooks from WSGI Middleware + Prior to falcon 0.2.0b1, it's necessary to provide falcon with middleware + as "hook" functions that are either invoked before (to process requests) + or after (to process responses) the API endpoint code runs. + This mixin allows the process_request and process_response methods from a + typical WSGI middleware object to be extracted for use as these hooks, with + the appropriate method signatures. + """ + + def as_before_hook(self): + """Extract process_request method as "before" hook + :return: before hook function + """ + + # Need to wrap this up in a closure because the parameter counts + # differ + def before_hook(req, resp, params=None): + return self.process_request(req, resp) + + try: + return before_hook + except AttributeError as ex: + # No such method, we presume. + message_template = ("Failed to get before hook from middleware " + "{0} - {1}") + message = message_template.format(self.__name__, ex.message) + LOG.error(message) + raise errors.DeckhandException(message) + + def as_after_hook(self): + """Extract process_response method as "after" hook + :return: after hook function + """ + + # Need to wrap this up in a closure because the parameter counts + # differ + def after_hook(req, resp, resource=None): + return self.process_response(req, resp, resource) + + try: + return after_hook + except AttributeError as ex: + # No such method, we presume. + message_template = ("Failed to get after hook from middleware " + "{0} - {1}") + message = message_template.format(self.__name__, ex.message) + LOG.error(message) + raise errors.DeckhandException(message) + + +class YAMLTranslator(HookableMiddlewareMixin, object): + """Middleware for converting all responses (error and success) to YAML. + + ``falcon`` error exceptions use JSON formatting and headers by default. + This middleware will intercept all responses and guarantee they are YAML + format. + + .. note:: + + This does not include the 401 Unauthorized that is raised by + ``keystonemiddleware`` which is executed in the pipeline before + ``falcon`` middleware. + """ + + def process_response(self, req, resp, resource): + resp.set_header('Content-Type', 'application/x-yaml') + + for attr in ('body', 'data'): + if not hasattr(resp, attr): + continue + + resp_attr = getattr(resp, attr) + + try: + resp_attr = json.loads(resp_attr) + except (TypeError, ValueError): + pass + + if isinstance(resp_attr, dict): + setattr(resp, attr, yaml.safe_dump(resp_attr)) + elif isinstance(resp_attr, (list, tuple)): + setattr(resp, attr, yaml.safe_dump_all(resp_attr)) diff --git a/deckhand/control/revision_diffing.py b/deckhand/control/revision_diffing.py index 48e352de..cd6f728c 100644 --- a/deckhand/control/revision_diffing.py +++ b/deckhand/control/revision_diffing.py @@ -38,4 +38,4 @@ class RevisionDiffingResource(api_base.BaseResource): resp.status = falcon.HTTP_200 resp.append_header('Content-Type', 'application/x-yaml') - resp.body = self.to_yaml_body(resp_body) + resp.body = resp_body diff --git a/deckhand/control/revision_documents.py b/deckhand/control/revision_documents.py index 9262c473..85a6762f 100644 --- a/deckhand/control/revision_documents.py +++ b/deckhand/control/revision_documents.py @@ -62,7 +62,7 @@ class RevisionDocumentsResource(api_base.BaseResource): resp.status = falcon.HTTP_200 resp.append_header('Content-Type', 'application/x-yaml') - resp.body = self.to_yaml_body(self.view_builder.list(documents)) + resp.body = self.view_builder.list(documents) class RenderedDocumentsResource(api_base.BaseResource): @@ -109,5 +109,4 @@ class RenderedDocumentsResource(api_base.BaseResource): resp.status = falcon.HTTP_200 resp.append_header('Content-Type', 'application/x-yaml') - resp.body = self.to_yaml_body( - self.view_builder.list(rendered_documents)) + resp.body = self.view_builder.list(rendered_documents) diff --git a/deckhand/control/revision_tags.py b/deckhand/control/revision_tags.py index 31eaf939..4eac65b3 100644 --- a/deckhand/control/revision_tags.py +++ b/deckhand/control/revision_tags.py @@ -52,7 +52,7 @@ class RevisionTagsResource(api_base.BaseResource): resp_body = revision_tag_view.ViewBuilder().show(resp_tag) resp.status = falcon.HTTP_201 resp.append_header('Content-Type', 'application/x-yaml') - resp.body = self.to_yaml_body(resp_body) + resp.body = resp_body def on_get(self, req, resp, revision_id, tag=None): """Show tag details or list all tags for a revision.""" @@ -73,7 +73,7 @@ class RevisionTagsResource(api_base.BaseResource): resp_body = revision_tag_view.ViewBuilder().show(resp_tag) resp.status = falcon.HTTP_200 resp.append_header('Content-Type', 'application/x-yaml') - resp.body = self.to_yaml_body(resp_body) + resp.body = resp_body @policy.authorize('deckhand:list_tags') def _list_all_tags(self, req, resp, revision_id): @@ -86,7 +86,7 @@ class RevisionTagsResource(api_base.BaseResource): resp_body = revision_tag_view.ViewBuilder().list(resp_tags) resp.status = falcon.HTTP_200 resp.append_header('Content-Type', 'application/x-yaml') - resp.body = self.to_yaml_body(resp_body) + resp.body = resp_body def on_delete(self, req, resp, revision_id, tag=None): """Deletes a single tag or deletes all tags for a revision.""" diff --git a/deckhand/control/revisions.py b/deckhand/control/revisions.py index 7162f511..ac7084e2 100644 --- a/deckhand/control/revisions.py +++ b/deckhand/control/revisions.py @@ -54,7 +54,7 @@ class RevisionsResource(api_base.BaseResource): revision_resp = self.view_builder.show(revision) resp.status = falcon.HTTP_200 resp.append_header('Content-Type', 'application/x-yaml') - resp.body = self.to_yaml_body(revision_resp) + resp.body = revision_resp @policy.authorize('deckhand:list_revisions') @common.sanitize_params(['tag']) @@ -64,7 +64,7 @@ class RevisionsResource(api_base.BaseResource): resp.status = falcon.HTTP_200 resp.append_header('Content-Type', 'application/x-yaml') - resp.body = self.to_yaml_body(revisions_resp) + resp.body = revisions_resp @policy.authorize('deckhand:delete_revisions') def on_delete(self, req, resp): diff --git a/deckhand/control/rollback.py b/deckhand/control/rollback.py index 77169fb8..42099765 100644 --- a/deckhand/control/rollback.py +++ b/deckhand/control/rollback.py @@ -48,4 +48,4 @@ class RollbackResource(api_base.BaseResource): revision_resp = self.view_builder.show(rollback_revision) resp.status = falcon.HTTP_201 resp.append_header('Content-Type', 'application/x-yaml') - resp.body = self.to_yaml_body(revision_resp) + resp.body = revision_resp diff --git a/deckhand/service.py b/deckhand/service.py new file mode 100644 index 00000000..78e5173e --- /dev/null +++ b/deckhand/service.py @@ -0,0 +1,68 @@ +# 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. + +import os + +import falcon +from oslo_config import cfg +from oslo_log import log + +from deckhand.control import base +from deckhand.control import buckets +from deckhand.control import middleware +from deckhand.control import revision_diffing +from deckhand.control import revision_documents +from deckhand.control import revision_tags +from deckhand.control import revisions +from deckhand.control import rollback +from deckhand.control import versions + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +def configure_app(app, version=''): + + v1_0_routes = [ + ('bucket/{bucket_name}/documents', buckets.BucketsResource()), + ('revisions', revisions.RevisionsResource()), + ('revisions/{revision_id}', revisions.RevisionsResource()), + ('revisions/{revision_id}/diff/{comparison_revision_id}', + revision_diffing.RevisionDiffingResource()), + ('revisions/{revision_id}/documents', + revision_documents.RevisionDocumentsResource()), + ('revisions/{revision_id}/rendered-documents', + revision_documents.RenderedDocumentsResource()), + ('revisions/{revision_id}/tags', revision_tags.RevisionTagsResource()), + ('revisions/{revision_id}/tags/{tag}', + revision_tags.RevisionTagsResource()), + ('rollback/{revision_id}', rollback.RollbackResource()) + ] + + for path, res in v1_0_routes: + app.add_route(os.path.join('/api/%s' % version, path), res) + app.add_route('/versions', versions.VersionsResource()) + + return app + + +def deckhand_app_factory(global_config, **local_config): + # The order of the middleware is important because the `process_response` + # method for `YAMLTranslator` should execute after that of any other + # middleware to convert the response to YAML format. + middleware_list = [middleware.YAMLTranslator(), + middleware.ContextMiddleware()] + + app = falcon.API(request_type=base.DeckhandRequest, + middleware=middleware_list) + + return configure_app(app, version='v1.0') diff --git a/deckhand/tests/functional/gabbits/document-crud-error-bucket-conflict.yaml b/deckhand/tests/functional/gabbits/document-crud-error-bucket-conflict.yaml index d4b40405..a2b57d18 100644 --- a/deckhand/tests/functional/gabbits/document-crud-error-bucket-conflict.yaml +++ b/deckhand/tests/functional/gabbits/document-crud-error-bucket-conflict.yaml @@ -28,7 +28,3 @@ tests: PUT: /api/v1.0/bucket/b/documents status: 409 data: <@resources/sample-doc.yaml - # Deckhand exceptions return the following content-type header by - # default. TODO(fmontei): Override that later. - response_headers: - content-type: 'application/json; charset=UTF-8' \ No newline at end of file diff --git a/deckhand/tests/functional/gabbits/revision-crud-success-single-bucket.yaml b/deckhand/tests/functional/gabbits/revision-crud-success-single-bucket.yaml index 4f3080f7..e244f9b1 100644 --- a/deckhand/tests/functional/gabbits/revision-crud-success-single-bucket.yaml +++ b/deckhand/tests/functional/gabbits/revision-crud-success-single-bucket.yaml @@ -69,7 +69,3 @@ tests: desc: Verify that the revision was deleted GET: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision'] status: 404 - # Deckhand exceptions return the following content-type header by - # default. TODO(fmontei): Override that later. - response_headers: - content-type: 'application/json; charset=UTF-8' diff --git a/deckhand/tests/unit/base.py b/deckhand/tests/unit/base.py index 8d085aa3..cebbbe63 100644 --- a/deckhand/tests/unit/base.py +++ b/deckhand/tests/unit/base.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import absolute_import + import os import fixtures diff --git a/deckhand/tests/unit/control/base.py b/deckhand/tests/unit/control/base.py index b4533712..4d6ba71d 100644 --- a/deckhand/tests/unit/control/base.py +++ b/deckhand/tests/unit/control/base.py @@ -14,9 +14,9 @@ from falcon import testing as falcon_testing -from deckhand.control import api +from deckhand import service from deckhand.tests.unit import base as test_base -from deckhand.tests.unit import policy_fixture +from deckhand.tests.unit import fixtures class BaseControllerTest(test_base.DeckhandWithDBTestCase, @@ -25,5 +25,9 @@ class BaseControllerTest(test_base.DeckhandWithDBTestCase, def setUp(self): super(BaseControllerTest, self).setUp() - self.app = falcon_testing.TestClient(api.start_api()) - self.policy = self.useFixture(policy_fixture.RealPolicyFixture()) + self.app = falcon_testing.TestClient( + service.deckhand_app_factory(None)) + self.policy = self.useFixture(fixtures.RealPolicyFixture()) + # NOTE: allow_anonymous_access allows these unit tests to get around + # Keystone authentication. + self.useFixture(fixtures.ConfPatcher(allow_anonymous_access=True)) diff --git a/deckhand/tests/unit/control/test_api_initialization.py b/deckhand/tests/unit/control/test_api_initialization.py index 1192566a..891a0d2b 100644 --- a/deckhand/tests/unit/control/test_api_initialization.py +++ b/deckhand/tests/unit/control/test_api_initialization.py @@ -16,7 +16,6 @@ import inspect import mock from deckhand.control import api -from deckhand.control import base from deckhand.control import buckets from deckhand.control import revision_diffing from deckhand.control import revision_documents @@ -45,19 +44,17 @@ class TestApi(test_base.DeckhandTestCase): if inspect.isclass(obj)] return class_names + @mock.patch.object(api, 'policy', autospec=True) @mock.patch.object(api, 'db_api', autospec=True) @mock.patch.object(api, 'logging', autospec=True) @mock.patch.object(api, 'CONF', autospec=True) - @mock.patch.object(api, 'falcon', autospec=True) - def test_start_api(self, mock_falcon, mock_config, mock_logging, - mock_db_api): + @mock.patch('deckhand.service.falcon', autospec=True) + def test_init_application(self, mock_falcon, mock_config, mock_logging, + mock_db_api, _): mock_falcon_api = mock_falcon.API.return_value - result = api.start_api() - self.assertEqual(mock_falcon_api, result) + api.init_application() - mock_falcon.API.assert_called_once_with( - request_type=base.DeckhandRequest) mock_falcon_api.add_route.assert_has_calls([ mock.call('/api/v1.0/bucket/{bucket_name}/documents', self.buckets_resource()), diff --git a/deckhand/tests/unit/policy_fixture.py b/deckhand/tests/unit/fixtures.py similarity index 75% rename from deckhand/tests/unit/policy_fixture.py rename to deckhand/tests/unit/fixtures.py index 8610b5d6..b0e162a8 100644 --- a/deckhand/tests/unit/policy_fixture.py +++ b/deckhand/tests/unit/fixtures.py @@ -12,6 +12,9 @@ # License for the specific language governing permissions and limitations # under the License. +"""Fixtures for Deckhand tests.""" +from __future__ import absolute_import + import os import yaml @@ -24,10 +27,37 @@ from deckhand import policies import deckhand.policy from deckhand.tests.unit import fake_policy - CONF = cfg.CONF +class ConfPatcher(fixtures.Fixture): + """Fixture to patch and restore global CONF. + + This also resets overrides for everything that is patched during + it's teardown. + + """ + + def __init__(self, **kwargs): + """Constructor + + :params group: if specified all config options apply to that group. + + :params **kwargs: the rest of the kwargs are processed as a + set of key/value pairs to be set as configuration override. + + """ + super(ConfPatcher, self).__init__() + self.group = kwargs.pop('group', None) + self.args = kwargs + + def setUp(self): + super(ConfPatcher, self).setUp() + for k, v in self.args.items(): + self.addCleanup(CONF.clear_override, k, self.group) + CONF.set_override(k, v, self.group) + + class RealPolicyFixture(fixtures.Fixture): """Load the live policy for tests. diff --git a/deckhand/tests/unit/test_policy.py b/deckhand/tests/unit/test_policy.py index e5b71309..1fd3e2a1 100644 --- a/deckhand/tests/unit/test_policy.py +++ b/deckhand/tests/unit/test_policy.py @@ -17,7 +17,7 @@ from oslo_policy import policy as common_policy from deckhand.control import base as api_base import deckhand.policy from deckhand.tests.unit import base as test_base -from deckhand.tests.unit import policy_fixture +from deckhand.tests.unit import fixtures class PolicyBaseTestCase(test_base.DeckhandTestCase): @@ -32,7 +32,7 @@ class PolicyBaseTestCase(test_base.DeckhandTestCase): "deckhand:list_cleartext_documents": [['rule:admin_api']] } - self.policy = self.useFixture(policy_fixture.RealPolicyFixture()) + self.policy = self.useFixture(fixtures.RealPolicyFixture()) self._set_rules() def _set_rules(self): diff --git a/etc/deckhand/deckhand-paste.ini b/etc/deckhand/deckhand-paste.ini new file mode 100644 index 00000000..9f56ba51 --- /dev/null +++ b/etc/deckhand/deckhand-paste.ini @@ -0,0 +1,35 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# 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. + +# PasteDeploy Configuration File +# Used to configure uWSGI middleware pipeline + +[filter:authtoken] +paste.filter_factory = keystonemiddleware.auth_token:filter_factory + +[filter:debug] +use = egg:oslo.middleware#debug + +[filter:cors] +paste.filter_factory = oslo_middleware.cors:filter_factory +oslo_config_project = deckhand + +[filter:request_id] +paste.filter_factory = oslo_middleware:RequestId.factory + +[app:api] +paste.app_factory = deckhand.service:deckhand_app_factory + +[pipeline:deckhand_api] +pipeline = authtoken api diff --git a/etc/deckhand/deckhand.conf.sample b/etc/deckhand/deckhand.conf.sample index ae6611ae..ecbbaab5 100644 --- a/etc/deckhand/deckhand.conf.sample +++ b/etc/deckhand/deckhand.conf.sample @@ -1,5 +1,24 @@ [DEFAULT] +# +# From deckhand.conf +# + +# +# Allow limited access to unauthenticated users. +# +# Assign a boolean to determine API access for unathenticated +# users. When set to False, the API cannot be accessed by +# unauthenticated users. When set to True, unauthenticated users can +# access the API with read-only privileges. This however only applies +# when using ContextMiddleware. +# +# Possible values: +# * True +# * False +# (boolean value) +#allow_anonymous_access = false + # # From oslo.log # diff --git a/etc/deckhand/policy.yaml.sample b/etc/deckhand/policy.yaml.sample index d4f3cc90..9449b1e1 100644 --- a/etc/deckhand/policy.yaml.sample +++ b/etc/deckhand/policy.yaml.sample @@ -60,7 +60,8 @@ # GET /api/v1.0/revisions #"deckhand:list_revisions": "rule:admin_api" -# Delete all revisions. +# Delete all revisions. Warning: this is equivalent to purging the +# database. # DELETE /api/v1.0/revisions #"deckhand:delete_revisions": "rule:admin_api" diff --git a/requirements.txt b/requirements.txt index 8899cbcb..ce992d9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ pbr!=2.1.0,>=2.0.0 # Apache-2.0 PasteDeploy>=1.5.0 # MIT Paste # MIT Routes>=2.3.1 # MIT +keystoneauth1>=3.2.0 # Apache-2.0 six>=1.9.0 # MIT oslo.concurrency>=3.8.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index 419aeb1b..0779faf2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,8 +3,8 @@ name = deckhand summary = Secrets management persistence tool. description-file = README.rst -author = deckhand team -home-page = http://deckhand-helm.readthedocs.io/en/latest/ +author = Deckhand team +home-page = http://deckhand.readthedocs.io/en/latest/ classifier = Intended Audience :: Information Technology Intended Audience :: System Administrators diff --git a/tools/functional-tests.sh b/tools/functional-tests.sh index 1f4b90fc..af997a0b 100755 --- a/tools/functional-tests.sh +++ b/tools/functional-tests.sh @@ -47,6 +47,9 @@ function gen_config { cp etc/deckhand/logging.conf.sample $CONF_DIR/logging.conf +# NOTE: allow_anonymous_access allows these functional tests to get around +# Keystone authentication, but the context that is provided has zero privileges +# so we must also override the policy file for authorization to pass. cat < $CONF_DIR/deckhand.conf [DEFAULT] debug = true @@ -54,6 +57,7 @@ log_config_append = $CONF_DIR/logging.conf log_file = deckhand.log log_dir = . use_stderr = true +allow_anonymous_access = true [oslo_policy] policy_file = policy.yaml @@ -64,6 +68,15 @@ policy_file = policy.yaml connection = $DATABASE_URL [keystone_authtoken] +# Populate keystone_authtoken with values like the following should Keystone +# integration be needed here. +# project_domain_name = Default +# project_name = admin +# user_domain_name = Default +# password = devstack +# username = admin +# auth_url = http://127.0.0.1/identity +# auth_type = password EOCONF echo $CONF_DIR/deckhand.conf 1>&2 @@ -73,6 +86,14 @@ EOCONF rm -f deckhand.log } +function gen_paste { + log_section Creating paste config without [filter:authtoken] + # NOTE(fmontei): Since this script does not currently support Keystone + # integration, we remove ``filter:authtoken`` from the ``deckhand_api`` + # pipeline to avoid any kind of auth issues. + sed 's/authtoken api/api/' etc/deckhand/deckhand-paste.ini &> $CONF_DIR/deckhand-paste.ini +} + function gen_policy { log_section Creating policy file with liberal permissions @@ -92,6 +113,7 @@ function gen_policy { } gen_config +gen_paste gen_policy uwsgi \