[test] Add integration test scenario for encrypting generic type
This PS adds an integration test scenario for validating that encrypting a generic document type and using it as a substitution source during document rendering works. Deckhand will now submit all generic documents to be encrypted to Barbican with a 'secret_type' of 'passphrase'. No encoding is provided Deckhand-side (i.e. base64) because encoding is deprecated in Barbican since it lead to strange behavior; Barbican will figure out what to encode the payload as automatically. For more information, see [0] and [1]. In addition, this PS handles 2 edge cases around secret payloads that are rejected by Barbican if not handled correctly by Deckhand: empty payloads and non-string type payloads [2]. For the first case Deckhand forcibly changes the document to cleartext because there is no point in encrypting a document with an empty payload. For the second case Deckhand sets overrides any previously set secret_type to 'opaque' and encodes the payload to base64 -- when it goes to render the secret it decodes the payload also using base64. Integration tests have been added to handle both edge cases described above. [0] https://bugs.launchpad.net/python-barbicanclient/+bug/1419166 [1]49505b9aec/barbicanclient/v1/secrets.py (L252)
[2]49505b9aec/barbicanclient/v1/secrets.py (L297)
Change-Id: I1964aa84ad07b6f310b39974f078b84a1dc84983
This commit is contained in:
parent
0143b2d727
commit
84ab5c5096
@ -12,49 +12,213 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import ast
|
||||
import re
|
||||
|
||||
import barbicanclient
|
||||
from oslo_log import log as logging
|
||||
from oslo_serialization import base64
|
||||
from oslo_utils import uuidutils
|
||||
import six
|
||||
|
||||
from deckhand.barbican import client_wrapper
|
||||
from deckhand.common import utils
|
||||
from deckhand import errors
|
||||
from deckhand import types
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BarbicanDriver(object):
|
||||
|
||||
_url_re = re.compile(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|'
|
||||
'(?:%[0-9a-fA-F][0-9a-fA-F]))+')
|
||||
|
||||
def __init__(self):
|
||||
self.barbicanclient = client_wrapper.BarbicanClientWrapper()
|
||||
|
||||
def create_secret(self, **kwargs):
|
||||
"""Create a secret."""
|
||||
secret = self.barbicanclient.call("secrets.create", **kwargs)
|
||||
@classmethod
|
||||
def is_barbican_ref(cls, secret_ref):
|
||||
# TODO(felipemonteiro): Query Keystone service catalog for Barbican
|
||||
# endpoint and cache it if Keystone is enabled. For now, it should be
|
||||
# enough to check that ``secret_ref`` is a valid URL, contains
|
||||
# 'secrets' substring, ends in a UUID and that the source document from
|
||||
# which the reference is extracted is encrypted.
|
||||
try:
|
||||
secret_uuid = secret_ref.split('/')[-1]
|
||||
except Exception:
|
||||
secret_uuid = None
|
||||
return (
|
||||
isinstance(secret_ref, six.string_types) and
|
||||
cls._url_re.match(secret_ref) and
|
||||
'secrets' in secret_ref and
|
||||
uuidutils.is_uuid_like(secret_uuid)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_secret_type(schema):
|
||||
"""Get the Barbican secret type based on the following mapping:
|
||||
|
||||
``deckhand/Certificate/v1`` => certificate
|
||||
``deckhand/CertificateKey/v1`` => private
|
||||
``deckhand/CertificateAuthority/v1`` => certificate
|
||||
``deckhand/CertificateAuthorityKey/v1`` => private
|
||||
``deckhand/Passphrase/v1`` => passphrase
|
||||
``deckhand/PrivateKey/v1`` => private
|
||||
``deckhand/PublicKey/v1`` => public
|
||||
Other => passphrase
|
||||
|
||||
:param schema: The document's schema.
|
||||
:returns: The value corresponding to the mapping above.
|
||||
"""
|
||||
parts = schema.split('/')
|
||||
if len(parts) == 3:
|
||||
namespace, kind, _ = parts
|
||||
elif len(parts) == 2:
|
||||
namespace, kind = parts
|
||||
else:
|
||||
raise ValueError(
|
||||
'Schema %s must consist of namespace/kind/version.' % schema)
|
||||
|
||||
is_generic = (
|
||||
'/'.join([namespace, kind]) not in types.DOCUMENT_SECRET_TYPES
|
||||
)
|
||||
|
||||
# If the document kind is not a built-in secret type, then default to
|
||||
# 'passphrase'.
|
||||
if is_generic:
|
||||
LOG.debug('Defaulting to secret_type="passphrase" for generic '
|
||||
'document schema %s.', schema)
|
||||
return 'passphrase'
|
||||
|
||||
kind = kind.lower()
|
||||
|
||||
if kind in [
|
||||
'certificateauthoritykey', 'certificatekey', 'privatekey'
|
||||
]:
|
||||
return 'private'
|
||||
elif kind == 'certificateauthority':
|
||||
return 'certificate'
|
||||
elif kind == 'publickey':
|
||||
return 'public'
|
||||
# NOTE(felipemonteiro): This branch below handles certificate and
|
||||
# passphrase, both of which are supported secret types in Barbican.
|
||||
return kind
|
||||
|
||||
def _base64_encode_payload(self, secret_doc):
|
||||
"""Ensures secret document payload is compatible with Barbican."""
|
||||
|
||||
payload = secret_doc.data
|
||||
secret_type = self._get_secret_type(secret_doc.schema)
|
||||
|
||||
# NOTE(felipemonteiro): The logic for the 2 conditions below is
|
||||
# enforced from Barbican's Python client. Some pre-processing and
|
||||
# transformation is needed to make Barbican work with non-compatible
|
||||
# formats.
|
||||
if not payload and payload is not False:
|
||||
# There is no point in even bothering to encrypt an empty
|
||||
# body, which just leads to needless overhead, so return
|
||||
# early.
|
||||
LOG.info('Barbican does not accept empty payloads so '
|
||||
'Deckhand will not encrypt document [%s, %s] %s.',
|
||||
secret_doc.schema, secret_doc.layer, secret_doc.name)
|
||||
secret_doc.storage_policy = types.CLEARTEXT
|
||||
elif not isinstance(
|
||||
payload, (six.text_type, six.binary_type)):
|
||||
LOG.debug('Forcibly setting secret_type=opaque and '
|
||||
'base64-encoding non-string payload for '
|
||||
'document [%s, %s] %s.', secret_doc.schema,
|
||||
secret_doc.layer, secret_doc.name)
|
||||
# NOTE(felipemonteiro): base64-encoding the non-string payload is
|
||||
# done for serialization purposes, not for security purposes.
|
||||
# 'opaque' is used to avoid Barbican doing any further
|
||||
# serialization server-side.
|
||||
secret_type = 'opaque'
|
||||
try:
|
||||
payload = base64.encode_as_text(six.text_type(payload))
|
||||
except Exception:
|
||||
message = ('Failed to base64-encode payload of type %s '
|
||||
'for Barbican storage.', type(payload))
|
||||
LOG.error(message)
|
||||
raise errors.UnknownSubstitutionError(
|
||||
src_schema=secret_doc.schema,
|
||||
src_layer=secret_doc.layer, src_name=secret_doc.name,
|
||||
schema='N/A', layer='N/A', name='N/A', details=message)
|
||||
return secret_type, payload
|
||||
|
||||
def create_secret(self, secret_doc):
|
||||
"""Create a secret.
|
||||
|
||||
:param secret_doc: Document with ``storagePolicy`` of "encrypted".
|
||||
:type secret_doc: document.DocumentDict
|
||||
:returns: Secret reference returned by Barbican
|
||||
:rtype: str
|
||||
"""
|
||||
secret_type, payload = self._base64_encode_payload(secret_doc)
|
||||
|
||||
if secret_doc.storage_policy == types.CLEARTEXT:
|
||||
return payload
|
||||
|
||||
# Store secret_ref in database for `secret_doc`.
|
||||
kwargs = {
|
||||
'name': secret_doc['metadata']['name'],
|
||||
'secret_type': secret_type,
|
||||
'payload': payload
|
||||
}
|
||||
LOG.info('Storing encrypted document data in Barbican.')
|
||||
|
||||
try:
|
||||
secret = self.barbicanclient.call("secrets.create", **kwargs)
|
||||
secret_ref = secret.store()
|
||||
except (barbicanclient.exceptions.HTTPAuthError,
|
||||
barbicanclient.exceptions.HTTPClientError,
|
||||
barbicanclient.exceptions.HTTPServerError) as e:
|
||||
LOG.exception(str(e))
|
||||
LOG.error('Caught %s error from Barbican, likely due to a '
|
||||
'configuration or deployment issue.',
|
||||
e.__class__.__name__)
|
||||
raise errors.BarbicanException(details=str(e))
|
||||
except barbicanclient.exceptions.PayloadException as e:
|
||||
LOG.error('Caught %s error from Barbican, because the secret '
|
||||
'payload type is unsupported.', e.__class__.__name__)
|
||||
raise errors.BarbicanException(details=str(e))
|
||||
|
||||
# NOTE(fmontei): The dictionary representation of the Secret object by
|
||||
# default has keys that are not snake case -- so make them snake case.
|
||||
resp = secret.to_dict()
|
||||
for key in resp:
|
||||
resp[utils.to_snake_case(key)] = resp.pop(key)
|
||||
resp['secret_ref'] = secret_ref
|
||||
return resp
|
||||
|
||||
def get_secret(self, secret_ref):
|
||||
"""Get a secret."""
|
||||
return secret_ref
|
||||
|
||||
def _base64_decode_payload(self, src_doc, dest_doc, payload):
|
||||
try:
|
||||
return self.barbicanclient.call("secrets.get", secret_ref)
|
||||
# If the secret_type is 'opaque' then this implies the
|
||||
# payload was encoded to base64 previously. Reverse the
|
||||
# operation.
|
||||
payload = ast.literal_eval(base64.decode_as_text(payload))
|
||||
except Exception:
|
||||
message = ('Failed to unencode the original payload that '
|
||||
'presumably was encoded to base64 with '
|
||||
'secret_type=opaque for document [%s, %s] %s.' %
|
||||
src_doc.meta)
|
||||
LOG.error(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=message)
|
||||
return payload
|
||||
|
||||
def get_secret(self, src_doc, dest_doc, secret_ref):
|
||||
"""Get a secret."""
|
||||
try:
|
||||
secret = self.barbicanclient.call("secrets.get", secret_ref)
|
||||
except (barbicanclient.exceptions.HTTPAuthError,
|
||||
barbicanclient.exceptions.HTTPClientError,
|
||||
barbicanclient.exceptions.HTTPServerError,
|
||||
ValueError) as e:
|
||||
LOG.exception(str(e))
|
||||
raise errors.BarbicanException(details=str(e))
|
||||
|
||||
payload = secret.payload
|
||||
if secret.secret_type == 'opaque':
|
||||
LOG.debug('Forcibly base64-decoding original non-string payload '
|
||||
'for document [%s, %s] %s.', *src_doc.meta)
|
||||
secret = self._base64_decode_payload(src_doc, dest_doc, payload)
|
||||
else:
|
||||
secret = payload
|
||||
|
||||
return secret
|
||||
|
@ -30,6 +30,11 @@ class DocumentDict(dict):
|
||||
Useful for accessing nested dictionary keys without having to worry about
|
||||
exceptions getting thrown.
|
||||
|
||||
.. note::
|
||||
|
||||
As a rule of thumb, setters for any metadata properties should be
|
||||
avoided. Only implement or use for well-understood edge cases.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -112,7 +117,7 @@ class DocumentDict(dict):
|
||||
|
||||
@substitutions.setter
|
||||
def substitutions(self, value):
|
||||
return utils.jsonpath_replace(self, value, 'metadata.substitutions')
|
||||
return utils.jsonpath_replace(self, value, '.metadata.substitutions')
|
||||
|
||||
@property
|
||||
def actions(self):
|
||||
@ -123,6 +128,10 @@ class DocumentDict(dict):
|
||||
def storage_policy(self):
|
||||
return utils.jsonpath_parse(self, 'metadata.storagePolicy') or ''
|
||||
|
||||
@storage_policy.setter
|
||||
def storage_policy(self, value):
|
||||
return utils.jsonpath_replace(self, value, '.metadata.storagePolicy')
|
||||
|
||||
@property
|
||||
def is_encrypted(self):
|
||||
return self.storage_policy == 'encrypted'
|
||||
|
@ -56,21 +56,26 @@ def sanitize_params(allowed_params):
|
||||
# This maps which type should be enforced per query parameter.
|
||||
# Everything not included in type dict below is assumed to be a
|
||||
# string or a list of strings.
|
||||
type_dict = {'limit': int}
|
||||
type_dict = {
|
||||
'limit': {
|
||||
'func': lambda x: abs(int(x)),
|
||||
'type': int
|
||||
}
|
||||
}
|
||||
|
||||
def _enforce_query_filter_type(key, val):
|
||||
if key in type_dict:
|
||||
cast_type = type_dict[key]
|
||||
cast_func = type_dict.get(key)
|
||||
if cast_func:
|
||||
try:
|
||||
cast_val = cast_type(val)
|
||||
cast_val = cast_func['func'](val)
|
||||
except Exception:
|
||||
raise falcon.HTTPInvalidParam(
|
||||
'Query parameter %s must be of type %s.' % (
|
||||
key, cast_type),
|
||||
key, cast_func['type']),
|
||||
key)
|
||||
return cast_val
|
||||
else:
|
||||
return val
|
||||
cast_val = val
|
||||
return cast_val
|
||||
|
||||
def _convert_to_dict(sanitized_params, filter_key, filter_val):
|
||||
# Key-value pairs like metadata.label=foo=bar need to be
|
||||
|
@ -186,7 +186,8 @@ def documents_create(bucket_name, documents, validations=None,
|
||||
validation)
|
||||
|
||||
if documents_to_delete:
|
||||
LOG.debug('Deleting documents: %s.', documents_to_delete)
|
||||
LOG.debug('Deleting documents: %s.',
|
||||
[d.meta for d in documents_to_delete])
|
||||
deleted_documents = []
|
||||
|
||||
for d in documents_to_delete:
|
||||
@ -215,8 +216,9 @@ def documents_create(bucket_name, documents, validations=None,
|
||||
resp.append(doc.to_dict())
|
||||
|
||||
if documents_to_create:
|
||||
LOG.debug('Creating documents: %s.',
|
||||
[(d['schema'], d['name']) for d in documents_to_create])
|
||||
LOG.debug(
|
||||
'Creating documents: %s.', [(d['schema'], d['layer'], d['name'])
|
||||
for d in documents_to_create])
|
||||
for doc in documents_to_create:
|
||||
with session.begin():
|
||||
doc['bucket_id'] = bucket['id']
|
||||
|
@ -17,10 +17,9 @@ import re
|
||||
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log as logging
|
||||
from oslo_utils import uuidutils
|
||||
import six
|
||||
|
||||
from deckhand.barbican import driver
|
||||
from deckhand.barbican.driver import BarbicanDriver
|
||||
from deckhand.common import document as document_wrapper
|
||||
from deckhand.common import utils
|
||||
from deckhand import errors
|
||||
@ -36,10 +35,7 @@ class SecretsManager(object):
|
||||
Currently only supports Barbican.
|
||||
"""
|
||||
|
||||
barbican_driver = driver.BarbicanDriver()
|
||||
|
||||
_url_re = re.compile(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|'
|
||||
'(?:%[0-9a-fA-F][0-9a-fA-F]))+')
|
||||
barbican_driver = BarbicanDriver()
|
||||
|
||||
@staticmethod
|
||||
def requires_encryption(document):
|
||||
@ -48,76 +44,40 @@ class SecretsManager(object):
|
||||
document = clazz(document)
|
||||
return document.is_encrypted
|
||||
|
||||
@classmethod
|
||||
def is_barbican_ref(cls, secret_ref):
|
||||
# TODO(fmontei): Query Keystone service catalog for Barbican endpoint
|
||||
# and cache it if Keystone is enabled. For now, it should be enough
|
||||
# to check that ``secret_ref`` is a valid URL, contains 'secrets'
|
||||
# substring, ends in a UUID and that the source document from which
|
||||
# the reference is extracted is encrypted.
|
||||
try:
|
||||
secret_uuid = secret_ref.split('/')[-1]
|
||||
except Exception:
|
||||
secret_uuid = None
|
||||
return (
|
||||
isinstance(secret_ref, six.string_types) and
|
||||
cls._url_re.match(secret_ref) and
|
||||
'secrets' in secret_ref and
|
||||
uuidutils.is_uuid_like(secret_uuid)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create(cls, secret_doc):
|
||||
"""Securely store secrets contained in ``secret_doc``.
|
||||
|
||||
Ordinarily, Deckhand documents are stored directly in Deckhand's
|
||||
database. However, secret data (contained in the data section for the
|
||||
documents with the schemas enumerated below) must be stored using a
|
||||
secure storage service like Barbican.
|
||||
|
||||
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 Deckhand.
|
||||
by Barbican in its own DB.
|
||||
|
||||
:param secret_doc: A Deckhand document with one of the following
|
||||
schemas:
|
||||
:param secret_doc: A Deckhand document with a schema that belongs to
|
||||
``types.DOCUMENT_SECRET_TYPES``.
|
||||
|
||||
* ``deckhand/Certificate/v1``
|
||||
* ``deckhand/CertificateKey/v1``
|
||||
* ``deckhand/Passphrase/v1``
|
||||
|
||||
:returns: Dictionary representation of
|
||||
``deckhand.db.sqlalchemy.models.DocumentSecret``.
|
||||
: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)
|
||||
|
||||
encryption_type = secret_doc['metadata']['storagePolicy']
|
||||
secret_type = cls._get_secret_type(secret_doc['schema'])
|
||||
created_secret = secret_doc['data']
|
||||
if secret_doc.storage_policy == types.ENCRYPTED:
|
||||
payload = cls.barbican_driver.create_secret(secret_doc)
|
||||
else:
|
||||
payload = secret_doc.data
|
||||
|
||||
if encryption_type == types.ENCRYPTED:
|
||||
# Store secret_ref in database for `secret_doc`.
|
||||
kwargs = {
|
||||
'name': secret_doc['metadata']['name'],
|
||||
'secret_type': secret_type,
|
||||
'payload': secret_doc['data']
|
||||
}
|
||||
LOG.info('Storing encrypted document data in Barbican.')
|
||||
resp = cls.barbican_driver.create_secret(**kwargs)
|
||||
|
||||
secret_ref = resp['secret_ref']
|
||||
created_secret = secret_ref
|
||||
|
||||
return created_secret
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def get(cls, secret_ref):
|
||||
"""Return a secret payload from Barbican.
|
||||
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.
|
||||
@ -125,43 +85,13 @@ class SecretsManager(object):
|
||||
: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...')
|
||||
# TODO(fmontei): Need to avoid this call if Keystone is disabled.
|
||||
secret = cls.barbican_driver.get_secret(secret_ref=secret_ref)
|
||||
payload = secret.payload
|
||||
secret = cls.barbican_driver.get_secret(src_doc, dest_doc,
|
||||
secret_ref=secret_ref)
|
||||
LOG.debug('Successfully retrieved Barbican secret using reference.')
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _get_secret_type(cls, schema):
|
||||
"""Get the Barbican secret type based on the following mapping:
|
||||
|
||||
``deckhand/Certificate/v1`` => certificate
|
||||
``deckhand/CertificateKey/v1`` => private
|
||||
``deckhand/CertificateAuthority/v1`` => certificate
|
||||
``deckhand/CertificateAuthorityKey/v1`` => private
|
||||
``deckhand/Passphrase/v1`` => passphrase
|
||||
``deckhand/PrivateKey/v1`` => private
|
||||
``deckhand/PublicKey/v1`` => public
|
||||
|
||||
:param schema: The document's schema.
|
||||
:returns: The value corresponding to the mapping above.
|
||||
"""
|
||||
_schema = schema.split('/')[1].lower().strip()
|
||||
if _schema in [
|
||||
'certificateauthoritykey', 'certificatekey', 'privatekey'
|
||||
]:
|
||||
return 'private'
|
||||
elif _schema == 'certificateauthority':
|
||||
return 'certificate'
|
||||
elif _schema == 'publickey':
|
||||
return 'public'
|
||||
# NOTE(fmontei): This branch below handles certificate and passphrase,
|
||||
# both of which are supported secret types in Barbican.
|
||||
return _schema
|
||||
return secret
|
||||
|
||||
|
||||
class SecretsSubstitution(object):
|
||||
@ -209,7 +139,7 @@ class SecretsSubstitution(object):
|
||||
@staticmethod
|
||||
def get_encrypted_data(src_secret, src_doc, dest_doc):
|
||||
try:
|
||||
src_secret = SecretsManager.get(src_secret)
|
||||
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 '
|
||||
@ -270,24 +200,6 @@ class SecretsSubstitution(object):
|
||||
else:
|
||||
LOG.warning(exc_message)
|
||||
|
||||
def _get_encrypted_secret(self, src_secret, src_doc, dest_doc):
|
||||
try:
|
||||
src_secret = SecretsManager.get(src_secret)
|
||||
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 _check_src_secret_is_not_none(self, src_secret, src_path, src_doc,
|
||||
dest_doc):
|
||||
if src_secret is None:
|
||||
@ -378,7 +290,7 @@ class SecretsSubstitution(object):
|
||||
|
||||
# If the document has storagePolicy == encrypted then resolve
|
||||
# the Barbican reference into the actual secret.
|
||||
if src_doc.is_encrypted and SecretsManager.is_barbican_ref(
|
||||
if src_doc.is_encrypted and BarbicanDriver.is_barbican_ref(
|
||||
src_secret):
|
||||
src_secret = self.get_encrypted_data(src_secret, src_doc,
|
||||
document)
|
||||
|
@ -0,0 +1,134 @@
|
||||
# Tests success paths for edge cases around rendering with secrets.
|
||||
#
|
||||
# 1. Verifies that attempting to encrypt a secret passphrase with an empty
|
||||
# string skips encryption and stores the document as cleartext instead
|
||||
# and that rendering the document works (which should avoid Barbican
|
||||
# API call).
|
||||
# 2. Verifies that attempting to encrypt any document with an incompatible
|
||||
# payload type (non-str, non-binary) results in the payload being properly
|
||||
# encoded and decoded by Deckhand before and after storing and retrieving
|
||||
# the encrypted data.
|
||||
|
||||
defaults:
|
||||
request_headers:
|
||||
content-type: application/x-yaml
|
||||
X-Auth-Token: $ENVIRON['TEST_AUTH_TOKEN']
|
||||
response_headers:
|
||||
content-type: application/x-yaml
|
||||
verbose: true
|
||||
|
||||
tests:
|
||||
### Scenario 1 ###
|
||||
- name: attempt_create_encrypted_passphrase_empty_payload
|
||||
desc: |
|
||||
Attempting to create an encrypted passphrase with empty payload should
|
||||
avoid encryption and return the empty payload instead.
|
||||
PUT: /api/v1.0/buckets/secret/documents
|
||||
status: 200
|
||||
data: |-
|
||||
---
|
||||
schema: deckhand/LayeringPolicy/v1
|
||||
metadata:
|
||||
schema: metadata/Control/v1
|
||||
name: layering-policy
|
||||
data:
|
||||
layerOrder:
|
||||
- site
|
||||
---
|
||||
schema: deckhand/Passphrase/v1
|
||||
metadata:
|
||||
schema: metadata/Document/v1
|
||||
name: my-passphrase
|
||||
storagePolicy: encrypted
|
||||
layeringDefinition:
|
||||
layer: site
|
||||
data: ''
|
||||
...
|
||||
response_multidoc_jsonpaths:
|
||||
$.`len`: 2
|
||||
$.[1].metadata.name: my-passphrase
|
||||
$.[1].data: ''
|
||||
|
||||
- name: verify_revision_documents_returns_same_empty_payload
|
||||
desc: Verify that the created document wasn't encrypted.
|
||||
GET: /api/v1.0/revisions/$RESPONSE['$.[0].status.revision']/documents
|
||||
status: 200
|
||||
query_parameters:
|
||||
metadata.name: my-passphrase
|
||||
response_multidoc_jsonpaths:
|
||||
$.`len`: 1
|
||||
$.[0].data: ''
|
||||
|
||||
- name: verify_rendered_documents_returns_same_empty_payload
|
||||
desc: |
|
||||
Verify that rendering the document returns the same empty payload
|
||||
which requires that the data be read directly from Deckhand's DB
|
||||
rather than Barbican.
|
||||
GET: /api/v1.0/revisions/$RESPONSE['$.[0].status.revision']/rendered-documents
|
||||
status: 200
|
||||
query_parameters:
|
||||
metadata.name: my-passphrase
|
||||
response_multidoc_jsonpaths:
|
||||
$.`len`: 1
|
||||
$.[0].data: ''
|
||||
|
||||
### Scenario 2 ###
|
||||
- name: create_encrypted_passphrase_with_incompatible_payload
|
||||
desc: |
|
||||
Attempting to encrypt a non-str/non-bytes payload should result in
|
||||
it first being base64-encoded then passed to Barbican. The response
|
||||
should be a Barbican reference, which indicates the scenario passed
|
||||
successfully.
|
||||
PUT: /api/v1.0/buckets/secret/documents
|
||||
status: 200
|
||||
data: |-
|
||||
---
|
||||
schema: deckhand/LayeringPolicy/v1
|
||||
metadata:
|
||||
schema: metadata/Control/v1
|
||||
name: layering-policy
|
||||
data:
|
||||
layerOrder:
|
||||
- site
|
||||
---
|
||||
schema: armada/Generic/v1
|
||||
metadata:
|
||||
schema: metadata/Document/v1
|
||||
name: armada-doc
|
||||
storagePolicy: encrypted
|
||||
layeringDefinition:
|
||||
layer: site
|
||||
data:
|
||||
# This will be an object in memory requiring base64 encoding.
|
||||
foo: bar
|
||||
...
|
||||
response_multidoc_jsonpaths:
|
||||
$.`len`: 2
|
||||
$.[1].metadata.name: armada-doc
|
||||
# NOTE(fmontei): jsonpath-rw-ext uses a 1 character separator (rather than allowing a string)
|
||||
# leading to this nastiness:
|
||||
$.[1].data.`split(:, 0, 1)` + "://" + $.[1].data.`split(/, 2, 3)`: $ENVIRON['TEST_BARBICAN_URL']
|
||||
|
||||
- name: verify_revision_documents_returns_barbican_ref
|
||||
desc: Verify that the encrypted document returns a Barbican ref.
|
||||
GET: /api/v1.0/revisions/$RESPONSE['$.[0].status.revision']/documents
|
||||
status: 200
|
||||
query_parameters:
|
||||
metadata.name: armada-doc
|
||||
response_multidoc_jsonpaths:
|
||||
$.`len`: 1
|
||||
$.[0].data.`split(:, 0, 1)` + "://" + $.[0].data.`split(/, 2, 3)`: $ENVIRON['TEST_BARBICAN_URL']
|
||||
|
||||
- name: verify_rendered_documents_returns_unencrypted_payload
|
||||
desc: |
|
||||
Verify that rendering the document returns the original payload which
|
||||
means that Deckhand successfully encoded and decoded the non-compatible
|
||||
payload using base64 encoding.
|
||||
GET: /api/v1.0/revisions/$RESPONSE['$.[0].status.revision']/rendered-documents
|
||||
status: 200
|
||||
query_parameters:
|
||||
metadata.name: armada-doc
|
||||
response_multidoc_jsonpaths:
|
||||
$.`len`: 1
|
||||
$.[0].data:
|
||||
foo: bar
|
@ -41,6 +41,11 @@ tests:
|
||||
layer: site
|
||||
data: not-a-real-password
|
||||
...
|
||||
response_multidoc_jsonpaths:
|
||||
$.`len`: 2
|
||||
# NOTE(fmontei): jsonpath-rw-ext uses a 1 character separator (rather than allowing a string)
|
||||
# leading to this nastiness:
|
||||
$.[1].data.`split(:, 0, 1)` + "://" + $.[1].data.`split(/, 2, 3)`: $ENVIRON['TEST_BARBICAN_URL']
|
||||
|
||||
- name: verify_rendered_documents_returns_secret_payload
|
||||
desc: Verify that the rendering the document returns the secret payload.
|
||||
|
@ -0,0 +1,97 @@
|
||||
# Tests success paths for secret substitution using a generic document type.
|
||||
# This entails setting storagePolicy=encrypted for a non-built-in secret
|
||||
# document.
|
||||
#
|
||||
# 1. Tests that creating an encrypted generic document results in a
|
||||
# Barbican reference being returned.
|
||||
# 2. Tests that the encrypted payload is included in the destination
|
||||
# and source documents after document rendering.
|
||||
|
||||
defaults:
|
||||
request_headers:
|
||||
content-type: application/x-yaml
|
||||
X-Auth-Token: $ENVIRON['TEST_AUTH_TOKEN']
|
||||
response_headers:
|
||||
content-type: application/x-yaml
|
||||
verbose: true
|
||||
|
||||
tests:
|
||||
- name: purge
|
||||
desc: Begin testing from known state.
|
||||
DELETE: /api/v1.0/revisions
|
||||
status: 204
|
||||
response_headers: null
|
||||
|
||||
- name: encrypt_generic_document_for_secret_substitution
|
||||
desc: |
|
||||
Create documents using a generic document type (armada/Generic/v1) as the
|
||||
substitution source with storagePolicy=encrypted.
|
||||
PUT: /api/v1.0/buckets/secret/documents
|
||||
status: 200
|
||||
data: |-
|
||||
---
|
||||
schema: deckhand/LayeringPolicy/v1
|
||||
metadata:
|
||||
schema: metadata/Control/v1
|
||||
name: layering-policy
|
||||
data:
|
||||
layerOrder:
|
||||
- site
|
||||
---
|
||||
# Generic document as substitution source.
|
||||
schema: armada/Generic/v1
|
||||
metadata:
|
||||
name: example-armada-cert
|
||||
schema: metadata/Document/v1
|
||||
layeringDefinition:
|
||||
layer: site
|
||||
storagePolicy: encrypted
|
||||
data: ARMADA CERTIFICATE DATA
|
||||
---
|
||||
schema: armada/Chart/v1
|
||||
metadata:
|
||||
schema: metadata/Document/v1
|
||||
name: armada-chart-01
|
||||
layeringDefinition:
|
||||
layer: site
|
||||
substitutions:
|
||||
- dest:
|
||||
path: .chart.values.tls.certificate
|
||||
src:
|
||||
schema: armada/Generic/v1
|
||||
name: example-armada-cert
|
||||
path: .
|
||||
data: {}
|
||||
...
|
||||
|
||||
- name: verify_multiple_revision_documents_returns_secret_ref
|
||||
desc: Verify that secret ref was created for example-armada-cert among multiple created documents.
|
||||
GET: /api/v1.0/revisions/$RESPONSE['$.[0].status.revision']/documents
|
||||
status: 200
|
||||
query_parameters:
|
||||
metadata.name: example-armada-cert
|
||||
response_multidoc_jsonpaths:
|
||||
$.`len`: 1
|
||||
# NOTE(fmontei): jsonpath-rw-ext uses a 1 character separator (rather than allowing a string)
|
||||
# leading to this nastiness:
|
||||
$.[0].data.`split(:, 0, 1)` + "://" + $.[0].data.`split(/, 2, 3)`: $ENVIRON['TEST_BARBICAN_URL']
|
||||
|
||||
- name: verify_secret_payload_in_destination_document
|
||||
desc: Verify secret payload is injected in destination document as well as example-armada-cert.
|
||||
GET: /api/v1.0/revisions/$RESPONSE['$.[0].status.revision']/rendered-documents
|
||||
status: 200
|
||||
query_parameters:
|
||||
metadata.name:
|
||||
- armada-chart-01
|
||||
- example-armada-cert
|
||||
sort: metadata.name
|
||||
response_multidoc_jsonpaths:
|
||||
$.`len`: 2
|
||||
$.[0].metadata.name: armada-chart-01
|
||||
$.[0].data:
|
||||
chart:
|
||||
values:
|
||||
tls:
|
||||
certificate: ARMADA CERTIFICATE DATA
|
||||
$.[1].metadata.name: example-armada-cert
|
||||
$.[1].data: ARMADA CERTIFICATE DATA
|
@ -16,9 +16,13 @@ import copy
|
||||
import yaml
|
||||
|
||||
import mock
|
||||
from oslo_serialization import base64
|
||||
from oslo_utils import uuidutils
|
||||
import six
|
||||
import testtools
|
||||
|
||||
from deckhand.barbican import driver
|
||||
from deckhand.common import document as document_wrapper
|
||||
from deckhand.engine import secrets_manager
|
||||
from deckhand import errors
|
||||
from deckhand import factories
|
||||
@ -30,38 +34,45 @@ class TestSecretsManager(test_base.TestDbBase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestSecretsManager, self).setUp()
|
||||
self.mock_barbican_driver = self.patchobject(
|
||||
secrets_manager.SecretsManager, 'barbican_driver')
|
||||
self.mock_barbicanclient = self.patchobject(
|
||||
secrets_manager.SecretsManager.barbican_driver, 'barbicanclient')
|
||||
self.secret_ref = "https://barbican_host/v1/secrets/{secret_uuid}"\
|
||||
.format(**{'secret_uuid': uuidutils.generate_uuid()})
|
||||
self.mock_barbican_driver.create_secret.return_value = (
|
||||
{'secret_ref': self.secret_ref})
|
||||
self.factory = factories.DocumentSecretFactory()
|
||||
|
||||
def _test_create_secret(self, encryption_type, secret_type):
|
||||
secret_data = test_utils.rand_password()
|
||||
secret_doc = self.factory.gen_test(
|
||||
secret_type, encryption_type, secret_data)
|
||||
payload = secret_doc['data']
|
||||
self.mock_barbican_driver.get_secret.return_value = (
|
||||
mock.Mock(payload=payload))
|
||||
def _mock_barbican_client_call(self, payload):
|
||||
def fake_call(action, *args, **kwargs):
|
||||
if action == "secrets.create":
|
||||
return mock.Mock(**{'store.return_value': self.secret_ref})
|
||||
elif action == "secrets.get":
|
||||
return mock.Mock(payload=payload)
|
||||
|
||||
created_secret = secrets_manager.SecretsManager.create(secret_doc)
|
||||
fake_secret_obj = self.mock_barbicanclient.call
|
||||
fake_secret_obj.side_effect = fake_call
|
||||
|
||||
def _test_create_secret(self, encryption_type, secret_type):
|
||||
secret_payload = test_utils.rand_password()
|
||||
secret_doc = self.factory.gen_test(
|
||||
secret_type, encryption_type, secret_payload)
|
||||
payload = secret_doc['data']
|
||||
|
||||
self._mock_barbican_client_call(payload)
|
||||
secret_ref = secrets_manager.SecretsManager.create(secret_doc)
|
||||
|
||||
if encryption_type == 'cleartext':
|
||||
self.assertEqual(secret_data, created_secret)
|
||||
self.assertEqual(secret_payload, secret_ref)
|
||||
elif encryption_type == 'encrypted':
|
||||
expected_kwargs = {
|
||||
'name': secret_doc['metadata']['name'],
|
||||
'secret_type': secrets_manager.SecretsManager._get_secret_type(
|
||||
'secret_type': driver.BarbicanDriver._get_secret_type(
|
||||
'deckhand/' + secret_type),
|
||||
'payload': payload
|
||||
}
|
||||
self.mock_barbican_driver.create_secret.assert_called_once_with(
|
||||
**expected_kwargs)
|
||||
self.assertEqual(self.secret_ref, created_secret)
|
||||
self.assertEqual(self.secret_ref, secret_ref)
|
||||
self.mock_barbicanclient.call.assert_called_once_with(
|
||||
'secrets.create', **expected_kwargs)
|
||||
|
||||
return created_secret, payload
|
||||
return secret_ref, payload
|
||||
|
||||
def test_create_cleartext_certificate(self):
|
||||
self._test_create_secret('cleartext', 'Certificate')
|
||||
@ -102,11 +113,59 @@ class TestSecretsManager(test_base.TestDbBase):
|
||||
def test_retrieve_barbican_secret(self):
|
||||
secret_ref, expected_secret = self._test_create_secret(
|
||||
'encrypted', 'Certificate')
|
||||
secret_payload = secrets_manager.SecretsManager.get(secret_ref)
|
||||
self.mock_barbicanclient.get_secret.return_value = (
|
||||
mock.Mock(payload=expected_secret))
|
||||
|
||||
secret_payload = secrets_manager.SecretsManager.get(secret_ref, {}, {})
|
||||
|
||||
self.assertEqual(expected_secret, secret_payload)
|
||||
self.mock_barbican_driver.get_secret.assert_called_once_with(
|
||||
secret_ref=secret_ref)
|
||||
self.mock_barbicanclient.call.assert_called_with(
|
||||
'secrets.get', secret_ref)
|
||||
|
||||
def test_empty_payload_skips_encryption(self):
|
||||
for empty_payload in ('', {}, []):
|
||||
secret_doc = self.factory.gen_test(
|
||||
'Certificate', 'encrypted', empty_payload)
|
||||
|
||||
self._mock_barbican_client_call(empty_payload)
|
||||
retrieved_payload = secrets_manager.SecretsManager.create(
|
||||
secret_doc)
|
||||
|
||||
self.assertEqual(empty_payload, retrieved_payload)
|
||||
self.assertEqual('cleartext',
|
||||
secret_doc['metadata']['storagePolicy'])
|
||||
self.mock_barbicanclient.call.assert_not_called()
|
||||
|
||||
def test_create_and_retrieve_base64_encoded_payload(self):
|
||||
# Validate base64-encoded encryption.
|
||||
payload = {'foo': 'bar'}
|
||||
secret_doc = self.factory.gen_test(
|
||||
'Certificate', 'encrypted', payload)
|
||||
|
||||
expected_payload = base64.encode_as_text(six.text_type({'foo': 'bar'}))
|
||||
expected_kwargs = {
|
||||
'name': secret_doc['metadata']['name'],
|
||||
'secret_type': 'opaque',
|
||||
'payload': expected_payload
|
||||
}
|
||||
|
||||
self._mock_barbican_client_call(payload)
|
||||
secret_ref = secrets_manager.SecretsManager.create(secret_doc)
|
||||
|
||||
self.assertEqual(self.secret_ref, secret_ref)
|
||||
self.assertEqual('encrypted',
|
||||
secret_doc['metadata']['storagePolicy'])
|
||||
self.mock_barbicanclient.call.assert_called_once_with(
|
||||
"secrets.create", **expected_kwargs)
|
||||
|
||||
# Validate base64-encoded decryption.
|
||||
self.mock_barbicanclient.get_secret.return_value = (
|
||||
mock.Mock(payload=expected_payload, secret_type='opaque'))
|
||||
|
||||
dummy_document = document_wrapper.DocumentDict({})
|
||||
retrieved_payload = secrets_manager.SecretsManager.get(
|
||||
secret_ref, dummy_document, dummy_document)
|
||||
self.assertEqual(payload, retrieved_payload)
|
||||
|
||||
|
||||
class TestSecretsSubstitution(test_base.TestDbBase):
|
||||
@ -197,7 +256,8 @@ class TestSecretsSubstitution(test_base.TestDbBase):
|
||||
}
|
||||
self._test_doc_substitution(
|
||||
document_mapping, [certificate], expected_data)
|
||||
mock_secrets_manager.get.assert_called_once_with(secret_ref=secret_ref)
|
||||
mock_secrets_manager.get.assert_called_once_with(
|
||||
secret_ref, certificate, mock.ANY)
|
||||
|
||||
def test_create_destination_path_with_array(self):
|
||||
# Validate that the destination data will be populated with an array
|
||||
|
@ -12,33 +12,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
DOCUMENT_SCHEMA_TYPES = (
|
||||
CERTIFICATE_AUTHORITY_SCHEMA,
|
||||
CERTIFICATE_KEY_AUTHORITY_SCHEMA,
|
||||
CERTIFICATE_SCHEMA,
|
||||
CERTIFICATE_KEY_SCHEMA,
|
||||
PRIVATE_KEY_SCHEMA,
|
||||
PUBLIC_KEY_SCHEMA,
|
||||
PASSPHRASE_SCHEMA,
|
||||
DATA_SCHEMA_SCHEMA,
|
||||
LAYERING_POLICY_SCHEMA,
|
||||
PASSPHRASE_SCHEMA,
|
||||
VALIDATION_POLICY_SCHEMA,
|
||||
) = (
|
||||
'deckhand/CertificateAuthority',
|
||||
'deckhand/CertificateAuthorityKey',
|
||||
'deckhand/Certificate',
|
||||
'deckhand/CertificateKey',
|
||||
'deckhand/PrivateKey',
|
||||
'deckhand/PublicKey',
|
||||
'deckhand/Passphrase',
|
||||
'deckhand/DataSchema',
|
||||
'deckhand/LayeringPolicy',
|
||||
'deckhand/Passphrase',
|
||||
'deckhand/ValidationPolicy',
|
||||
)
|
||||
|
||||
|
||||
DOCUMENT_SECRET_TYPES = (
|
||||
CERTIFICATE_AUTHORITY_SCHEMA,
|
||||
CERTIFICATE_KEY_AUTHORITY_SCHEMA,
|
||||
@ -52,12 +25,26 @@ DOCUMENT_SECRET_TYPES = (
|
||||
'deckhand/CertificateAuthorityKey',
|
||||
'deckhand/Certificate',
|
||||
'deckhand/CertificateKey',
|
||||
'deckhand/Passphrase',
|
||||
'deckhand/PrivateKey',
|
||||
'deckhand/PublicKey',
|
||||
'deckhand/Passphrase'
|
||||
)
|
||||
|
||||
|
||||
DOCUMENT_SCHEMA_TYPES = (
|
||||
DATA_SCHEMA_SCHEMA,
|
||||
LAYERING_POLICY_SCHEMA,
|
||||
VALIDATION_POLICY_SCHEMA,
|
||||
) = (
|
||||
'deckhand/DataSchema',
|
||||
'deckhand/LayeringPolicy',
|
||||
'deckhand/ValidationPolicy',
|
||||
)
|
||||
|
||||
|
||||
DOCUMENT_SCHEMA_TYPES += DOCUMENT_SECRET_TYPES
|
||||
|
||||
|
||||
DECKHAND_VALIDATION_TYPES = (
|
||||
DECKHAND_SCHEMA_VALIDATION,
|
||||
) = (
|
||||
@ -70,5 +57,5 @@ ENCRYPTION_TYPES = (
|
||||
ENCRYPTED
|
||||
) = (
|
||||
'cleartext',
|
||||
'encrypted'
|
||||
'encrypted',
|
||||
)
|
||||
|
@ -71,8 +71,8 @@ Supported query string parameters:
|
||||
* ``metadata.name`` - string, optional
|
||||
* ``metadata.layeringDefinition.abstract`` - string, optional - Valid values are
|
||||
the "true" and "false".
|
||||
* ``metadata.layeringDefinition.layer`` - string, optional - Only return documents from
|
||||
the specified layer.
|
||||
* ``metadata.layeringDefinition.layer`` - string, optional - Only return
|
||||
documents from the specified layer.
|
||||
* ``metadata.label`` - string, optional, repeatable - Uses the format
|
||||
``metadata.label=key=value``. Repeating this parameter indicates all
|
||||
requested labels must apply (AND not OR).
|
||||
@ -86,7 +86,8 @@ Supported query string parameters:
|
||||
"asc". Controls the order in which the ``sort`` result is returned: "asc"
|
||||
returns sorted results in ascending order, while "desc" returns results in
|
||||
descending order.
|
||||
* ``limit`` - int - Controls number of documents returned by this endpoint.
|
||||
* ``limit`` - int, optional - Controls number of documents returned by this
|
||||
endpoint.
|
||||
|
||||
GET ``/revisions/{revision_id}/rendered-documents``
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
Loading…
x
Reference in New Issue
Block a user