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
|
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)
|
||||||
|
@ -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()
|
|
||||||
|
@ -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()
|
||||||
|
@ -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):
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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())
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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')
|
||||||
|
@ -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)
|
||||||
|
@ -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']
|
||||||
|
@ -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']]
|
||||||
|
@ -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')
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
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
|
# 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'])
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
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
|
Hacking
|
||||||
=======
|
=======
|
||||||
|
@ -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 = []
|
||||||
|
@ -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
|
||||||
========
|
========
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
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.db
|
||||||
namespace = oslo.log
|
namespace = oslo.log
|
||||||
namespace = oslo.middleware
|
namespace = oslo.middleware
|
||||||
|
namespace = oslo.policy
|
||||||
namespace = keystonemiddleware.auth_token
|
namespace = keystonemiddleware.auth_token
|
||||||
|
@ -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
|
||||||
|
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 =
|
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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
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]
|
[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}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user