From 582dee6fb9ae893a5f271de0f1da7db523a0c744 Mon Sep 17 00:00:00 2001 From: Felipe Monteiro Date: Thu, 21 Sep 2017 18:22:03 +0100 Subject: [PATCH] DECKHAND-61: oslo.policy integration This PS implements oslo.policy integration in Deckhand. The policy.py file implements 2 types of functions for performing policy enforcement in Deckhand: authorize, which is a decorator that is used directly around falcon on_HTTP_VERB methods that raises a 403 immediately if policy enforcement fails; and conditional_authorize, to be used inside controller code conditionally. For example, since Deckhand has two types of documents with respect to security -- encrypted and cleartext documents -- policy enforcement is conditioned on the type of the documents' metadata.storagePolicy. Included in this PS: - policy framework implementation - policy in code and policy documentation for all Deckhand policies - modification of functional test script to override default admin-only policies with custom policy file dynamically created using lax permissions - bug fix for filtering out deleted documents (and its predecessors in previous revisions) for PUT /revisions/{revision_id}/documents - policy documentation - basic unit tests for policy enforcement framework - allow functional tests to be filtered via regex Due to the size of this PS, functional tests related to policy enforcement will be done in a follow up. Change-Id: If418129f9b401091e098c0bd6c7336b8a5cd2359 --- deckhand/conf/config.py | 20 ---- deckhand/context.py | 99 ++++------------- deckhand/control/api.py | 6 +- deckhand/control/base.py | 8 +- deckhand/control/buckets.py | 39 +++++-- deckhand/control/revision_diffing.py | 2 + deckhand/control/revision_documents.py | 18 ++- deckhand/control/revision_tags.py | 6 + deckhand/control/revisions.py | 4 + deckhand/control/rollback.py | 18 ++- deckhand/control/views/document.py | 4 - deckhand/db/sqlalchemy/api.py | 79 +++++++++---- deckhand/db/sqlalchemy/models.py | 7 +- deckhand/errors.py | 5 + deckhand/policies/__init__.py | 29 +++++ deckhand/policies/base.py | 30 +++++ deckhand/policies/document.py | 105 ++++++++++++++++++ deckhand/policies/revision.py | 66 +++++++++++ deckhand/policies/revision_tag.py | 75 +++++++++++++ deckhand/policy.py | 99 +++++++++++++++++ deckhand/tests/functional/test_gabbi.py | 21 ++-- deckhand/tests/unit/db/base.py | 3 +- .../tests/unit/db/test_revision_documents.py | 82 ++++++++++++++ deckhand/tests/unit/test_policy.py | 82 ++++++++++++++ doc/source/HACKING.rst | 15 +++ doc/source/conf.py | 11 +- doc/source/glossary.rst | 15 +++ doc/source/index.rst | 14 +++ doc/source/policy-enforcement.rst | 54 +++++++++ etc/deckhand/config-generator.conf | 1 + etc/deckhand/deckhand.conf.sample | 20 ++++ etc/deckhand/policy-generator.conf | 3 + etc/deckhand/policy.yaml.sample | 95 ++++++++++++++++ ...o.policy-integration-f03ac6a7a2ccef5a.yaml | 7 ++ setup.cfg | 3 + tools/functional-tests.sh | 54 +++++++-- tox.ini | 3 + 37 files changed, 1038 insertions(+), 164 deletions(-) create mode 100644 deckhand/policies/__init__.py create mode 100644 deckhand/policies/base.py create mode 100644 deckhand/policies/document.py create mode 100644 deckhand/policies/revision.py create mode 100644 deckhand/policies/revision_tag.py create mode 100644 deckhand/policy.py create mode 100644 deckhand/tests/unit/test_policy.py create mode 100644 doc/source/policy-enforcement.rst create mode 100644 etc/deckhand/policy-generator.conf create mode 100644 etc/deckhand/policy.yaml.sample create mode 100644 releasenotes/notes/oslo.policy-integration-f03ac6a7a2ccef5a.yaml diff --git a/deckhand/conf/config.py b/deckhand/conf/config.py index b6336fe1..b7e5ce7f 100644 --- a/deckhand/conf/config.py +++ b/deckhand/conf/config.py @@ -49,24 +49,4 @@ def list_opts(): return opts -def parse_args(args=None, usage=None, default_config_files=None): - CONF(args=args, - project='deckhand', - usage=usage, - default_config_files=default_config_files) - - -def parse_cache_args(args=None): - # Look for Deckhand config files in the following directories:: - # - # ~/.${project}/ - # ~/ - # /etc/${project}/ - # /etc/ - # ${SNAP}/etc/${project} - # ${SNAP_COMMON}/etc/${project} - config_files = cfg.find_config_files(project='deckhand') - parse_args(args=args, default_config_files=config_files) - - register_opts(CONF) diff --git a/deckhand/context.py b/deckhand/context.py index 4caa1d41..b4206e15 100644 --- a/deckhand/context.py +++ b/deckhand/context.py @@ -12,90 +12,39 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""RequestContext: context for requests that persist throughout Deckhand.""" - -import copy - +from oslo_config import cfg from oslo_context import context -from oslo_db.sqlalchemy import enginefacade -from oslo_utils import timeutils -import six +from oslo_policy import policy as common_policy + +from deckhand import policy + +CONF = cfg.CONF -@enginefacade.transaction_context_provider class RequestContext(context.RequestContext): - """Security context and request information. - - Represents the user taking a given action within the system. + """User security context object + Stores information about the security context under which the user + accesses the system, as well as additional request information. """ - def __init__(self, user_id=None, is_admin=None, user_name=None, - timestamp=None, **kwargs): - if user_id: - kwargs['user'] = user_id - - super(RequestContext, self).__init__(is_admin=is_admin, **kwargs) - - if not timestamp: - timestamp = timeutils.utcnow() - if isinstance(timestamp, six.string_types): - timestamp = timeutils.parse_strtime(timestamp) - self.timestamp = timestamp - - @property - def project_id(self): - return self.tenant - - @project_id.setter - def project_id(self, value): - self.tenant = value - - @property - def user_id(self): - return self.user - - @user_id.setter - def user_id(self, value): - self.user = value + def __init__(self, policy_enforcer=None, project=None, **kwargs): + if project: + kwargs['tenant'] = project + self.project = project + self.policy_enforcer = policy_enforcer or common_policy.Enforcer(CONF) + policy.register_rules(self.policy_enforcer) + super(RequestContext, self).__init__(**kwargs) def to_dict(self): - values = super(RequestContext, self).to_dict() - values.update({ - 'user_id': getattr(self, 'user_id', None), - 'project_id': getattr(self, 'project_id', None), - 'is_admin': getattr(self, 'is_admin', None) - }) - return values + out_dict = super(RequestContext, self).to_dict() + out_dict['roles'] = self.roles + + if out_dict.get('tenant'): + out_dict['project'] = out_dict['tenant'] + out_dict.pop('tenant') + return out_dict @classmethod def from_dict(cls, values): - return super(RequestContext, cls).from_dict( - values, - user_id=values.get('user_id'), - project_id=values.get('project_id') - ) - - def elevated(self, read_deleted=None): - """Return a version of this context with admin flag set.""" - context = copy.copy(self) - # context.roles must be deepcopied to leave original roles - # without changes - context.roles = copy.deepcopy(self.roles) - context.is_admin = True - - if 'admin' not in context.roles: - context.roles.append('admin') - - if read_deleted is not None: - context.read_deleted = read_deleted - - return context - - def to_policy_values(self): - policy = super(RequestContext, self).to_policy_values() - policy['is_admin'] = self.is_admin - return policy - - def __str__(self): - return "" % self.to_dict() + return cls(**values) diff --git a/deckhand/control/api.py b/deckhand/control/api.py index 2c0f306b..fe6adbbc 100644 --- a/deckhand/control/api.py +++ b/deckhand/control/api.py @@ -42,7 +42,7 @@ def _get_config_files(env=None): return [os.path.join(dirname, config_file) for config_file in CONFIG_FILES] -def start_api(state_manager=None): +def start_api(): """Main entry point for initializing the Deckhand API service. Create routes for the v1.0 API and sets up logging. @@ -79,3 +79,7 @@ def start_api(state_manager=None): control_api.add_route('/versions', versions.VersionsResource()) return control_api + + +if __name__ == '__main__': + start_api() diff --git a/deckhand/control/base.py b/deckhand/control/base.py index 1ff4e0e0..ff7ad319 100644 --- a/deckhand/control/base.py +++ b/deckhand/control/base.py @@ -15,7 +15,8 @@ import yaml import falcon -from oslo_context import context + +from deckhand import context class BaseResource(object): @@ -50,9 +51,10 @@ class BaseResource(object): class DeckhandRequest(falcon.Request): - def __init__(self, env, options=None): + def __init__(self, env, options=None, policy_enforcer=None): super(DeckhandRequest, self).__init__(env, options) - self.context = context.RequestContext.from_environ(self.env) + self.context = context.RequestContext.from_environ( + self.env, policy_enforcer=policy_enforcer) @property def project_id(self): diff --git a/deckhand/control/buckets.py b/deckhand/control/buckets.py index d105e29a..f0575d55 100644 --- a/deckhand/control/buckets.py +++ b/deckhand/control/buckets.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import itertools import yaml import falcon @@ -24,6 +25,7 @@ from deckhand.db.sqlalchemy import api as db_api from deckhand.engine import document_validation from deckhand.engine import secrets_manager from deckhand import errors as deckhand_errors +from deckhand import policy from deckhand import types LOG = logging.getLogger(__name__) @@ -45,27 +47,50 @@ class BucketsResource(api_base.BaseResource): LOG.error(error_msg) raise falcon.HTTPBadRequest(description=six.text_type(e)) - # All concrete documents in the payload must successfully pass their - # JSON schema validations. Otherwise raise an error. try: + # NOTE: Must validate documents before doing policy enforcement, + # because we expect certain formatting of the documents while doing + # policy enforcement. validation_policies = document_validation.DocumentValidation( documents).validate_all() except deckhand_errors.InvalidDocumentFormat as e: + # FIXME(fmontei): Save the malformed documents and the failed + # validation policy in the DB for future debugging, and only + # afterward raise an exception. raise falcon.HTTPBadRequest(description=e.format_message()) + cleartext_documents = [] + secret_documents = [] + for document in documents: if any([document['schema'].startswith(t) for t in types.DOCUMENT_SECRET_TYPES]): - secret_data = self.secrets_mgr.create(document) - document['data'] = secret_data + secret_documents.append(document) + else: + cleartext_documents.append(document) + + if secret_documents and any( + [d['metadata'].get('storagePolicy') == 'encrypted' + for d in secret_documents]): + policy.conditional_authorize('deckhand:create_encrypted_documents', + req.context) + if cleartext_documents: + policy.conditional_authorize('deckhand:create_cleartext_documents', + req.context) + + for document in secret_documents: + secret_data = self.secrets_mgr.create(document) + document['data'] = secret_data try: - documents.extend(validation_policies) - created_documents = db_api.documents_create(bucket_name, documents) + documents_to_create = itertools.chain( + cleartext_documents, secret_documents, validation_policies) + created_documents = db_api.documents_create( + bucket_name, list(documents_to_create)) except deckhand_errors.DocumentExists as e: raise falcon.HTTPConflict(description=e.format_message()) except Exception as e: - raise falcon.HTTPInternalServerError(description=e) + raise falcon.HTTPInternalServerError(description=six.text_type(e)) if created_documents: resp.body = self.to_yaml_body( diff --git a/deckhand/control/revision_diffing.py b/deckhand/control/revision_diffing.py index b63b7cc1..48e352de 100644 --- a/deckhand/control/revision_diffing.py +++ b/deckhand/control/revision_diffing.py @@ -17,11 +17,13 @@ import falcon from deckhand.control import base as api_base from deckhand.db.sqlalchemy import api as db_api from deckhand import errors +from deckhand import policy class RevisionDiffingResource(api_base.BaseResource): """API resource for realizing revision diffing.""" + @policy.authorize('deckhand:show_revision_diff') def on_get(self, req, resp, revision_id, comparison_revision_id): if revision_id == '0': revision_id = 0 diff --git a/deckhand/control/revision_documents.py b/deckhand/control/revision_documents.py index defa4cdf..a0a8cb00 100644 --- a/deckhand/control/revision_documents.py +++ b/deckhand/control/revision_documents.py @@ -20,6 +20,7 @@ from deckhand.control import common from deckhand.control.views import document as document_view from deckhand.db.sqlalchemy import api as db_api from deckhand import errors +from deckhand import policy LOG = logging.getLogger(__name__) @@ -41,9 +42,24 @@ class RevisionDocumentsResource(api_base.BaseResource): documents will be as originally posted with no substitutions or layering applied. """ + include_cleartext = policy.conditional_authorize( + 'deckhand:list_cleartext_documents', req.context, do_raise=False) + include_encrypted = policy.conditional_authorize( + 'deckhand:list_encrypted_documents', req.context, do_raise=False) + + filters = sanitized_params.copy() + filters['metadata.storagePolicy'] = [] + if include_cleartext: + filters['metadata.storagePolicy'].append('cleartext') + if include_encrypted: + filters['metadata.storagePolicy'].append('encrypted') + + # Never return deleted documents to user. + filters['deleted'] = False + try: documents = db_api.revision_get_documents( - revision_id, **sanitized_params) + revision_id, **filters) except errors.RevisionNotFound as e: raise falcon.HTTPNotFound(description=e.format_message()) diff --git a/deckhand/control/revision_tags.py b/deckhand/control/revision_tags.py index a96d120a..31eaf939 100644 --- a/deckhand/control/revision_tags.py +++ b/deckhand/control/revision_tags.py @@ -21,6 +21,7 @@ from deckhand.control import base as api_base from deckhand.control.views import revision_tag as revision_tag_view from deckhand.db.sqlalchemy import api as db_api from deckhand import errors +from deckhand import policy LOG = logging.getLogger(__name__) @@ -28,6 +29,7 @@ LOG = logging.getLogger(__name__) class RevisionTagsResource(api_base.BaseResource): """API resource for realizing CRUD for revision tags.""" + @policy.authorize('deckhand:create_tag') def on_post(self, req, resp, revision_id, tag=None): """Creates a revision tag.""" body = req.stream.read(req.content_length or 0) @@ -59,6 +61,7 @@ class RevisionTagsResource(api_base.BaseResource): else: self._list_all_tags(req, resp, revision_id) + @policy.authorize('deckhand:show_tag') def _show_tag(self, req, resp, revision_id, tag): """Retrieve details for a specified tag.""" try: @@ -72,6 +75,7 @@ class RevisionTagsResource(api_base.BaseResource): resp.append_header('Content-Type', 'application/x-yaml') resp.body = self.to_yaml_body(resp_body) + @policy.authorize('deckhand:list_tags') def _list_all_tags(self, req, resp, revision_id): """List all tags for a revision.""" try: @@ -91,6 +95,7 @@ class RevisionTagsResource(api_base.BaseResource): else: self._delete_all_tags(req, resp, revision_id) + @policy.authorize('deckhand:delete_tag') def _delete_tag(self, req, resp, revision_id, tag): """Delete a specified tag.""" try: @@ -102,6 +107,7 @@ class RevisionTagsResource(api_base.BaseResource): resp.append_header('Content-Type', 'application/x-yaml') resp.status = falcon.HTTP_204 + @policy.authorize('deckhand:delete_tags') def _delete_all_tags(self, req, resp, revision_id): """Delete all tags for a revision.""" try: diff --git a/deckhand/control/revisions.py b/deckhand/control/revisions.py index 6a3c305e..7162f511 100644 --- a/deckhand/control/revisions.py +++ b/deckhand/control/revisions.py @@ -19,6 +19,7 @@ from deckhand.control import common from deckhand.control.views import revision as revision_view from deckhand.db.sqlalchemy import api as db_api from deckhand import errors +from deckhand import policy class RevisionsResource(api_base.BaseResource): @@ -38,6 +39,7 @@ class RevisionsResource(api_base.BaseResource): else: self._list_revisions(req, resp) + @policy.authorize('deckhand:show_revision') def _show_revision(self, req, resp, revision_id): """Returns detailed description of a particular revision. @@ -54,6 +56,7 @@ class RevisionsResource(api_base.BaseResource): resp.append_header('Content-Type', 'application/x-yaml') resp.body = self.to_yaml_body(revision_resp) + @policy.authorize('deckhand:list_revisions') @common.sanitize_params(['tag']) def _list_revisions(self, req, resp, sanitized_params): revisions = db_api.revision_get_all(**sanitized_params) @@ -63,6 +66,7 @@ class RevisionsResource(api_base.BaseResource): resp.append_header('Content-Type', 'application/x-yaml') resp.body = self.to_yaml_body(revisions_resp) + @policy.authorize('deckhand:delete_revisions') def on_delete(self, req, resp): db_api.revision_delete_all() resp.append_header('Content-Type', 'application/x-yaml') diff --git a/deckhand/control/rollback.py b/deckhand/control/rollback.py index a35af04f..96f1c7b6 100644 --- a/deckhand/control/rollback.py +++ b/deckhand/control/rollback.py @@ -18,6 +18,7 @@ from deckhand.control import base as api_base from deckhand.control.views import revision as revision_view from deckhand.db.sqlalchemy import api as db_api from deckhand import errors +from deckhand import policy class RollbackResource(api_base.BaseResource): @@ -25,15 +26,28 @@ class RollbackResource(api_base.BaseResource): view_builder = revision_view.ViewBuilder() + @policy.authorize('deckhand:create_cleartext_documents') def on_post(self, req, resp, revision_id): try: - revision = db_api.revision_rollback(revision_id) + latest_revision = db_api.revision_get_latest() except errors.RevisionNotFound as e: raise falcon.HTTPNotFound(description=e.format_message()) + + for document in latest_revision['documents']: + if document['metadata'].get('storagePolicy') == 'cleartext': + policy.conditional_authorize( + 'deckhand:create_cleartext_documents', req.context) + elif document['metadata'].get('storagePolicy') == 'encrypted': + policy.conditional_authorize( + 'deckhand:create_encrypted_documents', req.context) + + try: + rollback_revision = db_api.revision_rollback( + revision_id, latest_revision) except errors.InvalidRollback as e: raise falcon.HTTPBadRequest(description=e.format_message()) - revision_resp = self.view_builder.show(revision) + 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) diff --git a/deckhand/control/views/document.py b/deckhand/control/views/document.py index 729380c6..ec126e05 100644 --- a/deckhand/control/views/document.py +++ b/deckhand/control/views/document.py @@ -42,10 +42,6 @@ class ViewBuilder(common.ViewBuilder): attrs = ['id', 'metadata', 'data', 'schema'] for document in documents: - # Never return deleted documents to the user. - if document['deleted']: - continue - resp_obj = {x: document[x] for x in attrs} resp_obj.setdefault('status', {}) resp_obj['status']['bucket'] = document['bucket_name'] diff --git a/deckhand/db/sqlalchemy/api.py b/deckhand/db/sqlalchemy/api.py index bf4cd669..c08a1dca 100644 --- a/deckhand/db/sqlalchemy/api.py +++ b/deckhand/db/sqlalchemy/api.py @@ -234,8 +234,10 @@ def _fill_in_metadata_defaults(values): if not values['_metadata'].get('storagePolicy', None): values['_metadata']['storagePolicy'] = 'cleartext' - if ('layeringDefinition' in values['_metadata'] - and 'abstract' not in values['_metadata']['layeringDefinition']): + if 'layeringDefinition' not in values['_metadata']: + values['_metadata'].setdefault('layeringDefinition', {}) + + if 'abstract' not in values['_metadata']['layeringDefinition']: values['_metadata']['layeringDefinition']['abstract'] = False return values @@ -320,7 +322,7 @@ def revision_create(session=None): return revision.to_dict() -def revision_get(revision_id, session=None): +def revision_get(revision_id=None, session=None): """Return the specified `revision_id`. :param revision_id: The ID corresponding to the ``Revision`` object. @@ -343,6 +345,29 @@ def revision_get(revision_id, session=None): return revision +def revision_get_latest(session=None): + """Return the latest revision. + + :param session: Database session object. + :returns: Dictionary representation of latest revision. + :raises: RevisionNotFound if the latest revision was not found. + """ + session = session or get_session() + + latest_revision = session.query(models.Revision)\ + .order_by(models.Revision.created_at.desc())\ + .first() + if not latest_revision: + raise errors.RevisionNotFound(revision='latest') + + latest_revision = latest_revision.to_dict() + + latest_revision['documents'] = _update_revision_history( + latest_revision['documents']) + + return latest_revision + + def require_revision_exists(f): """Decorator to require the specified revision to exist. @@ -456,6 +481,23 @@ def revision_delete_all(session=None): .delete(synchronize_session=False) +def _exclude_deleted_documents(documents): + """Excludes all documents with ``deleted=True`` field including all + documents earlier in the revision history with the same `metadata.name` + and `schema` from ``documents``. + """ + for doc in copy.copy(documents): + if doc['deleted']: + docs_to_delete = [ + d for d in documents if + (d['schema'], d['name']) == (doc['schema'], doc['name']) + and d['created_at'] <= doc['deleted_at'] + ] + for d in list(docs_to_delete): + documents.remove(d) + return documents + + def _filter_revision_documents(documents, unique_only, **filters): """Return the list of documents that match filters. @@ -466,7 +508,11 @@ def _filter_revision_documents(documents, unique_only, **filters): """ # TODO(fmontei): Implement this as an sqlalchemy query. filtered_documents = {} - unique_filters = ('name', 'schema') + unique_filters = ('schema', 'name') + exclude_deleted = filters.pop('deleted', None) is False + + if exclude_deleted: + documents = _exclude_deleted_documents(documents) for document in documents: # NOTE(fmontei): Only want to include non-validation policy documents @@ -503,6 +549,7 @@ def revision_get_documents(revision_id=None, include_history=True, :param filters: Dictionary attributes (including nested) used to filter out revision documents. :param session: Database session object. + :param filters: Key-value pairs used for filtering out revision documents. :returns: All revision documents for ``revision_id`` that match the ``filters``, including document revision history if applicable. :raises: RevisionNotFound if the revision was not found. @@ -606,17 +653,8 @@ def revision_diff(revision_id, comparison_revision_id): # Remove each deleted document and its older counterparts because those # documents technically don't exist. - for doc_collection in (docs, comparison_docs): - for doc in copy.copy(doc_collection): - if doc['deleted']: - docs_to_delete = filter( - lambda d: - (d['schema'], d['name']) == - (doc['schema'], doc['name']) - and d['created_at'] <= doc['deleted_at'], - doc_collection) - for d in list(docs_to_delete): - doc_collection.remove(d) + for documents in (docs, comparison_docs): + documents = _exclude_deleted_documents(documents) revision = revision_get(revision_id) if revision_id != 0 else None comparison_revision = (revision_get(comparison_revision_id) @@ -794,23 +832,18 @@ def revision_tag_delete_all(revision_id, session=None): #################### -@require_revision_exists -def revision_rollback(revision_id, session=None): +def revision_rollback(revision_id, latest_revision, session=None): """Rollback the latest revision to revision specified by ``revision_id``. Rolls back the latest revision to the revision specified by ``revision_id`` thereby creating a new, carbon-copy revision. :param revision_id: Revision ID to which to rollback. + :param latest_revision: Dictionary representation of the latest revision + in the system. :returns: The newly created revision. """ session = session or get_session() - - # We know that the last revision exists, since require_revision_exists - # ensures revision_id exists, which at the very least is the last revision. - latest_revision = session.query(models.Revision)\ - .order_by(models.Revision.created_at.desc())\ - .first() latest_revision_hashes = [ (d['data_hash'], d['metadata_hash']) for d in latest_revision['documents']] diff --git a/deckhand/db/sqlalchemy/models.py b/deckhand/db/sqlalchemy/models.py index 8b530832..b44fe8d4 100644 --- a/deckhand/db/sqlalchemy/models.py +++ b/deckhand/db/sqlalchemy/models.py @@ -135,9 +135,8 @@ class Document(BASE, DeckhandBase): id = Column(Integer, primary_key=True) name = Column(String(64), nullable=False) schema = Column(String(64), nullable=False) - # NOTE: Do not define a maximum length for these JSON data below. However, - # this approach is not compatible with all database types. - # "metadata" is reserved, so use "_metadata" instead. + # NOTE(fmontei): ``metadata`` is reserved by the DB, so ``_metadata`` + # must be used to store document metadata information in the DB. _metadata = Column(oslo_types.JsonEncodedDict(), nullable=False) data = Column(oslo_types.JsonEncodedDict(), nullable=True) data_hash = Column(String, nullable=False) @@ -175,8 +174,6 @@ class Document(BASE, DeckhandBase): d = super(Document, self).to_dict() d['bucket_name'] = self.bucket_name - # NOTE(fmontei): ``metadata`` is reserved by the DB, so ``_metadata`` - # must be used to store document metadata information in the DB. if not raw_dict: d['metadata'] = d.pop('_metadata') diff --git a/deckhand/errors.py b/deckhand/errors.py index 56f4ae6c..45c8a062 100644 --- a/deckhand/errors.py +++ b/deckhand/errors.py @@ -137,3 +137,8 @@ class BarbicanException(DeckhandException): def __init__(self, message, code): super(BarbicanException, self).__init__(message=message, code=code) + + +class PolicyNotAuthorized(DeckhandException): + msg_fmt = "Policy doesn't allow %(action)s to be performed." + code = 403 diff --git a/deckhand/policies/__init__.py b/deckhand/policies/__init__.py new file mode 100644 index 00000000..2c1c398a --- /dev/null +++ b/deckhand/policies/__init__.py @@ -0,0 +1,29 @@ +# 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 itertools + +from deckhand.policies import base +from deckhand.policies import document +from deckhand.policies import revision +from deckhand.policies import revision_tag + + +def list_rules(): + return itertools.chain( + base.list_rules(), + document.list_rules(), + revision.list_rules(), + revision_tag.list_rules() + ) diff --git a/deckhand/policies/base.py b/deckhand/policies/base.py new file mode 100644 index 00000000..7b9c58b2 --- /dev/null +++ b/deckhand/policies/base.py @@ -0,0 +1,30 @@ +# 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. + +from oslo_policy import policy + +POLICY_ROOT = 'deckhand:%s' +RULE_ADMIN_API = 'rule:admin_api' + + +rules = [ + policy.RuleDefault( + "admin_api", + "role:admin", + "Default rule for most Admin APIs.") +] + + +def list_rules(): + return rules diff --git a/deckhand/policies/document.py b/deckhand/policies/document.py new file mode 100644 index 00000000..c54d7a3a --- /dev/null +++ b/deckhand/policies/document.py @@ -0,0 +1,105 @@ +# 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. + +from oslo_policy import policy + +from deckhand.policies import base + + +document_policies = [ + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'create_cleartext_documents', + base.RULE_ADMIN_API, + """Create a batch of documents specified in the request body, whereby +a new revision is created. Also, roll back a revision to a previous one in the +revision history, whereby the target revision's documents are re-created for +the new revision. + +Conditionally enforced for the endpoints below if the any of the documents in +the request body have a `metadata.storagePolicy` of "cleartext".""", + [ + { + 'method': 'PUT', + 'path': '/api/v1.0/bucket/{bucket_name}/documents' + }, + { + 'method': 'POST', + 'path': '/api/v1.0/rollback/{target_revision_id}' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'create_encrypted_documents', + base.RULE_ADMIN_API, + """Create a batch of documents specified in the request body, whereby +a new revision is created. Also, roll back a revision to a previous one in the +history, whereby the target revision's documents are re-created for the new +revision. + +Conditionally enforced for the endpoints below if the any of the documents in +the request body have a `metadata.storagePolicy` of "encrypted".""", + [ + { + 'method': 'PUT', + 'path': '/api/v1.0/bucket/{bucket_name}/documents' + }, + { + 'method': 'POST', + 'path': '/api/v1.0/rollback/{target_revision_id}' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'list_cleartext_documents', + base.RULE_ADMIN_API, + """List cleartext documents for a revision (with no layering or +substitution applied) as well as fully layered and substituted concrete +documents. + +Conditionally enforced for the endpoints below if the any of the documents in +the request body have a `metadata.storagePolicy` of "cleartext". If policy +enforcement fails, cleartext documents are omitted.""", + [ + { + 'method': 'GET', + 'path': 'api/v1.0/revisions/{revision_id}/documents' + }, + { + 'method': 'GET', + 'path': 'api/v1.0/revisions/{revision_id}/rendered-documents' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'list_encrypted_documents', + base.RULE_ADMIN_API, + """List cleartext documents for a revision (with no layering or +substitution applied) as well as fully layered and substituted concrete +documents. + +Conditionally enforced for the endpoints below if the any of the documents in +the request body have a `metadata.storagePolicy` of "encrypted". If policy +enforcement fails, encrypted documents are omitted.""", + [ + { + 'method': 'GET', + 'path': 'api/v1.0/revisions/{revision_id}/documents' + }, + { + 'method': 'GET', + 'path': 'api/v1.0/revisions/{revision_id}/rendered-documents' + } + ]), +] + + +def list_rules(): + return document_policies diff --git a/deckhand/policies/revision.py b/deckhand/policies/revision.py new file mode 100644 index 00000000..7f45a361 --- /dev/null +++ b/deckhand/policies/revision.py @@ -0,0 +1,66 @@ +# 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. + +from oslo_policy import policy + +from deckhand.policies import base + + +revision_policies = [ + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'show_revision', + base.RULE_ADMIN_API, + "Show details for a revision tag.", + [ + { + 'method': 'GET', + 'path': '/api/v1.0/revisions/{revision_id}' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'list_revisions', + base.RULE_ADMIN_API, + "List all revisions.", + [ + { + 'method': 'GET', + 'path': '/api/v1.0/revisions' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'delete_revisions', + base.RULE_ADMIN_API, + "Delete all revisions.", + [ + { + 'method': 'DELETE', + 'path': '/api/v1.0/revisions' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'show_revision_diff', + base.RULE_ADMIN_API, + "Show revision diff between two revisions.", + [ + { + 'method': 'GET', + 'path': ('/api/v1.0/revisions/{revision_id}/diff/' + '{comparison_revision_id}') + } + ]), +] + + +def list_rules(): + return revision_policies diff --git a/deckhand/policies/revision_tag.py b/deckhand/policies/revision_tag.py new file mode 100644 index 00000000..b4b6e8ef --- /dev/null +++ b/deckhand/policies/revision_tag.py @@ -0,0 +1,75 @@ +# 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. + +from oslo_policy import policy + +from deckhand.policies import base + + +revision_tag_policies = [ + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'create_tag', + base.RULE_ADMIN_API, + "Create a revision tag.", + [ + { + 'method': 'POST', + 'path': '/api/v1.0/revisions/{revision_id}/tags' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'show_tag', + base.RULE_ADMIN_API, + "Show details for a revision tag.", + [ + { + 'method': 'GET', + 'path': '/api/v1.0/revisions/{revision_id}/tags/{tag}' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'list_tags', + base.RULE_ADMIN_API, + "List all tags for a revision.", + [ + { + 'method': 'GET', + 'path': '/api/v1.0/revisions/{revision_id}/tags' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'delete_tag', + base.RULE_ADMIN_API, + "Delete a revision tag.", + [ + { + 'method': 'DELETE', + 'path': '/api/v1.0/revisions/{revision_id}/tags/{tag}' + } + ]), + policy.DocumentedRuleDefault( + base.POLICY_ROOT % 'delete_tags', + base.RULE_ADMIN_API, + "Delete all tags for a revision.", + [ + { + 'method': 'DELETE', + 'path': '/api/v1.0/revisions/{revision_id}/tags' + } + ]) +] + + +def list_rules(): + return revision_tag_policies diff --git a/deckhand/policy.py b/deckhand/policy.py new file mode 100644 index 00000000..9a2cb8ae --- /dev/null +++ b/deckhand/policy.py @@ -0,0 +1,99 @@ +# 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 functools +import six + +import falcon +from oslo_config import cfg +from oslo_log import log as logging +from oslo_policy import policy + +from deckhand import errors +from deckhand import policies + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) + + +def _do_enforce_rbac(action, context, do_raise=True): + policy_enforcer = context.policy_enforcer + credentials = context.to_policy_values() + target = {'project_id': context.project_id, + 'user_id': context.user_id} + exc = errors.PolicyNotAuthorized + + try: + # oslo.policy supports both enforce and authorize. authorize is + # stricter because it'll raise an exception if the policy action is + # not found in the list of registered rules. This means that attempting + # to enforce anything not found in ``deckhand.policies`` will error out + # with a 'Policy not registered' message. + return policy_enforcer.authorize( + action, target, context.to_dict(), do_raise=do_raise, + exc=exc, action=action) + except policy.PolicyNotRegistered as e: + LOG.exception('Policy not registered.') + raise falcon.HTTPForbidden(description=six.text_type(e)) + except Exception as e: + LOG.debug( + 'Policy check for %(action)s failed with credentials ' + '%(credentials)s', + {'action': action, 'credentials': credentials}) + raise falcon.HTTPForbidden(description=six.text_type(e)) + + +def authorize(action): + """Verifies whether a policy action can be performed given the credentials + found in the falcon request context. + + :param action: The policy action to enforce. + :returns: ``True`` if policy enforcement succeeded, else ``False``. + :raises: falcon.HTTPForbidden if policy enforcement failed or if the policy + action isn't registered under ``deckhand.policies``. + """ + def decorator(func): + @functools.wraps(func) + def handler(*args, **kwargs): + # args[1] is always the falcon Request object. + context = args[1].context + _do_enforce_rbac(action, context) + return func(*args, **kwargs) + return handler + + return decorator + + +def conditional_authorize(action, context, do_raise=True): + """Conditionally authorize a policy action. + + :param action: The policy action to enforce. + :param context: The falcon request context object. + :param do_raise: Whether to raise the exception if policy enforcement + fails. ``True`` by default. + :raises: falcon.HTTPForbidden if policy enforcement failed or if the policy + action isn't registered under ``deckhand.policies``. + + Example:: + + # If any requested documents' metadata.storagePolicy == 'cleartext'. + if cleartext_documents: + policy.conditional_authorize('deckhand:create_cleartext_documents', + req.context) + """ + return _do_enforce_rbac(action, context, do_raise=do_raise) + + +def register_rules(enforcer): + enforcer.register_defaults(policies.list_rules()) diff --git a/deckhand/tests/functional/test_gabbi.py b/deckhand/tests/functional/test_gabbi.py index e975a55b..810c6775 100644 --- a/deckhand/tests/functional/test_gabbi.py +++ b/deckhand/tests/functional/test_gabbi.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os +import yaml + import gabbi.driver import gabbi.handlers.jsonhandler import gabbi.json_parser -import os -import yaml TESTS_DIR = 'gabbits' @@ -48,11 +49,11 @@ class MultidocJsonpaths(gabbi.handlers.jsonhandler.JSONHandler): def load_tests(loader, tests, pattern): test_dir = os.path.join(os.path.dirname(__file__), TESTS_DIR) return gabbi.driver.build_tests(test_dir, loader, - # NOTE(fmontei): When there are multiple handlers listed that - # accept the same content-type, the one that is earliest in the - # list will be used. Thus, we cannot specify multiple content - # handlers for handling list/dictionary responses from the server - # using different handlers. - content_handlers=[MultidocJsonpaths], - verbose=True, - url=os.environ['DECKHAND_TEST_URL']) + # NOTE(fmontei): When there are multiple handlers listed that + # accept the same content-type, the one that is earliest in the + # list will be used. Thus, we cannot specify multiple content + # handlers for handling list/dictionary responses from the server + # using different handlers. + content_handlers=[MultidocJsonpaths], + verbose=True, + url=os.environ['DECKHAND_TEST_URL']) diff --git a/deckhand/tests/unit/db/base.py b/deckhand/tests/unit/db/base.py index 6ad366ae..9d8948da 100644 --- a/deckhand/tests/unit/db/base.py +++ b/deckhand/tests/unit/db/base.py @@ -102,7 +102,8 @@ class TestDbBase(base.DeckhandWithDBTestCase): return db_api.revision_get_all() def rollback_revision(self, revision_id): - return db_api.revision_rollback(revision_id) + latest_revision = db_api.revision_get_latest() + return db_api.revision_rollback(revision_id, latest_revision) def _validate_object(self, obj): for attr in BASE_EXPECTED_FIELDS: diff --git a/deckhand/tests/unit/db/test_revision_documents.py b/deckhand/tests/unit/db/test_revision_documents.py index 6f969b5b..8bf1f056 100644 --- a/deckhand/tests/unit/db/test_revision_documents.py +++ b/deckhand/tests/unit/db/test_revision_documents.py @@ -31,3 +31,85 @@ class TestRevisionDocumentsFiltering(base.TestDbBase): self.assertEqual(1, len(retrieved_documents)) self.assertEqual(bucket_name, retrieved_documents[0]['bucket_name']) + + def test_document_filtering_exclude_deleted_documents(self): + documents = base.DocumentFixture.get_minimal_fixture() + bucket_name = test_utils.rand_name('bucket') + self.create_documents(bucket_name, documents) + + revision_id = self.create_documents(bucket_name, [])[0]['revision_id'] + retrieved_documents = self.list_revision_documents( + revision_id, include_history=False, deleted=False) + + self.assertEmpty(retrieved_documents) + + def test_revision_document_filtering_with_single_item_list(self): + document = base.DocumentFixture.get_minimal_fixture() + # If not provided, Deckhand defaults to 'cleartext'. + document['metadata']['storagePolicy'] = None + bucket_name = test_utils.rand_name('bucket') + created_documents = self.create_documents(bucket_name, document) + + retrieved_documents = self.list_revision_documents( + created_documents[0]['revision_id'], + **{'metadata.storagePolicy': ['cleartext']}) + self.assertEqual([d['id'] for d in created_documents], + [d['id'] for d in retrieved_documents]) + + def test_revision_document_filtering_with_multi_item_list(self): + all_created_documents = [] + + for storage_policy in ['cleartext', 'cleartext']: + document = base.DocumentFixture.get_minimal_fixture() + document['metadata']['storagePolicy'] = storage_policy + bucket_name = test_utils.rand_name('bucket') + created_documents = self.create_documents(bucket_name, document) + all_created_documents.extend(created_documents) + + retrieved_documents = self.list_revision_documents( + created_documents[0]['revision_id'], + **{'metadata.storagePolicy': ['cleartext', 'encrypted']}) + + self.assertEqual([d['id'] for d in all_created_documents], + [d['id'] for d in retrieved_documents]) + + def test_revision_document_filtering_single_item_list_exclude_all(self): + documents = base.DocumentFixture.get_minimal_multi_fixture(count=3) + # If not provided, Deckhand defaults to 'cleartext'. + for document in documents: + document['metadata']['storagePolicy'] = None + bucket_name = test_utils.rand_name('bucket') + created_documents = self.create_documents(bucket_name, documents) + + retrieved_documents = self.list_revision_documents( + created_documents[0]['revision_id'], + **{'metadata.storagePolicy': ['encrypted']}) + self.assertEmpty(retrieved_documents) + + def test_revision_document_filtering_single_item_list_exclude_many(self): + documents = base.DocumentFixture.get_minimal_multi_fixture(count=3) + # Only the first document should be returned. + documents[0]['metadata']['storagePolicy'] = 'encrypted' + for document in documents[1:]: + document['metadata']['storagePolicy'] = 'cleartext' + bucket_name = test_utils.rand_name('bucket') + created_documents = self.create_documents(bucket_name, documents) + + retrieved_documents = self.list_revision_documents( + created_documents[0]['revision_id'], + **{'metadata.storagePolicy': ['encrypted']}) + self.assertEqual([created_documents[0]['id']], + [d['id'] for d in retrieved_documents]) + + def test_revision_document_filtering_with_multi_item_list_exclude(self): + for storage_policy in ['cleartext', 'cleartext']: + document = base.DocumentFixture.get_minimal_fixture() + document['metadata']['storagePolicy'] = storage_policy + bucket_name = test_utils.rand_name('bucket') + created_documents = self.create_documents(bucket_name, document) + + retrieved_documents = self.list_revision_documents( + created_documents[0]['revision_id'], + **{'metadata.storagePolicy': ['wrong_val', 'encrypted']}) + + self.assertEmpty(retrieved_documents) diff --git a/deckhand/tests/unit/test_policy.py b/deckhand/tests/unit/test_policy.py new file mode 100644 index 00000000..54246e83 --- /dev/null +++ b/deckhand/tests/unit/test_policy.py @@ -0,0 +1,82 @@ +# 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 falcon +import mock +from oslo_policy import policy as common_policy + +from deckhand.conf import config +from deckhand.control import base as api_base +from deckhand import policy +from deckhand.tests.unit import base as test_base + +CONF = config.CONF + + +class PolicyBaseTestCase(test_base.DeckhandTestCase): + + def setUp(self): + super(PolicyBaseTestCase, self).setUp() + # The default policies in deckhand.policies are automatically + # registered. Override them with custom rules. '@' allows anyone to + # perform a policy action. + self.rules = { + "deckhand:create_cleartext_documents": [['@']], + "deckhand:list_cleartext_documents": [['rule:admin_api']] + } + self.policy_enforcer = common_policy.Enforcer(CONF) + self._set_rules() + + def _set_rules(self): + rules = common_policy.Rules.from_dict(self.rules) + self.policy_enforcer.set_rules(rules) + self.addCleanup(self.policy_enforcer.clear) + + def _enforce_policy(self, action): + api_args = self._get_args() + + @policy.authorize(action) + def noop(*args, **kwargs): + pass + + noop(*api_args) + + def _get_args(self): + # Returns the first two arguments that would be passed to any falcon + # on_{HTTP_VERB} method: (self (which is mocked), falcon Request obj). + falcon_req = api_base.DeckhandRequest( + mock.MagicMock(), policy_enforcer=self.policy_enforcer) + return (mock.Mock(), falcon_req) + + +class PolicyPositiveTestCase(PolicyBaseTestCase): + + def test_enforce_allowed_action(self): + action = "deckhand:create_cleartext_documents" + self._enforce_policy(action) + + +class PolicyNegativeTestCase(PolicyBaseTestCase): + + def test_enforce_disallowed_action(self): + action = "deckhand:list_cleartext_documents" + error_re = "Policy doesn't allow %s to be performed." % action + e = self.assertRaises( + falcon.HTTPForbidden, self._enforce_policy, action) + self.assertRegexpMatches(error_re, e.description) + + def test_enforce_nonexistent_action(self): + action = "example:undefined" + error_re = "Policy %s has not been registered" % action + e = self.assertRaises( + falcon.HTTPForbidden, self._enforce_policy, action) + self.assertRegexpMatches(error_re, e.description) diff --git a/doc/source/HACKING.rst b/doc/source/HACKING.rst index 1847447d..5d9c0f16 100644 --- a/doc/source/HACKING.rst +++ b/doc/source/HACKING.rst @@ -1,3 +1,18 @@ +.. + 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. + ======= Hacking ======= diff --git a/doc/source/conf.py b/doc/source/conf.py index 6dcc1305..6d6697ca 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -30,7 +30,16 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.todo', + 'sphinx.ext.viewcode', + 'oslo_policy.sphinxpolicygen' +] + +# oslo_policy.sphinxpolicygen options +policy_generator_config_file = '../../etc/deckhand/policy-generator.conf' +sample_policy_basename = '_static/deckhand' # Add any paths that contain templates here, relative to this directory. # templates_path = [] diff --git a/doc/source/glossary.rst b/doc/source/glossary.rst index a890c466..8ea9a642 100644 --- a/doc/source/glossary.rst +++ b/doc/source/glossary.rst @@ -1,3 +1,18 @@ +.. + 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. + ======== Glossary ======== diff --git a/doc/source/index.rst b/doc/source/index.rst index cdb013d4..42859b84 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -32,12 +32,26 @@ The service understands a variety of document formats, the combination of which describe the manner in which Deckhand renders finalized documents for consumption by other UCP services. +User's Guide +============ + +.. toctree:: + :maxdepth: 2 + + policy-enforcement + +Developer's Guide +================= + .. toctree:: :maxdepth: 2 HACKING testing +Glossary +======== + .. toctree:: :maxdepth: 1 diff --git a/doc/source/policy-enforcement.rst b/doc/source/policy-enforcement.rst new file mode 100644 index 00000000..b826a2db --- /dev/null +++ b/doc/source/policy-enforcement.rst @@ -0,0 +1,54 @@ +.. + 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. + + +Rest API Policy Enforcement +=========================== +Policy enforcement in Deckhand leverages the ``oslo.policy`` library like +all OpenStack projects. The implementation is located in ``deckhand.policy``. +Two types of policy authorization exist in Deckhand: + + 1) Decorator-level authorization used for wrapping around ``falcon`` + "on_{HTTP_VERB}" methods. In this case, if policy authorization fails + a 403 Forbidden is always raised. + 2) Conditional authorization, which means that the policy is only enforced + if a certain set of conditions are true. + +Deckhand, for example, will only conditionally enforce listing encrypted +documents if a document's ``metadata.storagePolicy`` is "encrypted". + +Policy Implementation +--------------------- + +Deckhand uses ``authorize`` from ``oslo.policy`` as the latter supports both +``enforce`` and ``authorize``. ``authorize`` is stricter because it'll raise an +exception if the policy action is not registered under ``deckhand.policies`` +(which enumerates all the legal policy actions and their default rules). This +means that attempting to enforce anything not found in ``deckhand.policies`` +will error out with a 'Policy not registered' message. + +.. automodule:: deckhand.policy + :members: + +Sample Policy File +================== +The following is a sample Deckhand policy file for adaptation and use. It is +auto-generated from Deckhand when this documentation is built, so +if you are having issues with an option, please compare your version of +Deckhand with the version of this documentation. + +The sample configuration can also be viewed in `file form <_static/deckhand.policy.yaml.sample>`_. + +.. literalinclude:: _static/deckhand.policy.yaml.sample diff --git a/etc/deckhand/config-generator.conf b/etc/deckhand/config-generator.conf index 854605fe..5c47c4c3 100644 --- a/etc/deckhand/config-generator.conf +++ b/etc/deckhand/config-generator.conf @@ -5,4 +5,5 @@ namespace = deckhand.conf namespace = oslo.db namespace = oslo.log namespace = oslo.middleware +namespace = oslo.policy namespace = keystonemiddleware.auth_token diff --git a/etc/deckhand/deckhand.conf.sample b/etc/deckhand/deckhand.conf.sample index cee6bbb7..ae6611ae 100644 --- a/etc/deckhand/deckhand.conf.sample +++ b/etc/deckhand/deckhand.conf.sample @@ -541,3 +541,23 @@ # Whether the application is behind a proxy or not. This determines if the # middleware should parse the headers or not. (boolean value) #enable_proxy_headers_parsing = false + + +[oslo_policy] + +# +# From oslo.policy +# + +# The file that defines policies. (string value) +#policy_file = policy.json + +# Default rule. Enforced when a requested rule is not found. (string value) +#policy_default_rule = default + +# Directories where policy configuration files are stored. They can be relative +# to any directory in the search path defined by the config_dir option, or +# absolute paths. The file defined by policy_file must exist for these +# directories to be searched. Missing or empty directories are ignored. (multi +# valued) +#policy_dirs = policy.d diff --git a/etc/deckhand/policy-generator.conf b/etc/deckhand/policy-generator.conf new file mode 100644 index 00000000..54472cdc --- /dev/null +++ b/etc/deckhand/policy-generator.conf @@ -0,0 +1,3 @@ +[DEFAULT] +output_file = etc/deckhand/policy.yaml.sample +namespace = deckhand diff --git a/etc/deckhand/policy.yaml.sample b/etc/deckhand/policy.yaml.sample new file mode 100644 index 00000000..28de03d7 --- /dev/null +++ b/etc/deckhand/policy.yaml.sample @@ -0,0 +1,95 @@ +# Default rule for most Admin APIs. +#"admin_api": "role:admin" + +# Create a batch of documents specified in the request body, whereby +# a new revision is created. Also, roll back a revision to a previous +# one in the +# revision history, whereby the target revision's documents are re- +# created for +# the new revision. +# +# Conditionally enforced for the endpoints below if the any of the +# documents in +# the request body have a `metadata.storagePolicy` of "cleartext". +# PUT /api/v1.0/bucket/{bucket_name}/documents +# POST /api/v1.0/rollback/{target_revision_id} +#"deckhand:create_cleartext_documents": "rule:admin_api" + +# Create a batch of documents specified in the request body, whereby +# a new revision is created. Also, roll back a revision to a previous +# one in the +# history, whereby the target revision's documents are re-created for +# the new +# revision. +# +# Conditionally enforced for the endpoints below if the any of the +# documents in +# the request body have a `metadata.storagePolicy` of "encrypted". +# PUT /api/v1.0/bucket/{bucket_name}/documents +# POST /api/v1.0/rollback/{target_revision_id} +#"deckhand:create_encrypted_documents": "rule:admin_api" + +# List cleartext documents for a revision (with no layering or +# substitution applied) as well as fully layered and substituted +# concrete +# documents. +# +# Conditionally enforced for the endpoints below if the any of the +# documents in +# the request body have a `metadata.storagePolicy` of "cleartext". If +# policy +# enforcement fails, cleartext documents are omitted. +# GET api/v1.0/revisions/{revision_id}/documents +# GET api/v1.0/revisions/{revision_id}/rendered-documents +#"deckhand:list_cleartext_documents": "rule:admin_api" + +# List cleartext documents for a revision (with no layering or +# substitution applied) as well as fully layered and substituted +# concrete +# documents. +# +# Conditionally enforced for the endpoints below if the any of the +# documents in +# the request body have a `metadata.storagePolicy` of "encrypted". If +# policy +# enforcement fails, encrypted documents are omitted. +# GET api/v1.0/revisions/{revision_id}/documents +# GET api/v1.0/revisions/{revision_id}/rendered-documents +#"deckhand:list_encrypted_documents": "rule:admin_api" + +# Show details for a revision tag. +# GET /api/v1.0/revisions/{revision_id} +#"deckhand:show_revision": "rule:admin_api" + +# List all revisions. +# GET /api/v1.0/revisions +#"deckhand:list_revisions": "rule:admin_api" + +# Delete all revisions. +# DELETE /api/v1.0/revisions +#"deckhand:delete_revisions": "rule:admin_api" + +# Show revision diff between two revisions. +# GET /api/v1.0/revisions/{revision_id}/diff/{comparison_revision_id} +#"deckhand:show_revision_diff": "rule:admin_api" + +# Create a revision tag. +# POST /api/v1.0/revisions/{revision_id}/tags +#"deckhand:create_tag": "rule:admin_api" + +# Show details for a revision tag. +# GET /api/v1.0/revisions/{revision_id}/tags/{tag} +#"deckhand:show_tag": "rule:admin_api" + +# List all tags for a revision. +# GET /api/v1.0/revisions/{revision_id}/tags +#"deckhand:list_tags": "rule:admin_api" + +# Delete a revision tag. +# DELETE /api/v1.0/revisions/{revision_id}/tags/{tag} +#"deckhand:delete_tag": "rule:admin_api" + +# Delete all tags for a revision. +# DELETE /api/v1.0/revisions/{revision_id}/tags +#"deckhand:delete_tags": "rule:admin_api" + diff --git a/releasenotes/notes/oslo.policy-integration-f03ac6a7a2ccef5a.yaml b/releasenotes/notes/oslo.policy-integration-f03ac6a7a2ccef5a.yaml new file mode 100644 index 00000000..a72fb509 --- /dev/null +++ b/releasenotes/notes/oslo.policy-integration-f03ac6a7a2ccef5a.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + The ``oslo.policy`` framework has been integrated into Deckhand. All + currently supported endpoints are covered by RBAC enforcement. All + default policy rules are admin-only by default. The defaults can be + overriden via a custom ``policy.yaml`` file. diff --git a/setup.cfg b/setup.cfg index d597ac60..419aeb1b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,6 +24,9 @@ packages = oslo.config.opts = deckhand.conf = deckhand.conf.opts:list_opts +oslo.policy.policies = + deckhand = deckhand.policies:list_rules + [build_sphinx] source-dir = doc/source build-dir = doc/build diff --git a/tools/functional-tests.sh b/tools/functional-tests.sh index b51d2cdf..1f4b90fc 100755 --- a/tools/functional-tests.sh +++ b/tools/functional-tests.sh @@ -35,15 +35,17 @@ POSTGRES_IP=$( $POSTGRES_ID ) -log_section Creating config file CONF_DIR=$(mktemp -d) -export DECKHAND_TEST_URL=http://localhost:9000 -export DATABASE_URL=postgresql+psycopg2://deckhand:password@$POSTGRES_IP:5432/deckhand -# Used by Deckhand's initialization script to search for config files. -export OS_DECKHAND_CONFIG_DIR=$CONF_DIR +function gen_config { + log_section Creating config file -cp etc/deckhand/logging.conf.sample $CONF_DIR/logging.conf + export DECKHAND_TEST_URL=http://localhost:9000 + export DATABASE_URL=postgresql+psycopg2://deckhand:password@$POSTGRES_IP:5432/deckhand + # Used by Deckhand's initialization script to search for config files. + export OS_DECKHAND_CONFIG_DIR=$CONF_DIR + + cp etc/deckhand/logging.conf.sample $CONF_DIR/logging.conf cat < $CONF_DIR/deckhand.conf [DEFAULT] @@ -53,6 +55,9 @@ log_file = deckhand.log log_dir = . use_stderr = true +[oslo_policy] +policy_file = policy.yaml + [barbican] [database] @@ -61,11 +66,33 @@ connection = $DATABASE_URL [keystone_authtoken] EOCONF -echo $CONF_DIR/deckhand.conf 1>&2 -cat $CONF_DIR/deckhand.conf 1>&2 + echo $CONF_DIR/deckhand.conf 1>&2 + cat $CONF_DIR/deckhand.conf 1>&2 -log_section Starting server -rm -f deckhand.log + log_section Starting server + rm -f deckhand.log +} + +function gen_policy { + log_section Creating policy file with liberal permissions + + oslopolicy-sample-generator --config-file=etc/deckhand/policy-generator.conf + + policy_file='etc/deckhand/policy.yaml.sample' + policy_pattern="deckhand\:" + + touch $CONF_DIR/policy.yaml + + sed -n "/$policy_pattern/p" "$policy_file" \ + | sed 's/^../\"/' \ + | sed 's/rule\:[A-Za-z\_\-]*/@/' > $CONF_DIR/policy.yaml + + echo $CONF_DIR/'policy.yaml' 1>&2 + cat $CONF_DIR/'policy.yaml' 1>&2 +} + +gen_config +gen_policy uwsgi \ --http :9000 \ @@ -81,7 +108,12 @@ sleep 5 log_section Running tests set +e -ostestr -c 1 $* +posargs=$@ +if [ ${#posargs} -ge 1 ]; then + ostestr --concurrency 1 --regex $1 +else + ostestr --concurrency 1 +fi TEST_STATUS=$? set -e diff --git a/tox.ini b/tox.ini index 1779fd6d..3366b077 100644 --- a/tox.ini +++ b/tox.ini @@ -67,6 +67,9 @@ commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasen [testenv:genconfig] commands = oslo-config-generator --config-file=etc/deckhand/config-generator.conf +[testenv:genpolicy] +commands = oslopolicy-sample-generator --config-file=etc/deckhand/policy-generator.conf + [testenv:pep8] commands = flake8 {posargs}