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
This commit is contained in:
parent
3e62ace8ed
commit
582dee6fb9
@ -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)
|
||||
|
@ -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 "<Context %s>" % self.to_dict()
|
||||
return cls(**values)
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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')
|
||||
|
@ -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)
|
||||
|
@ -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']
|
||||
|
@ -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']]
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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
|
||||
|
29
deckhand/policies/__init__.py
Normal file
29
deckhand/policies/__init__.py
Normal file
@ -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()
|
||||
)
|
30
deckhand/policies/base.py
Normal file
30
deckhand/policies/base.py
Normal file
@ -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
|
105
deckhand/policies/document.py
Normal file
105
deckhand/policies/document.py
Normal file
@ -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
|
66
deckhand/policies/revision.py
Normal file
66
deckhand/policies/revision.py
Normal file
@ -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
|
75
deckhand/policies/revision_tag.py
Normal file
75
deckhand/policies/revision_tag.py
Normal file
@ -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
|
99
deckhand/policy.py
Normal file
99
deckhand/policy.py
Normal file
@ -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())
|
@ -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'])
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
82
deckhand/tests/unit/test_policy.py
Normal file
82
deckhand/tests/unit/test_policy.py
Normal file
@ -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)
|
@ -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
|
||||
=======
|
||||
|
@ -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 = []
|
||||
|
@ -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
|
||||
========
|
||||
|
@ -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
|
||||
|
||||
|
54
doc/source/policy-enforcement.rst
Normal file
54
doc/source/policy-enforcement.rst
Normal file
@ -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
|
@ -5,4 +5,5 @@ namespace = deckhand.conf
|
||||
namespace = oslo.db
|
||||
namespace = oslo.log
|
||||
namespace = oslo.middleware
|
||||
namespace = oslo.policy
|
||||
namespace = keystonemiddleware.auth_token
|
||||
|
@ -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
|
||||
|
3
etc/deckhand/policy-generator.conf
Normal file
3
etc/deckhand/policy-generator.conf
Normal file
@ -0,0 +1,3 @@
|
||||
[DEFAULT]
|
||||
output_file = etc/deckhand/policy.yaml.sample
|
||||
namespace = deckhand
|
95
etc/deckhand/policy.yaml.sample
Normal file
95
etc/deckhand/policy.yaml.sample
Normal file
@ -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"
|
||||
|
@ -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.
|
@ -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
|
||||
|
@ -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 <<EOCONF > $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
|
||||
|
||||
|
3
tox.ini
3
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}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user