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:
Felipe Monteiro 2017-09-21 18:22:03 +01:00
parent 3e62ace8ed
commit 582dee6fb9
37 changed files with 1038 additions and 164 deletions

View File

@ -49,24 +49,4 @@ def list_opts():
return 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) register_opts(CONF)

View File

@ -12,90 +12,39 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""RequestContext: context for requests that persist throughout Deckhand.""" from oslo_config import cfg
import copy
from oslo_context import context from oslo_context import context
from oslo_db.sqlalchemy import enginefacade from oslo_policy import policy as common_policy
from oslo_utils import timeutils
import six from deckhand import policy
CONF = cfg.CONF
@enginefacade.transaction_context_provider
class RequestContext(context.RequestContext): class RequestContext(context.RequestContext):
"""Security context and request information. """User security context object
Represents the user taking a given action within the system.
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, def __init__(self, policy_enforcer=None, project=None, **kwargs):
timestamp=None, **kwargs): if project:
if user_id: kwargs['tenant'] = project
kwargs['user'] = user_id self.project = project
self.policy_enforcer = policy_enforcer or common_policy.Enforcer(CONF)
super(RequestContext, self).__init__(is_admin=is_admin, **kwargs) policy.register_rules(self.policy_enforcer)
super(RequestContext, self).__init__(**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 to_dict(self): def to_dict(self):
values = super(RequestContext, self).to_dict() out_dict = super(RequestContext, self).to_dict()
values.update({ out_dict['roles'] = self.roles
'user_id': getattr(self, 'user_id', None),
'project_id': getattr(self, 'project_id', None), if out_dict.get('tenant'):
'is_admin': getattr(self, 'is_admin', None) out_dict['project'] = out_dict['tenant']
}) out_dict.pop('tenant')
return values return out_dict
@classmethod @classmethod
def from_dict(cls, values): def from_dict(cls, values):
return super(RequestContext, cls).from_dict( return cls(**values)
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()

View File

@ -42,7 +42,7 @@ def _get_config_files(env=None):
return [os.path.join(dirname, config_file) for config_file in CONFIG_FILES] 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. """Main entry point for initializing the Deckhand API service.
Create routes for the v1.0 API and sets up logging. 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()) control_api.add_route('/versions', versions.VersionsResource())
return control_api return control_api
if __name__ == '__main__':
start_api()

View File

@ -15,7 +15,8 @@
import yaml import yaml
import falcon import falcon
from oslo_context import context
from deckhand import context
class BaseResource(object): class BaseResource(object):
@ -50,9 +51,10 @@ class BaseResource(object):
class DeckhandRequest(falcon.Request): 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) 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 @property
def project_id(self): def project_id(self):

View File

@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import itertools
import yaml import yaml
import falcon 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 document_validation
from deckhand.engine import secrets_manager from deckhand.engine import secrets_manager
from deckhand import errors as deckhand_errors from deckhand import errors as deckhand_errors
from deckhand import policy
from deckhand import types from deckhand import types
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -45,27 +47,50 @@ class BucketsResource(api_base.BaseResource):
LOG.error(error_msg) LOG.error(error_msg)
raise falcon.HTTPBadRequest(description=six.text_type(e)) 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: 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( validation_policies = document_validation.DocumentValidation(
documents).validate_all() documents).validate_all()
except deckhand_errors.InvalidDocumentFormat as e: 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()) raise falcon.HTTPBadRequest(description=e.format_message())
cleartext_documents = []
secret_documents = []
for document in documents: for document in documents:
if any([document['schema'].startswith(t) if any([document['schema'].startswith(t)
for t in types.DOCUMENT_SECRET_TYPES]): for t in types.DOCUMENT_SECRET_TYPES]):
secret_data = self.secrets_mgr.create(document) secret_documents.append(document)
document['data'] = secret_data 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: try:
documents.extend(validation_policies) documents_to_create = itertools.chain(
created_documents = db_api.documents_create(bucket_name, documents) cleartext_documents, secret_documents, validation_policies)
created_documents = db_api.documents_create(
bucket_name, list(documents_to_create))
except deckhand_errors.DocumentExists as e: except deckhand_errors.DocumentExists as e:
raise falcon.HTTPConflict(description=e.format_message()) raise falcon.HTTPConflict(description=e.format_message())
except Exception as e: except Exception as e:
raise falcon.HTTPInternalServerError(description=e) raise falcon.HTTPInternalServerError(description=six.text_type(e))
if created_documents: if created_documents:
resp.body = self.to_yaml_body( resp.body = self.to_yaml_body(

View File

@ -17,11 +17,13 @@ import falcon
from deckhand.control import base as api_base from deckhand.control import base as api_base
from deckhand.db.sqlalchemy import api as db_api from deckhand.db.sqlalchemy import api as db_api
from deckhand import errors from deckhand import errors
from deckhand import policy
class RevisionDiffingResource(api_base.BaseResource): class RevisionDiffingResource(api_base.BaseResource):
"""API resource for realizing revision diffing.""" """API resource for realizing revision diffing."""
@policy.authorize('deckhand:show_revision_diff')
def on_get(self, req, resp, revision_id, comparison_revision_id): def on_get(self, req, resp, revision_id, comparison_revision_id):
if revision_id == '0': if revision_id == '0':
revision_id = 0 revision_id = 0

View File

@ -20,6 +20,7 @@ from deckhand.control import common
from deckhand.control.views import document as document_view from deckhand.control.views import document as document_view
from deckhand.db.sqlalchemy import api as db_api from deckhand.db.sqlalchemy import api as db_api
from deckhand import errors from deckhand import errors
from deckhand import policy
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -41,9 +42,24 @@ class RevisionDocumentsResource(api_base.BaseResource):
documents will be as originally posted with no substitutions or documents will be as originally posted with no substitutions or
layering applied. 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: try:
documents = db_api.revision_get_documents( documents = db_api.revision_get_documents(
revision_id, **sanitized_params) revision_id, **filters)
except errors.RevisionNotFound as e: except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(description=e.format_message()) raise falcon.HTTPNotFound(description=e.format_message())

View File

@ -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.control.views import revision_tag as revision_tag_view
from deckhand.db.sqlalchemy import api as db_api from deckhand.db.sqlalchemy import api as db_api
from deckhand import errors from deckhand import errors
from deckhand import policy
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -28,6 +29,7 @@ LOG = logging.getLogger(__name__)
class RevisionTagsResource(api_base.BaseResource): class RevisionTagsResource(api_base.BaseResource):
"""API resource for realizing CRUD for revision tags.""" """API resource for realizing CRUD for revision tags."""
@policy.authorize('deckhand:create_tag')
def on_post(self, req, resp, revision_id, tag=None): def on_post(self, req, resp, revision_id, tag=None):
"""Creates a revision tag.""" """Creates a revision tag."""
body = req.stream.read(req.content_length or 0) body = req.stream.read(req.content_length or 0)
@ -59,6 +61,7 @@ class RevisionTagsResource(api_base.BaseResource):
else: else:
self._list_all_tags(req, resp, revision_id) self._list_all_tags(req, resp, revision_id)
@policy.authorize('deckhand:show_tag')
def _show_tag(self, req, resp, revision_id, tag): def _show_tag(self, req, resp, revision_id, tag):
"""Retrieve details for a specified tag.""" """Retrieve details for a specified tag."""
try: try:
@ -72,6 +75,7 @@ class RevisionTagsResource(api_base.BaseResource):
resp.append_header('Content-Type', 'application/x-yaml') resp.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(resp_body) resp.body = self.to_yaml_body(resp_body)
@policy.authorize('deckhand:list_tags')
def _list_all_tags(self, req, resp, revision_id): def _list_all_tags(self, req, resp, revision_id):
"""List all tags for a revision.""" """List all tags for a revision."""
try: try:
@ -91,6 +95,7 @@ class RevisionTagsResource(api_base.BaseResource):
else: else:
self._delete_all_tags(req, resp, revision_id) self._delete_all_tags(req, resp, revision_id)
@policy.authorize('deckhand:delete_tag')
def _delete_tag(self, req, resp, revision_id, tag): def _delete_tag(self, req, resp, revision_id, tag):
"""Delete a specified tag.""" """Delete a specified tag."""
try: try:
@ -102,6 +107,7 @@ class RevisionTagsResource(api_base.BaseResource):
resp.append_header('Content-Type', 'application/x-yaml') resp.append_header('Content-Type', 'application/x-yaml')
resp.status = falcon.HTTP_204 resp.status = falcon.HTTP_204
@policy.authorize('deckhand:delete_tags')
def _delete_all_tags(self, req, resp, revision_id): def _delete_all_tags(self, req, resp, revision_id):
"""Delete all tags for a revision.""" """Delete all tags for a revision."""
try: try:

View File

@ -19,6 +19,7 @@ from deckhand.control import common
from deckhand.control.views import revision as revision_view from deckhand.control.views import revision as revision_view
from deckhand.db.sqlalchemy import api as db_api from deckhand.db.sqlalchemy import api as db_api
from deckhand import errors from deckhand import errors
from deckhand import policy
class RevisionsResource(api_base.BaseResource): class RevisionsResource(api_base.BaseResource):
@ -38,6 +39,7 @@ class RevisionsResource(api_base.BaseResource):
else: else:
self._list_revisions(req, resp) self._list_revisions(req, resp)
@policy.authorize('deckhand:show_revision')
def _show_revision(self, req, resp, revision_id): def _show_revision(self, req, resp, revision_id):
"""Returns detailed description of a particular revision. """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.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(revision_resp) resp.body = self.to_yaml_body(revision_resp)
@policy.authorize('deckhand:list_revisions')
@common.sanitize_params(['tag']) @common.sanitize_params(['tag'])
def _list_revisions(self, req, resp, sanitized_params): def _list_revisions(self, req, resp, sanitized_params):
revisions = db_api.revision_get_all(**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.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(revisions_resp) resp.body = self.to_yaml_body(revisions_resp)
@policy.authorize('deckhand:delete_revisions')
def on_delete(self, req, resp): def on_delete(self, req, resp):
db_api.revision_delete_all() db_api.revision_delete_all()
resp.append_header('Content-Type', 'application/x-yaml') resp.append_header('Content-Type', 'application/x-yaml')

View File

@ -18,6 +18,7 @@ from deckhand.control import base as api_base
from deckhand.control.views import revision as revision_view from deckhand.control.views import revision as revision_view
from deckhand.db.sqlalchemy import api as db_api from deckhand.db.sqlalchemy import api as db_api
from deckhand import errors from deckhand import errors
from deckhand import policy
class RollbackResource(api_base.BaseResource): class RollbackResource(api_base.BaseResource):
@ -25,15 +26,28 @@ class RollbackResource(api_base.BaseResource):
view_builder = revision_view.ViewBuilder() view_builder = revision_view.ViewBuilder()
@policy.authorize('deckhand:create_cleartext_documents')
def on_post(self, req, resp, revision_id): def on_post(self, req, resp, revision_id):
try: try:
revision = db_api.revision_rollback(revision_id) latest_revision = db_api.revision_get_latest()
except errors.RevisionNotFound as e: except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(description=e.format_message()) 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: except errors.InvalidRollback as e:
raise falcon.HTTPBadRequest(description=e.format_message()) 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.status = falcon.HTTP_201
resp.append_header('Content-Type', 'application/x-yaml') resp.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(revision_resp) resp.body = self.to_yaml_body(revision_resp)

View File

@ -42,10 +42,6 @@ class ViewBuilder(common.ViewBuilder):
attrs = ['id', 'metadata', 'data', 'schema'] attrs = ['id', 'metadata', 'data', 'schema']
for document in documents: 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 = {x: document[x] for x in attrs}
resp_obj.setdefault('status', {}) resp_obj.setdefault('status', {})
resp_obj['status']['bucket'] = document['bucket_name'] resp_obj['status']['bucket'] = document['bucket_name']

View File

@ -234,8 +234,10 @@ def _fill_in_metadata_defaults(values):
if not values['_metadata'].get('storagePolicy', None): if not values['_metadata'].get('storagePolicy', None):
values['_metadata']['storagePolicy'] = 'cleartext' values['_metadata']['storagePolicy'] = 'cleartext'
if ('layeringDefinition' in values['_metadata'] if 'layeringDefinition' not in values['_metadata']:
and 'abstract' not in values['_metadata']['layeringDefinition']): values['_metadata'].setdefault('layeringDefinition', {})
if 'abstract' not in values['_metadata']['layeringDefinition']:
values['_metadata']['layeringDefinition']['abstract'] = False values['_metadata']['layeringDefinition']['abstract'] = False
return values return values
@ -320,7 +322,7 @@ def revision_create(session=None):
return revision.to_dict() return revision.to_dict()
def revision_get(revision_id, session=None): def revision_get(revision_id=None, session=None):
"""Return the specified `revision_id`. """Return the specified `revision_id`.
:param revision_id: The ID corresponding to the ``Revision`` object. :param revision_id: The ID corresponding to the ``Revision`` object.
@ -343,6 +345,29 @@ def revision_get(revision_id, session=None):
return revision 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): def require_revision_exists(f):
"""Decorator to require the specified revision to exist. """Decorator to require the specified revision to exist.
@ -456,6 +481,23 @@ def revision_delete_all(session=None):
.delete(synchronize_session=False) .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): def _filter_revision_documents(documents, unique_only, **filters):
"""Return the list of documents that match 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. # TODO(fmontei): Implement this as an sqlalchemy query.
filtered_documents = {} 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: for document in documents:
# NOTE(fmontei): Only want to include non-validation policy 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 :param filters: Dictionary attributes (including nested) used to filter
out revision documents. out revision documents.
:param session: Database session object. :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 :returns: All revision documents for ``revision_id`` that match the
``filters``, including document revision history if applicable. ``filters``, including document revision history if applicable.
:raises: RevisionNotFound if the revision was not found. :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 # Remove each deleted document and its older counterparts because those
# documents technically don't exist. # documents technically don't exist.
for doc_collection in (docs, comparison_docs): for documents in (docs, comparison_docs):
for doc in copy.copy(doc_collection): documents = _exclude_deleted_documents(documents)
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)
revision = revision_get(revision_id) if revision_id != 0 else None revision = revision_get(revision_id) if revision_id != 0 else None
comparison_revision = (revision_get(comparison_revision_id) 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, latest_revision, session=None):
def revision_rollback(revision_id, session=None):
"""Rollback the latest revision to revision specified by ``revision_id``. """Rollback the latest revision to revision specified by ``revision_id``.
Rolls back the latest revision to the revision specified by ``revision_id`` Rolls back the latest revision to the revision specified by ``revision_id``
thereby creating a new, carbon-copy revision. thereby creating a new, carbon-copy revision.
:param revision_id: Revision ID to which to rollback. :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. :returns: The newly created revision.
""" """
session = session or get_session() 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 = [ latest_revision_hashes = [
(d['data_hash'], d['metadata_hash']) (d['data_hash'], d['metadata_hash'])
for d in latest_revision['documents']] for d in latest_revision['documents']]

View File

@ -135,9 +135,8 @@ class Document(BASE, DeckhandBase):
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True)
name = Column(String(64), nullable=False) name = Column(String(64), nullable=False)
schema = Column(String(64), nullable=False) schema = Column(String(64), nullable=False)
# NOTE: Do not define a maximum length for these JSON data below. However, # NOTE(fmontei): ``metadata`` is reserved by the DB, so ``_metadata``
# this approach is not compatible with all database types. # must be used to store document metadata information in the DB.
# "metadata" is reserved, so use "_metadata" instead.
_metadata = Column(oslo_types.JsonEncodedDict(), nullable=False) _metadata = Column(oslo_types.JsonEncodedDict(), nullable=False)
data = Column(oslo_types.JsonEncodedDict(), nullable=True) data = Column(oslo_types.JsonEncodedDict(), nullable=True)
data_hash = Column(String, nullable=False) data_hash = Column(String, nullable=False)
@ -175,8 +174,6 @@ class Document(BASE, DeckhandBase):
d = super(Document, self).to_dict() d = super(Document, self).to_dict()
d['bucket_name'] = self.bucket_name 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: if not raw_dict:
d['metadata'] = d.pop('_metadata') d['metadata'] = d.pop('_metadata')

View File

@ -137,3 +137,8 @@ class BarbicanException(DeckhandException):
def __init__(self, message, code): def __init__(self, message, code):
super(BarbicanException, self).__init__(message=message, code=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

View 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
View 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

View 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

View 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

View 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
View 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())

View File

@ -12,11 +12,12 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import os
import yaml
import gabbi.driver import gabbi.driver
import gabbi.handlers.jsonhandler import gabbi.handlers.jsonhandler
import gabbi.json_parser import gabbi.json_parser
import os
import yaml
TESTS_DIR = 'gabbits' TESTS_DIR = 'gabbits'
@ -48,11 +49,11 @@ class MultidocJsonpaths(gabbi.handlers.jsonhandler.JSONHandler):
def load_tests(loader, tests, pattern): def load_tests(loader, tests, pattern):
test_dir = os.path.join(os.path.dirname(__file__), TESTS_DIR) test_dir = os.path.join(os.path.dirname(__file__), TESTS_DIR)
return gabbi.driver.build_tests(test_dir, loader, return gabbi.driver.build_tests(test_dir, loader,
# NOTE(fmontei): When there are multiple handlers listed that # NOTE(fmontei): When there are multiple handlers listed that
# accept the same content-type, the one that is earliest in the # accept the same content-type, the one that is earliest in the
# list will be used. Thus, we cannot specify multiple content # list will be used. Thus, we cannot specify multiple content
# handlers for handling list/dictionary responses from the server # handlers for handling list/dictionary responses from the server
# using different handlers. # using different handlers.
content_handlers=[MultidocJsonpaths], content_handlers=[MultidocJsonpaths],
verbose=True, verbose=True,
url=os.environ['DECKHAND_TEST_URL']) url=os.environ['DECKHAND_TEST_URL'])

View File

@ -102,7 +102,8 @@ class TestDbBase(base.DeckhandWithDBTestCase):
return db_api.revision_get_all() return db_api.revision_get_all()
def rollback_revision(self, revision_id): 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): def _validate_object(self, obj):
for attr in BASE_EXPECTED_FIELDS: for attr in BASE_EXPECTED_FIELDS:

View File

@ -31,3 +31,85 @@ class TestRevisionDocumentsFiltering(base.TestDbBase):
self.assertEqual(1, len(retrieved_documents)) self.assertEqual(1, len(retrieved_documents))
self.assertEqual(bucket_name, retrieved_documents[0]['bucket_name']) 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)

View 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)

View File

@ -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 Hacking
======= =======

View File

@ -30,7 +30,16 @@
# Add any Sphinx extension module names here, as strings. They can be # Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # 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. # Add any paths that contain templates here, relative to this directory.
# templates_path = [] # templates_path = []

View File

@ -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 Glossary
======== ========

View File

@ -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 describe the manner in which Deckhand renders finalized documents for
consumption by other UCP services. consumption by other UCP services.
User's Guide
============
.. toctree::
:maxdepth: 2
policy-enforcement
Developer's Guide
=================
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
HACKING HACKING
testing testing
Glossary
========
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1

View 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

View File

@ -5,4 +5,5 @@ namespace = deckhand.conf
namespace = oslo.db namespace = oslo.db
namespace = oslo.log namespace = oslo.log
namespace = oslo.middleware namespace = oslo.middleware
namespace = oslo.policy
namespace = keystonemiddleware.auth_token namespace = keystonemiddleware.auth_token

View File

@ -541,3 +541,23 @@
# Whether the application is behind a proxy or not. This determines if the # Whether the application is behind a proxy or not. This determines if the
# middleware should parse the headers or not. (boolean value) # middleware should parse the headers or not. (boolean value)
#enable_proxy_headers_parsing = false #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

View File

@ -0,0 +1,3 @@
[DEFAULT]
output_file = etc/deckhand/policy.yaml.sample
namespace = deckhand

View 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"

View File

@ -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.

View File

@ -24,6 +24,9 @@ packages =
oslo.config.opts = oslo.config.opts =
deckhand.conf = deckhand.conf.opts:list_opts deckhand.conf = deckhand.conf.opts:list_opts
oslo.policy.policies =
deckhand = deckhand.policies:list_rules
[build_sphinx] [build_sphinx]
source-dir = doc/source source-dir = doc/source
build-dir = doc/build build-dir = doc/build

View File

@ -35,15 +35,17 @@ POSTGRES_IP=$(
$POSTGRES_ID $POSTGRES_ID
) )
log_section Creating config file
CONF_DIR=$(mktemp -d) CONF_DIR=$(mktemp -d)
export DECKHAND_TEST_URL=http://localhost:9000 function gen_config {
export DATABASE_URL=postgresql+psycopg2://deckhand:password@$POSTGRES_IP:5432/deckhand log_section Creating config file
# 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 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 cat <<EOCONF > $CONF_DIR/deckhand.conf
[DEFAULT] [DEFAULT]
@ -53,6 +55,9 @@ log_file = deckhand.log
log_dir = . log_dir = .
use_stderr = true use_stderr = true
[oslo_policy]
policy_file = policy.yaml
[barbican] [barbican]
[database] [database]
@ -61,11 +66,33 @@ connection = $DATABASE_URL
[keystone_authtoken] [keystone_authtoken]
EOCONF EOCONF
echo $CONF_DIR/deckhand.conf 1>&2 echo $CONF_DIR/deckhand.conf 1>&2
cat $CONF_DIR/deckhand.conf 1>&2 cat $CONF_DIR/deckhand.conf 1>&2
log_section Starting server log_section Starting server
rm -f deckhand.log 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 \ uwsgi \
--http :9000 \ --http :9000 \
@ -81,7 +108,12 @@ sleep 5
log_section Running tests log_section Running tests
set +e set +e
ostestr -c 1 $* posargs=$@
if [ ${#posargs} -ge 1 ]; then
ostestr --concurrency 1 --regex $1
else
ostestr --concurrency 1
fi
TEST_STATUS=$? TEST_STATUS=$?
set -e set -e

View File

@ -67,6 +67,9 @@ commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasen
[testenv:genconfig] [testenv:genconfig]
commands = oslo-config-generator --config-file=etc/deckhand/config-generator.conf 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] [testenv:pep8]
commands = flake8 {posargs} commands = flake8 {posargs}