356 lines
15 KiB
Python
356 lines
15 KiB
Python
# 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 copy
|
|
import re
|
|
|
|
from oslo_config import cfg
|
|
from oslo_log import log as logging
|
|
import six
|
|
|
|
from deckhand.barbican.driver import BarbicanDriver
|
|
from deckhand.common import document as document_wrapper
|
|
from deckhand.common import utils
|
|
from deckhand import errors
|
|
from deckhand import types
|
|
|
|
CONF = cfg.CONF
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
class SecretsManager(object):
|
|
"""Internal API resource for interacting with Barbican.
|
|
|
|
Currently only supports Barbican.
|
|
"""
|
|
|
|
barbican_driver = BarbicanDriver()
|
|
|
|
@staticmethod
|
|
def requires_encryption(document):
|
|
clazz = document_wrapper.DocumentDict
|
|
if not isinstance(document, clazz):
|
|
document = clazz(document)
|
|
return document.is_encrypted
|
|
|
|
@classmethod
|
|
def create(cls, secret_doc):
|
|
"""Securely store secrets contained in ``secret_doc``.
|
|
|
|
Documents with ``metadata.storagePolicy`` == "clearText" have their
|
|
secrets stored directly in Deckhand.
|
|
|
|
Documents with ``metadata.storagePolicy`` == "encrypted" are stored in
|
|
Barbican directly. Deckhand in turn stores the reference returned
|
|
by Barbican in its own DB.
|
|
|
|
:param secret_doc: A Deckhand document with a schema that belongs to
|
|
``types.DOCUMENT_SECRET_TYPES``.
|
|
|
|
:returns: Unecrypted data section from ``secret_doc`` if the document's
|
|
``storagePolicy`` is "cleartext" or a Barbican secret reference
|
|
if the ``storagePolicy`` is "encrypted'.
|
|
"""
|
|
# TODO(fmontei): Look into POSTing Deckhand metadata into Barbican's
|
|
# Secrets Metadata API to make it easier to track stale secrets from
|
|
# prior revisions that need to be deleted.
|
|
if not isinstance(secret_doc, document_wrapper.DocumentDict):
|
|
secret_doc = document_wrapper.DocumentDict(secret_doc)
|
|
|
|
if secret_doc.storage_policy == types.ENCRYPTED:
|
|
payload = cls.barbican_driver.create_secret(secret_doc)
|
|
else:
|
|
payload = secret_doc.data
|
|
|
|
return payload
|
|
|
|
@classmethod
|
|
def get(cls, secret_ref, src_doc, dest_doc):
|
|
"""Retrieve a secret payload from Barbican.
|
|
|
|
Extracts {secret_uuid} from a secret reference and queries Barbican's
|
|
Secrets API with it.
|
|
|
|
:param str secret_ref: A string formatted like:
|
|
"https://{barbican_host}/v1/secrets/{secret_uuid}"
|
|
:returns: Secret payload from Barbican.
|
|
"""
|
|
LOG.debug('Resolving Barbican secret using source document '
|
|
'reference...')
|
|
secret = cls.barbican_driver.get_secret(src_doc, dest_doc,
|
|
secret_ref=secret_ref)
|
|
LOG.debug('Successfully retrieved Barbican secret using reference.')
|
|
return secret
|
|
|
|
|
|
class SecretsSubstitution(object):
|
|
"""Class for document substitution logic for YAML files."""
|
|
|
|
__slots__ = ('_fail_on_missing_sub_src', '_substitution_sources')
|
|
|
|
_insecure_reg_exps = (
|
|
re.compile(r'^.* is not of type .+$'),
|
|
)
|
|
|
|
@staticmethod
|
|
def sanitize_potential_secrets(error, document):
|
|
"""Sanitize all secret data that may have been substituted into the
|
|
document or contained in the document itself (if the document has
|
|
``metadata.storagePolicy`` == 'encrypted'). Uses references in
|
|
``document.substitutions`` to determine which values to sanitize. Only
|
|
meaningful to call this on post-rendered documents.
|
|
|
|
:param error: Error message produced by ``jsonschema``.
|
|
:param document: Document to sanitize.
|
|
:type document: DocumentDict
|
|
"""
|
|
if not document.substitutions and not document.is_encrypted:
|
|
return document
|
|
|
|
to_sanitize = copy.deepcopy(document)
|
|
safe_message = 'Sanitized to avoid exposing secret.'
|
|
|
|
# Sanitize any secrets contained in `error.message` referentially.
|
|
if error.message and any(
|
|
r.match(error.message)
|
|
for r in SecretsSubstitution._insecure_reg_exps):
|
|
error.message = safe_message
|
|
|
|
# Sanitize any secrets extracted from the document itself.
|
|
for sub in document.substitutions:
|
|
replaced_data = utils.jsonpath_replace(
|
|
to_sanitize['data'], safe_message, sub['dest']['path'])
|
|
if replaced_data:
|
|
to_sanitize['data'] = replaced_data
|
|
|
|
return to_sanitize
|
|
|
|
@staticmethod
|
|
def get_encrypted_data(src_secret, src_doc, dest_doc):
|
|
try:
|
|
src_secret = SecretsManager.get(src_secret, src_doc, dest_doc)
|
|
except errors.BarbicanException as e:
|
|
LOG.error(
|
|
'Failed to resolve a Barbican reference for substitution '
|
|
'source document [%s, %s] %s referenced in document [%s, %s] '
|
|
'%s. Details: %s', src_doc.schema, src_doc.layer, src_doc.name,
|
|
dest_doc.schema, dest_doc.layer, dest_doc.name,
|
|
e.format_message())
|
|
raise errors.UnknownSubstitutionError(
|
|
src_schema=src_doc.schema, src_layer=src_doc.layer,
|
|
src_name=src_doc.name, schema=dest_doc.schema,
|
|
layer=dest_doc.layer, name=dest_doc.name,
|
|
details=e.format_message())
|
|
else:
|
|
return src_secret
|
|
|
|
def __init__(self, substitution_sources=None,
|
|
fail_on_missing_sub_src=True):
|
|
"""SecretSubstitution constructor.
|
|
|
|
This class will automatically detect documents that require
|
|
substitution; documents need not be filtered prior to being passed to
|
|
the constructor.
|
|
|
|
:param substitution_sources: (DEPRECATED) List of documents that are
|
|
potential sources for substitution. Or dict of documents keyed on
|
|
tuple of (schema, metadata.name). Should only include concrete
|
|
documents.
|
|
:type substitution_sources: List[dict] or dict
|
|
:param bool fail_on_missing_sub_src: Whether to fail on a missing
|
|
substitution source. Default is True.
|
|
"""
|
|
|
|
# This maps a 2-tuple of (schema, name) to a document from which the
|
|
# document.meta can be extracted which is a 3-tuple of (schema, layer,
|
|
# name). This is necessary since the substitution format in the
|
|
# document itself only provides a 2-tuple of (schema, name).
|
|
self._substitution_sources = {}
|
|
self._fail_on_missing_sub_src = fail_on_missing_sub_src
|
|
|
|
if isinstance(substitution_sources, dict):
|
|
self._substitution_sources = substitution_sources
|
|
else:
|
|
self._substitution_sources = dict()
|
|
for document in substitution_sources:
|
|
if not isinstance(document, document_wrapper.DocumentDict):
|
|
document = document_wrapper.DocumentDict(document)
|
|
if document.schema and document.name:
|
|
self._substitution_sources.setdefault(
|
|
(document.schema, document.name), document)
|
|
|
|
def _handle_unknown_substitution_exc(self, exc_message, src_doc, dest_doc):
|
|
if self._fail_on_missing_sub_src:
|
|
LOG.error(exc_message)
|
|
raise errors.UnknownSubstitutionError(
|
|
src_schema=src_doc.schema, src_layer=src_doc.layer,
|
|
src_name=src_doc.name, schema=dest_doc.schema,
|
|
layer=dest_doc.layer, name=dest_doc.name, details=exc_message)
|
|
else:
|
|
LOG.warning(exc_message)
|
|
|
|
def _check_src_secret_is_not_none(self, src_secret, src_path, src_doc,
|
|
dest_doc):
|
|
if src_secret is None:
|
|
if self._fail_on_missing_sub_src:
|
|
raise errors.SubstitutionSourceDataNotFound(
|
|
src_path=src_path, src_schema=src_doc.schema,
|
|
src_layer=src_doc.layer, src_name=src_doc.name,
|
|
dest_schema=dest_doc.schema, dest_layer=dest_doc.layer,
|
|
dest_name=dest_doc.name)
|
|
else:
|
|
LOG.warning('Could not find source path %s in source document '
|
|
'or the secret extracted is None. Source document:'
|
|
' [%s, %s] %s. Destination document: [%s, %s] %s.',
|
|
src_path, src_doc.schema, src_doc.layer,
|
|
src_doc.name, dest_doc.schema, dest_doc.layer,
|
|
dest_doc.name)
|
|
|
|
def substitute_all(self, documents):
|
|
"""Substitute all documents that have a `metadata.substitutions` field.
|
|
|
|
Concrete (non-abstract) documents can be used as a source of
|
|
substitution into other documents. This substitution is
|
|
layer-independent, a document in the region layer could insert data
|
|
from a document in the site layer.
|
|
|
|
:param documents: List of documents that are candidates for
|
|
substitution.
|
|
:type documents: dict or List[dict]
|
|
:returns: List of fully substituted documents.
|
|
:rtype: Generator[:class:`DocumentDict`]
|
|
:raises SubstitutionSourceNotFound: If a substitution source document
|
|
is referenced by another document but wasn't found.
|
|
:raises UnknownSubstitutionError: If an unknown error occurred during
|
|
substitution.
|
|
"""
|
|
|
|
documents_to_substitute = []
|
|
if not isinstance(documents, list):
|
|
documents = [documents]
|
|
|
|
for document in documents:
|
|
if not isinstance(document, document_wrapper.DocumentDict):
|
|
document = document_wrapper.DocumentDict(document)
|
|
# If the document has substitutions include it.
|
|
if document.substitutions:
|
|
documents_to_substitute.append(document)
|
|
|
|
LOG.debug('Performing substitution on following documents: %s',
|
|
', '.join(['[%s, %s] %s' % d.meta
|
|
for d in documents_to_substitute]))
|
|
|
|
for document in documents_to_substitute:
|
|
LOG.debug('Checking for substitutions for document [%s, %s] %s.',
|
|
*document.meta)
|
|
for sub in document.substitutions:
|
|
src_schema = sub['src']['schema']
|
|
src_name = sub['src']['name']
|
|
src_path = sub['src']['path']
|
|
|
|
if (src_schema, src_name) in self._substitution_sources:
|
|
src_doc = self._substitution_sources[
|
|
(src_schema, src_name)]
|
|
else:
|
|
message = ('Could not find substitution source document '
|
|
'[%s] %s among the provided substitution '
|
|
'sources.' % (src_schema, src_name))
|
|
if self._fail_on_missing_sub_src:
|
|
LOG.error(message)
|
|
raise errors.SubstitutionSourceNotFound(
|
|
src_schema=src_schema, src_name=src_name,
|
|
document_schema=document.schema,
|
|
document_name=document.name)
|
|
else:
|
|
LOG.warning(message)
|
|
continue
|
|
|
|
# If the data is a dictionary, retrieve the nested secret
|
|
# via jsonpath_parse, else the secret is the primitive/string
|
|
# stored in the data section itself.
|
|
if isinstance(src_doc.get('data'), dict):
|
|
src_secret = utils.jsonpath_parse(src_doc.get('data', {}),
|
|
src_path)
|
|
else:
|
|
src_secret = src_doc.get('data')
|
|
|
|
self._check_src_secret_is_not_none(src_secret, src_path,
|
|
src_doc, document)
|
|
|
|
# If the document has storagePolicy == encrypted then resolve
|
|
# the Barbican reference into the actual secret.
|
|
if src_doc.is_encrypted and BarbicanDriver.is_barbican_ref(
|
|
src_secret):
|
|
src_secret = self.get_encrypted_data(src_secret, src_doc,
|
|
document)
|
|
|
|
if not isinstance(sub['dest'], list):
|
|
dest_array = [sub['dest']]
|
|
else:
|
|
dest_array = sub['dest']
|
|
|
|
for each_dest_path in dest_array:
|
|
dest_path = each_dest_path['path']
|
|
dest_pattern = each_dest_path.get('pattern', None)
|
|
|
|
LOG.debug('Substituting from schema=%s layer=%s name=%s '
|
|
'src_path=%s into dest_path=%s, dest_pattern=%s',
|
|
src_schema, src_doc.layer, src_name, src_path,
|
|
dest_path, dest_pattern)
|
|
|
|
try:
|
|
exc_message = ''
|
|
substituted_data = utils.jsonpath_replace(
|
|
document['data'], src_secret,
|
|
dest_path, dest_pattern)
|
|
if (isinstance(document['data'], dict) and
|
|
isinstance(substituted_data, dict)):
|
|
document['data'].update(substituted_data)
|
|
elif substituted_data:
|
|
document['data'] = substituted_data
|
|
else:
|
|
exc_message = (
|
|
'Failed to create JSON path "%s" in the '
|
|
'destination document [%s, %s] %s. '
|
|
'No data was substituted.' % (
|
|
dest_path, document.schema,
|
|
document.layer, document.name))
|
|
except Exception as e:
|
|
LOG.error('Unexpected exception occurred '
|
|
'while attempting '
|
|
'substitution using '
|
|
'source document [%s, %s] %s '
|
|
'referenced in [%s, %s] %s. Details: %s',
|
|
src_schema, src_name, src_doc.layer,
|
|
document.schema, document.layer,
|
|
document.name,
|
|
six.text_type(e))
|
|
exc_message = six.text_type(e)
|
|
finally:
|
|
if exc_message:
|
|
self._handle_unknown_substitution_exc(
|
|
exc_message, src_doc, document)
|
|
|
|
yield document
|
|
|
|
def update_substitution_sources(self, schema, name, data):
|
|
if (schema, name) not in self._substitution_sources:
|
|
return
|
|
|
|
substitution_src = self._substitution_sources[(schema, name)]
|
|
if isinstance(data, dict) and isinstance(substitution_src.data, dict):
|
|
substitution_src.data.update(data)
|
|
else:
|
|
substitution_src.data = data
|