Add secret-store migrate API as microversion 1.3

Introduce PUT /v1/secrets/{id}/secret-store/{secret-store-id} so
project admins can move an existing payload onto another configured
backend without changing the secret UUID. Project members may migrate
only to the project's preferred store (or the global default when no
preferred store is set).

The request body is empty. Replace store metadata by deleting-and-
clearing mapped rows rather than orphan-deleting them in place. Treat
a UUID-shaped unknown store id, including the nil UUID, as 404 rather
than 400. Microversion 1.3 always returns computed secret_store_id /
secret_store_ref on secret metadata GET and list (null when multiple
backends are disabled or the secret has no payload). A live payload
that cannot be mapped to exactly one catalogue store returns HTTP 500.
The same microversion returns secret_store_id on secret-stores
catalogue GET responses so clients need not parse secret_store_ref.

Use the secret owner's project for rewrap_secret, not the token
project. Admin tokens scoped to a different project must still resolve
KEKs on the owning project (same as decrypt/delete).

Move secret_to_response and secret_not_found into secret_views so
consumers.py can build secret bodies without importing the secrets
controller (avoids a circular import).

Partial-Implements: blueprint admin-migrate-secret-store
Assisted-by: Cursor Grok 4.5
Change-Id: Ifa1c6ece6c361b7ae5b58d90f204b7660ae160d1
Signed-off-by: Ade Lee <alee@redhat.com>
This commit is contained in:
Ade Lee committed 2026-10-01 12:56:09 -04:00
1 parent 721dc68e7a
commit 9df3aa8848
29 files changed
+2048 -109

No files matched your search

+20 -1
View File
@@ -68,8 +68,10 @@ def _do_enforce_rbac(inst, req, action_name, ctx, **kwargs):
# Enforce access controls.
if ctx.policy_enforcer:
target = flatten(policy_dict)
creds = _credentials_for_policy(
ctx, action_name, target_data)
ctx.policy_enforcer.authorize(action_name, target,
ctx, do_raise=True)
creds, do_raise=True)
def enforce_rbac(action_name='default'):
@@ -160,6 +162,23 @@ def enforce_content_types(valid_content_types=[]):
return content_types_decorator
def _credentials_for_policy(ctx, action_name, target_data):
"""Return credentials for oslo.policy, without mutating ``ctx``.
GenericCheck is always ``creds[left] == target[right]``. The
migrate rule compares the destination store id from the URL with
the project's preferred (or global-default) store on the target.
The path id is stored on the target as ``req_secret_store_id``
and copied onto credentials as ``secret_store_id`` so the rule
can stay ``secret_store_id:%(target.secret.preferred_secret_store_id)s``.
"""
if action_name != 'secret:migrate_secretstore' or not target_data:
return ctx
creds = dict(ctx.to_policy_values())
creds['secret_store_id'] = target_data.get('req_secret_store_id')
return creds
def flatten(d, parent_key=''):
"""Flatten a nested dictionary
+4 -6
View File
@@ -14,6 +14,7 @@ import pecan
from barbican import api
from barbican.api import controllers
from barbican.api.controllers import secret_views
from barbican.common import exception
from barbican.common import hrefs
from barbican.common import quota
@@ -23,7 +24,6 @@ from barbican.common import validators
from barbican import i18n as u
from barbican.model import models
from barbican.model import repositories as repo
from barbican.plugin import util as putil
LOG = utils.getLogger(__name__)
@@ -391,13 +391,11 @@ class SecretConsumersController(controllers.ACLMixin):
secret = self.secret_repo.get_secret_by_id(
secret_id, suppress_exception=True)
if not secret:
controllers.secrets.secret_not_found()
secret_views.secret_not_found()
return secret
def _return_secret_data(self, secret_id):
secret = self._get_secret(secret_id)
secret_fields = putil.mime_types.augment_fields_with_content_types(
secret)
return hrefs.convert_to_hrefs(secret_fields)
return secret_views.secret_to_response(
secret, pecan.request)
+57
View File
@@ -0,0 +1,57 @@
# 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.
"""Shared secret HTTP helpers used by more than one controller.
Kept out of secrets.py so consumers.py can build secret responses without
importing the secrets controller (which would be a circular import).
"""
import pecan
from barbican.api.controllers import versions
from barbican.common import hrefs
from barbican.common import utils
from barbican import i18n as u
from barbican.plugin import resources as plugin
from barbican.plugin.util import mime_types
def secret_not_found():
"""Throw exception indicating secret not found."""
pecan.abort(404, u._('Secret not found.'))
def secret_to_response(secret, request, transport_key_id=None):
"""Build a secret HTTP body for the request microversion.
Centralizes content-types, hrefs, consumers (1.1), and computed
secret-store fields (1.3) so every return path uses one helper.
"""
fields = mime_types.augment_fields_with_content_types(secret)
if transport_key_id:
fields['transport_key_id'] = transport_key_id
resp = hrefs.convert_to_hrefs(fields)
if versions.is_supported(request, max_version='1.0'):
resp.pop('consumers', None)
if versions.is_supported(request, min_version='1.3'):
store = None
if utils.is_multiple_backends_enabled():
store = plugin.resolve_secret_store_for_secret(secret)
if store is None:
resp['secret_store_id'] = None
resp['secret_store_ref'] = None
else:
resp['secret_store_id'] = store.id
resp['secret_store_ref'] = hrefs.convert_secret_stores_to_href(
store.id)
return resp
+87 -39
View File
@@ -12,6 +12,7 @@
from oslo_utils import strutils
from oslo_utils import timeutils
from oslo_utils import uuidutils
import pecan
from urllib import parse
@@ -19,6 +20,7 @@ from barbican import api
from barbican.api import controllers
from barbican.api.controllers import acls
from barbican.api.controllers import consumers
from barbican.api.controllers import secret_views
from barbican.api.controllers import secretmeta
from barbican.api.controllers import versions
from barbican.common import accept
@@ -32,7 +34,6 @@ from barbican import i18n as u
from barbican.model import models
from barbican.model import repositories as repo
from barbican.plugin import resources as plugin
from barbican.plugin import util as putil
LOG = utils.getLogger(__name__)
@@ -40,7 +41,7 @@ LOG = utils.getLogger(__name__)
def _secret_not_found():
"""Throw exception indicating secret not found."""
pecan.abort(404, u._('Secret not found.'))
secret_views.secret_not_found()
def _invalid_secret_id():
@@ -86,25 +87,33 @@ class SecretController(controllers.ACLMixin):
@pecan.expose()
def _lookup(self, sub_resource, *remainder):
if sub_resource == 'acl':
return acls.SecretACLsController(self.secret), remainder
elif sub_resource == 'metadata':
if len(remainder) == 0 or remainder == ('',):
return secretmeta.SecretMetadataController(self.secret), \
remainder
else:
match sub_resource:
case 'acl':
return acls.SecretACLsController(self.secret), remainder
case 'metadata':
if len(remainder) == 0 or remainder == ('',):
return (secretmeta.SecretMetadataController(self.secret),
remainder)
request_method = pecan.request.method
allowed_methods = ['GET', 'PUT', 'DELETE']
if request_method in allowed_methods:
return secretmeta.SecretMetadatumController(self.secret), \
remainder
else:
# methods cannot be handled at controller level
pecan.abort(405)
else:
# only 'acl' and 'metadata' as sub-resource is supported
pecan.abort(404)
return (
secretmeta.SecretMetadatumController(self.secret),
remainder)
# methods cannot be handled at controller level
pecan.abort(405)
case 'secret-store':
if not versions.is_supported(pecan.request,
min_version='1.3'):
pecan.abort(404)
# Spec: PUT /v1/secrets/{id}/secret-store/{secret-store-id}
if not remainder or remainder == ('',):
pecan.abort(404)
store_id = remainder[0]
return (SecretMigrateStoreController(self.secret, store_id),
remainder[1:])
case _:
pecan.abort(404)
@pecan.expose(generic=True)
def index(self, **kwargs):
@@ -120,9 +129,6 @@ class SecretController(controllers.ACLMixin):
LOG.info('Retrieved secret metadata for project: %s',
external_project_id)
if versions.is_supported(pecan.request, max_version='1.0'):
# NOTE(xek): consumers are being introduced in 1.1
del resp['consumers']
return resp
else:
LOG.warning('Decrypted secret %s requested using deprecated '
@@ -135,16 +141,10 @@ class SecretController(controllers.ACLMixin):
"""GET Metadata-only for a secret."""
pecan.override_template('json', 'application/json')
secret_fields = putil.mime_types.augment_fields_with_content_types(
secret)
transport_key_id = self._get_transport_key_id_if_needed(
kwargs.get('transport_key_needed'), secret)
if transport_key_id:
secret_fields['transport_key_id'] = transport_key_id
return hrefs.convert_to_hrefs(secret_fields)
return secret_views.secret_to_response(
secret, pecan.request, transport_key_id=transport_key_id)
def _get_transport_key_id_if_needed(self, transport_key_needed, secret):
if transport_key_needed and transport_key_needed.lower() == 'true':
@@ -290,6 +290,63 @@ class SecretController(controllers.ACLMixin):
pass
class SecretMigrateStoreController(controllers.ACLMixin):
"""Handles PUT /v1/secrets/{id}/secret-store/{ss-id} (microversion 1.3)."""
def __init__(self, secret, secret_store_id):
LOG.debug('=== Creating SecretMigrateStoreController ===')
super().__init__()
self.secret = secret
self.secret_store_id = secret_store_id
self.secret_stores_repo = repo.get_secret_stores_repository()
def get_acl_tuple(self, req, **kwargs):
entity, acl = super().get_acl_tuple(req, **kwargs)
if acl is None:
acl = {}
entity = 'secret'
preferred_store_id = (
plugin.get_effective_secret_store_id_for_project(
getattr(self.secret, 'project', None)))
# Destination id from the URL. Copied onto credentials as
# secret_store_id in _do_enforce_rbac so the rule can compare
# secret_store_id:%(target.secret.preferred_secret_store_id)s
acl['req_secret_store_id'] = self.secret_store_id
acl['preferred_secret_store_id'] = preferred_store_id
return entity, acl
@pecan.expose(generic=True)
def index(self, **kwargs):
pecan.abort(405)
@index.when(method='PUT')
@utils.allow_all_content_types
@controllers.handle_exceptions(u._('Secret store migration'))
@controllers.enforce_rbac('secret:migrate_secretstore')
def on_put(self, external_project_id, **kwargs):
if not utils.is_multiple_backends_enabled():
raise exception.MultipleBackendsNotEnabled()
store_id = self.secret_store_id
if not uuidutils.is_uuid_like(store_id):
pecan.abort(400, u._('Provided secret store id is invalid.'))
secret_store = self.secret_stores_repo.get(
entity_id=store_id, suppress_exception=True)
if not secret_store:
pecan.abort(404, u._('Not Found. Secret store not found.'))
# Use the secret owner project, not the token project, so
# rewrap_secret resolves KEKs on the owning project.
owner_project_id = self.secret.project.external_id
project = res.get_or_create_project(owner_project_id)
plugin.rewrap_secret(self.secret, project, secret_store)
pecan.response.status = 204
LOG.info('Migrated secret %s for project: %s',
self.secret.id, owner_project_id)
return ''
class SecretsController(controllers.ACLMixin):
"""Handles Secret creation requests."""
@@ -378,15 +435,6 @@ class SecretsController(controllers.ACLMixin):
@controllers.handle_exceptions(u._('Secret(s) retrieval'))
@controllers.enforce_rbac('secrets:get')
def on_get(self, external_project_id, **kw):
no_consumers = versions.is_supported(pecan.request, max_version='1.0')
# NOTE(xek): consumers are being introduced in 1.1
def secret_fields(field):
resp = putil.mime_types.augment_fields_with_content_types(field)
if no_consumers:
del resp['consumers']
return resp
LOG.debug('Start secrets on_get '
'for project-ID %s:', external_project_id)
@@ -451,7 +499,7 @@ class SecretsController(controllers.ACLMixin):
'total': total}
else:
secrets_resp = [
hrefs.convert_to_hrefs(secret_fields(s))
secret_views.secret_to_response(s, pecan.request)
for s in secrets
]
# Add filtered query_string to pagination link
+8 -14
View File
@@ -15,11 +15,12 @@
import pecan
from barbican.api import controllers
from barbican.common import hrefs
from barbican.api.controllers import versions
from barbican.common import resources as res
from barbican.common import utils
from barbican import i18n as u
from barbican.model import repositories as repo
from barbican.plugin import resources as plugin
from barbican.plugin.util import multiple_backends
LOG = utils.getLogger(__name__)
@@ -43,13 +44,9 @@ def _multiple_backends_not_enabled():
def convert_secret_store_to_response_format(secret_store):
data = secret_store.to_dict_fields()
data['secret_store_plugin'] = data.pop('store_plugin')
data['secret_store_ref'] = hrefs.convert_secret_stores_to_href(
data['secret_store_id'])
# no need to pass store id as secret_store_ref is returned
data.pop('secret_store_id', None)
return data
requested = str(pecan.request.environ.get(
'key-manager.microversion', versions.MIN_API_VERSION))
return secret_store.to_api_dict(microversion=requested)
class PreferredSecretStoreController(controllers.ACLMixin):
@@ -207,11 +204,8 @@ class SecretStoresController(controllers.ACLMixin):
project = res.get_or_create_project(external_project_id)
project_store = self.proj_store_repo.get_secret_store_for_project(
project.id, None, suppress_exception=True)
if project_store is None:
preferred = plugin.get_preferred_secret_store_for_project(project)
if preferred is None:
_preferred_secret_store_not_found()
return convert_secret_store_to_response_format(
project_store.secret_store)
return convert_secret_store_to_response_format(preferred)
+2 -2
View File
@@ -27,8 +27,8 @@ from barbican import version
LOG = utils.getLogger(__name__)
_MIN_MICROVERSION = 0
_MAX_MICROVERSION = 2
_LAST_UPDATED = '2025-09-18T00:00:00Z'
_MAX_MICROVERSION = 3
_LAST_UPDATED = '2026-08-24T00:00:00Z'
# NOTE(xek): The above defines the minimum and maximum version of the API
# across all of the v1 REST API.
+23
View File
@@ -404,3 +404,26 @@ class MultipleStorePluginValueMissing(BarbicanException):
).format(section_name)
)
self.section_name = section_name
class SecretPayloadNotFound(BarbicanHTTPException):
"""Raised when a secret has no payload to migrate."""
message = u._("Secret has no stored payload to migrate.")
client_message = message
status_code = 400
class SecretStoreNotResolved(BarbicanHTTPException):
"""Raised when a secret has a payload but no catalogue store matches."""
message = u._(
"Could not resolve the secret store for secret %(secret_id)s.")
client_message = u._(
"Could not resolve the secret store for this secret.")
status_code = 500
class MultipleBackendsNotEnabled(BarbicanHTTPException):
"""Raised when migrate is requested but multiple backends are off."""
message = u._("Multiple secret store backends are not enabled.")
client_message = message
status_code = 400
+21
View File
@@ -158,6 +158,27 @@ rules = [
}
],
deprecated_rule=deprecated_secrets_get
),
policy.DocumentedRuleDefault(
name='secret:migrate_secretstore',
check_str=(
"True:%(enforce_new_defaults)s and "
"(role:admin or "
"(rule:secret_project_member and "
"secret_store_id:%(target.secret."
"preferred_secret_store_id)s))"),
scope_types=['project', 'domain', 'system'],
description=(
'Migrate a secret to a named secret store. Admins may '
'target any store; members may migrate only to the '
'project preferred store (or the global default).'),
operations=[
{
'path': ('/v1/secrets/{secret-id}/secret-store/'
'{secret-store-id}'),
'method': 'PUT'
}
]
)
]
+26
View File
@@ -28,6 +28,7 @@ from sqlalchemy.orm import collections as col
from sqlalchemy import types as sql_types
from barbican.common import exception
from barbican.common import hrefs
from barbican.common import utils
from barbican import i18n as u
@@ -206,6 +207,14 @@ class ModelBase(object):
return expiration
def _microversion_at_least(requested, minimum):
"""Compare OpenStack-style major.minor microversions."""
def _parts(ver):
major, minor = str(ver).split('.')
return int(major), int(minor)
return _parts(requested) >= _parts(minimum)
class SoftDeleteMixIn(object):
"""Mix-in class that adds soft delete functionality."""
@@ -1608,6 +1617,23 @@ class SecretStores(BASE, ModelBase):
'global_default': self.global_default,
'name': self.name}
def to_api_dict(self, microversion='1.0'):
"""Return this store as a public API response dict.
``to_dict_fields`` remains the version-agnostic DB dump
(``store_plugin``, always includes ``secret_store_id``). This
method applies API field names and microversion 1.3 visibility
of ``secret_store_id``.
"""
data = self.to_dict_fields()
store_id = data['secret_store_id']
data['secret_store_plugin'] = data.pop('store_plugin')
data['secret_store_ref'] = hrefs.convert_secret_stores_to_href(
store_id)
if not _microversion_at_least(microversion, '1.3'):
data.pop('secret_store_id', None)
return data
class ProjectSecretStore(BASE, ModelBase):
"""Stores secret store to be used for new project secrets.
+18 -2
View File
@@ -757,8 +757,12 @@ class SecretRepo(BaseRepo):
return query.order_by(*ordering)
def get_secret_by_id(self, entity_id, suppress_exception=False,
session=None):
"""Gets secret by its entity id without project id check."""
session=None, for_update=False):
"""Gets secret by its entity id without project id check.
:param for_update: When True, issue SELECT ... FOR UPDATE so
concurrent migrates of the same secret are serialized.
"""
session = self.get_session(session)
try:
utcnow = timeutils.utcnow()
@@ -768,6 +772,8 @@ class SecretRepo(BaseRepo):
query = session.query(models.Secret)
query = query.filter_by(id=entity_id, deleted=False)
query = query.filter(expiration_filter)
if for_update:
query = query.with_for_update()
entity = query.one()
except sa_orm.exc.NoResultFound:
entity = None
@@ -835,6 +841,16 @@ class SecretStoreMetadatumRepo(BaseRepo):
metadata = query.all()
return {m.key: m.value for m in metadata}
def delete_for_secret(self, secret_id):
"""Soft-delete all store metadata rows for a secret."""
session = get_session()
query = session.query(models.SecretStoreMetadatum)
query = query.filter_by(deleted=False)
query = query.filter(
models.SecretStoreMetadatum.secret_id == secret_id)
for metadatum in query.all():
metadatum.delete(session=session)
def _do_entity_name(self):
"""Sub-class hook: return entity name, such as for debugging."""
return "SecretStoreMetadatum"
+12
View File
@@ -131,6 +131,18 @@ class _CryptoPluginManager(named.NamedExtensionManager):
return decrypting_plugin
def get_plugin_by_name(self, plugin_name):
"""Get a loaded crypto plugin by its stevedore/config name.
``plugin_name`` is the value stored on
``secret_stores.crypto_plugin`` (for example ``simple_crypto``),
not the Python class path saved on KEK metadata.
"""
for ext in self.extensions:
if ext.obj and ext.name == plugin_name:
return ext.obj
raise base.CryptoPluginNotFound()
def _get_internal_plugin_names(self, crypto_conf):
"""Gets plugin names used for loading via stevedore.
+13
View File
@@ -640,6 +640,19 @@ class SecretStorePluginManager(named.NamedExtensionManager):
return plugin
raise SecretGenerateSupportedPluginNotFound(key_spec)
def get_plugin_by_name(self, store_plugin_name):
"""Get a loaded plugin by its stevedore/config name.
``store_plugin_name`` is the value stored on
``secret_stores.store_plugin`` (for example ``store_crypto``
or ``kmip_plugin``), not the Python class path saved on secret
metadata.
"""
for ext in self.extensions:
if ext.obj and ext.name == store_plugin_name:
return ext.obj
raise SecretStorePluginNotFound(store_plugin_name)
def _get_internal_plugin_names(self, secretstore_conf):
"""Gets plugin names used for loading via stevedore.
+287 -8
View File
@@ -11,14 +11,24 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from sqlalchemy import exc as sa_exc
from barbican.common import exception
from barbican.common import utils
from barbican import i18n as u
from barbican.model import models
from barbican.model import repositories as repos
from barbican.plugin.crypto import base as crypto_base
from barbican.plugin.crypto import manager as crypto_mgr
from barbican.plugin.interface import secret_store
from barbican.plugin import store_crypto
from barbican.plugin.util import translations as tr
LOG = utils.getLogger(__name__)
# Metadata keys that identify the plugin, not the stored object.
_PLUGIN_META_KEYS = ('plugin_name', 'content_type')
def _get_transport_key_model(key_spec, transport_key_needed, project_id):
key_model = None
@@ -270,18 +280,282 @@ def delete_secret(secret_model, project_id):
external_project_id=project_id)
def get_preferred_secret_store_for_project(project_model):
"""Return the preferred SecretStores row for a project, or None."""
if project_model is None:
return None
project_store_repo = repos.get_project_secret_store_repository()
project_store = project_store_repo.get_secret_store_for_project(
project_model.id, None, suppress_exception=True)
if project_store is None:
return None
return project_store.secret_store
def get_effective_secret_store_id_for_project(project_model):
"""Return preferred or global-default store id for a project.
Used by migrate policy so project members may only migrate onto the
project's preferred store, or the global default when none is set.
"""
if not utils.is_multiple_backends_enabled():
return None
preferred = get_preferred_secret_store_for_project(project_model)
if preferred is not None:
return preferred.id
from barbican.plugin.util import multiple_backends
default_store = multiple_backends.get_global_default_secret_store()
return default_store.id if default_store else None
def resolve_secret_store_for_secret(secret_model):
"""Return the SecretStores row currently holding this secret, if any.
Matches plugin identity the same way retrieve does, then maps that
onto the unique ``secret_stores`` catalogue row.
Returns None when multiple backends are disabled or the secret has
no payload. Raises
:class:`~barbican.common.exception.SecretStoreNotResolved`
when the secret has a live payload but zero or multiple catalogue
rows match (misconfiguration / removed backend).
"""
if not utils.is_multiple_backends_enabled():
return None
if not _secret_has_payload(secret_model):
return None
stores_repo = repos.get_secret_stores_repository()
matches = []
for store in stores_repo.get_all():
try:
store_plugin, crypto_plugin = _resolve_plugins_for_store(store)
except (secret_store.SecretStorePluginNotFound,
crypto_base.CryptoPluginNotFound):
LOG.debug('Skipping secret store %s while resolving current '
'store for secret %s', store.id, secret_model.id,
exc_info=True)
continue
if _secret_is_already_on_target(secret_model, store_plugin,
crypto_plugin):
matches.append(store)
if len(matches) == 1:
return matches[0]
LOG.error(
'Secret %s matched %s secret_stores rows while resolving '
'computed secret_store_id', secret_model.id, len(matches))
raise exception.SecretStoreNotResolved(secret_id=secret_model.id)
def rewrap_secret(secret_model, project_model, secret_store_model):
"""Move a secret payload onto a different secret store.
Decrypts via the plugin currently associated with ``secret_model``,
stores onto the target store, then retires the previous backend
object only when it is a different plugin object.
Takes a ``SELECT ... FOR UPDATE`` row lock on the secret so two
concurrent migrates of the same UUID are serialized. The loser
re-checks after the lock and no-ops when the winner already moved
the payload onto the requested store.
This is intentionally not ``store_secret()``, which refuses secrets
that already have a payload.
:param secret_model: Secret with an existing stored payload
:param project_model: Project that owns the secret
:param secret_store_model: Destination ``SecretStores`` row
"""
secret_repo = repos.get_secret_repository()
locked = secret_repo.get_secret_by_id(
secret_model.id, for_update=True)
if not _secret_has_payload(locked):
raise exception.SecretPayloadNotFound()
store_plugin, crypto_plugin = _resolve_plugins_for_store(
secret_store_model)
if _secret_is_already_on_target(locked, store_plugin,
crypto_plugin):
LOG.debug('Secret %s is already on the requested store',
locked.id)
return
current_meta = _get_secret_meta(locked)
plugin_manager = secret_store.get_manager()
retrieve_plugin = plugin_manager.get_plugin_retrieve_delete(
current_meta.get('plugin_name'))
secret_dto = _get_secret(
retrieve_plugin, current_meta, locked, project_model)
old_meta = current_meta
old_datums = _get_encrypted_datums(locked)
old_datum_ids = {datum.id for datum in old_datums}
# Keep the type from retrieve, else the type already stored.
# Default MIME is applied only in _save_secret_metadata_in_repo
# when both are missing (None is not persisted).
content_type = (
secret_dto.content_type
or old_meta.get('content_type')
)
new_meta = None
rollback_meta = None
try:
new_meta = _store_secret_using_plugin(
store_plugin, secret_dto, locked, project_model,
crypto_plugin=crypto_plugin)
rollback_meta = dict(new_meta) if new_meta else None
# Drop the previous mapped collection first. Saving new keys
# such as plugin_name into attribute_mapped_collection would
# otherwise orphan-delete the live ORM rows, and a later
# row.delete() raises InvalidRequestError.
secret_meta_repo = repos.get_secret_meta_repository()
if locked.id:
secret_meta_repo.delete_for_secret(locked.id)
if locked.secret_store_metadata:
locked.secret_store_metadata.clear()
_save_secret_metadata_in_repo(
locked, new_meta, store_plugin, content_type)
for datum in old_datums:
if not getattr(datum, 'deleted', False):
datum.delete()
except (exception.BarbicanException, sa_exc.SQLAlchemyError):
# Plugin or DB failure after a new backend object may exist.
LOG.exception('Failed to migrate secret %s to store %s',
locked.id, secret_store_model.id)
_rollback_failed_migrate(
store_plugin, locked, rollback_meta, old_datum_ids)
raise
saved_meta = _get_secret_meta(locked)
if _plugin_object_identity_matches(old_meta, saved_meta):
return
try:
retrieve_plugin.delete_secret(old_meta)
except Exception:
# Best-effort: a completed migrate must not fail because the
# source plugin object could not be deleted.
LOG.warning(
'Failed to delete old plugin object for secret %s',
locked.id, exc_info=True)
def _store_secret_using_plugin(store_plugin, secret_dto, secret_model,
project_model):
project_model, crypto_plugin=None):
if isinstance(store_plugin, store_crypto.StoreCryptoAdapterPlugin):
context = store_crypto.StoreCryptoContext(
project_model,
secret_model=secret_model)
secret_model=secret_model,
crypto_plugin=crypto_plugin)
secret_metadata = store_plugin.store_secret(secret_dto, context)
else:
secret_metadata = store_plugin.store_secret(secret_dto)
return secret_metadata
def _resolve_plugins_for_store(secret_store_model):
store_plugin = secret_store.get_manager().get_plugin_by_name(
secret_store_model.store_plugin)
crypto_plugin = None
if secret_store_model.crypto_plugin:
try:
crypto_plugin = crypto_mgr.get_manager().get_plugin_by_name(
secret_store_model.crypto_plugin)
except crypto_base.CryptoPluginNotFound:
raise crypto_base.CryptoPluginNotFound(
u._('Crypto plugin "{name}" not found.').format(
name=secret_store_model.crypto_plugin))
return store_plugin, crypto_plugin
def _get_encrypted_datums(secret_model):
"""Return non-deleted EncryptedDatum rows for a secret."""
if not secret_model or not secret_model.encrypted_data:
return []
return [
datum for datum in secret_model.encrypted_data
if not getattr(datum, 'deleted', False)
]
def _secret_has_payload(secret_model):
"""Return True when the secret has a stored payload.
Two local signals are checked:
* Non-deleted ``EncryptedDatum`` rows — used by store_crypto, which
keeps ciphertext in the Barbican database.
* Non-empty **secret-store** (plugin) metadata from
``_get_secret_meta`` / ``SecretStoreMetadatum`` — used by external
plugins (KMIP, Vault, …) that keep the payload on the backend and
only persist plugin object keys locally (for example
``plugin_name`` and a remote object id). Those rows are written
when the payload is stored, not for user-defined secret metadata.
User-provided metadata (``SecretUserMetadatum``, the
``/v1/secrets/{id}/metadata`` API) is a different table and is not
consulted here.
"""
if _get_encrypted_datums(secret_model):
return True
return bool(_get_secret_meta(secret_model))
def _secret_is_already_on_target(secret_model, store_plugin, crypto_plugin):
metadata = _get_secret_meta(secret_model)
target_name = utils.generate_fullname_for(store_plugin)
if metadata.get('plugin_name') != target_name:
return False
if crypto_plugin is None:
return True
if not isinstance(store_plugin,
store_crypto.StoreCryptoAdapterPlugin):
return True
datums = _get_encrypted_datums(secret_model)
if not datums or not datums[0].kek_meta_project:
return True
return (datums[0].kek_meta_project.plugin_name ==
utils.generate_fullname_for(crypto_plugin))
def _plugin_object_identity_matches(old_meta, new_meta):
"""Return True when old/new metadata name the same plugin object."""
if not old_meta or not new_meta:
return False
old_ids = {k: v for k, v in old_meta.items()
if k not in _PLUGIN_META_KEYS}
new_ids = {k: v for k, v in new_meta.items()
if k not in _PLUGIN_META_KEYS}
# store_crypto metadata is only plugin_name + content_type.
if not old_ids and not new_ids:
return True
return old_ids == new_ids
def _rollback_failed_migrate(store_plugin, secret_model, new_meta,
old_datum_ids):
if new_meta:
try:
store_plugin.delete_secret(new_meta)
except Exception:
# Best-effort: do not replace the original migrate error.
LOG.warning(
'Failed to clean up new plugin object for secret %s',
secret_model.id, exc_info=True)
for datum in _get_encrypted_datums(secret_model):
if datum.id in old_datum_ids:
continue
try:
datum.delete()
except Exception:
# Best-effort: do not replace the original migrate error.
LOG.warning(
'Failed to clean up new encrypted datum %s',
datum.id, exc_info=True)
def _generate_symmetric_key(
generate_plugin, key_spec, secret_model, project_model, content_type):
if isinstance(generate_plugin, store_crypto.StoreCryptoAdapterPlugin):
@@ -339,14 +613,19 @@ def _save_secret_metadata_in_repo(secret_model, secret_metadata,
store_plugin, content_type):
"""Add secret metadata to a secret."""
if not secret_metadata:
secret_metadata = {}
secret_metadata['plugin_name'] = utils.generate_fullname_for(store_plugin)
secret_metadata['content_type'] = content_type
to_save = {
key: value for key, value in (secret_metadata or {}).items()
if value is not None
}
to_save['plugin_name'] = utils.generate_fullname_for(store_plugin)
# SecretStoreMetadatumRepo.save skips None. Persist a MIME type so
# GET payload can still resolve content_type later.
to_save['content_type'] = (
content_type or 'application/octet-stream'
)
secret_meta_repo = repos.get_secret_meta_repository()
secret_meta_repo.save(secret_metadata, secret_model)
secret_meta_repo.save(to_save, secret_model)
def _save_secret_in_repo(secret_model, project_model):
+19 -6
View File
@@ -38,13 +38,15 @@ class StoreCryptoContext(object):
private_secret_model=None,
public_secret_model=None,
passphrase_secret_model=None,
content_type=None):
content_type=None,
crypto_plugin=None):
self.secret_model = secret_model
self.private_secret_model = private_secret_model
self.public_secret_model = public_secret_model
self.passphrase_secret_model = passphrase_secret_model
self.project_model = project_model
self.content_type = content_type
self.crypto_plugin = crypto_plugin
class StoreCryptoAdapterPlugin(object):
@@ -73,10 +75,15 @@ class StoreCryptoAdapterPlugin(object):
"""
# Find HSM-style 'crypto' plugin.
encrypting_plugin = manager.get_manager().get_plugin_store_generate(
base.PluginSupportTypes.ENCRYPT_DECRYPT,
project_id=context.project_model.id
)
if context.crypto_plugin:
encrypting_plugin = context.crypto_plugin
else:
encrypting_plugin = (
manager.get_manager().get_plugin_store_generate(
base.PluginSupportTypes.ENCRYPT_DECRYPT,
project_id=context.project_model.id
)
)
# Find or create a key encryption key metadata.
kek_datum_model, kek_meta_dto = _find_or_create_kek_objects(
@@ -116,7 +123,13 @@ class StoreCryptoAdapterPlugin(object):
raise sstore.SecretNotFoundException()
# TODO(john-wood-w) Need to revisit 1 to many datum relationship.
datum_model = context.secret_model.encrypted_data[0]
datums = [
datum for datum in context.secret_model.encrypted_data
if not getattr(datum, 'deleted', False)
]
if not datums:
raise sstore.SecretNotFoundException()
datum_model = datums[0]
# Find HSM-style 'crypto' plugin.
decrypting_plugin = manager.get_manager().get_plugin_retrieve(
@@ -414,6 +414,9 @@ class WhenTestingSecretConsumersResource(utils.BarbicanAPIBaseTestCase):
def setUp(self):
super(WhenTestingSecretConsumersResource, self).setUp()
# Consumers landed in microversion 1.1; POST/DELETE return a
# secret body shaped by that version (includes consumers).
utils.set_version(self.app, '1.1')
self.consumer_a = {
"service": "service_a",
@@ -0,0 +1,500 @@
# 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 unittest import mock
from oslo_utils import uuidutils
from barbican.api import controllers
from barbican.common import config
from barbican.common import exception
from barbican import context
from barbican.model import models
from barbican.model import repositories as repos
from barbican.plugin import resources as plugin
from barbican.tests.api.controllers import test_secrets
from barbican.tests.api.controllers.test_secretstores import SecretStoresMixin
from barbican.tests.api import test_resources_policy as test_policy
from barbican.tests import utils
class WhenTestingSecretMigrateStore(utils.BarbicanAPIBaseTestCase):
def setUp(self):
super(WhenTestingSecretMigrateStore, self).setUp()
utils.set_version(self.app, '1.3')
self.secret_stores_repo = repos.get_secret_stores_repository()
def _create_store(self, name=None):
suffix = uuidutils.generate_uuid()
name = name or ('store-' + suffix)
store = models.SecretStores(
name=name,
store_plugin='plugin-' + suffix)
return self.secret_stores_repo.create_from(store)
def _create_secret_with_payload(self):
resp, secret_uuid = test_secrets.create_secret(
self.app,
payload='migrate-me',
content_type='text/plain')
self.assertEqual(201, resp.status_int)
return secret_uuid
def _put_migrate(self, secret_uuid, store_id, expect_errors=False,
multiple_backends=True, headers=None):
with mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=multiple_backends):
return self.app.put(
'/secrets/{0}/secret-store/{1}'.format(
secret_uuid, store_id),
headers=headers,
expect_errors=expect_errors)
def _owner_from_rewrap(self, mock_rewrap):
captured = {}
def _capture(secret, project, store):
captured['external_id'] = project.external_id
mock_rewrap.side_effect = _capture
return captured
@mock.patch('barbican.plugin.resources.rewrap_secret', autospec=True)
def test_migrate_with_store_id_returns_204(self, mock_rewrap):
captured = self._owner_from_rewrap(mock_rewrap)
secret_uuid = self._create_secret_with_payload()
store = self._create_store()
resp = self._put_migrate(secret_uuid, store.id)
self.assertEqual(204, resp.status_int)
self.assertTrue(mock_rewrap.called)
self.assertEqual(self.project_id, captured['external_id'])
@mock.patch('barbican.plugin.resources.rewrap_secret', autospec=True)
def test_migrate_uses_secret_owner_project_not_token(
self, mock_rewrap):
captured = self._owner_from_rewrap(mock_rewrap)
secret_uuid = self._create_secret_with_payload()
store = self._create_store()
other_project = utils.generate_test_valid_uuid()
self.app.extra_environ['barbican.context'] = self._build_context(
other_project)
resp = self._put_migrate(secret_uuid, store.id)
self.assertEqual(204, resp.status_int)
self.assertEqual(self.project_id, captured['external_id'])
self.assertNotEqual(other_project, captured['external_id'])
@mock.patch('barbican.plugin.resources.rewrap_secret', autospec=True)
def test_migrate_accepts_any_accept_header(self, mock_rewrap):
# Empty 204 responses still go through Accept negotiation; allow all
# content types so clients sending text/plain (or other) do not 406.
secret_uuid = self._create_secret_with_payload()
store = self._create_store()
resp = self._put_migrate(
secret_uuid, store.id,
headers={'Accept': 'text/plain'})
self.assertEqual(204, resp.status_int)
self.assertTrue(mock_rewrap.called)
def test_migrate_with_invalid_uuid_returns_400(self):
secret_uuid = self._create_secret_with_payload()
resp = self._put_migrate(
secret_uuid, 'not-a-uuid', expect_errors=True)
self.assertEqual(400, resp.status_int)
def test_migrate_with_nil_uuid_returns_404(self):
secret_uuid = self._create_secret_with_payload()
resp = self._put_migrate(
secret_uuid,
'00000000-0000-0000-0000-000000000000',
expect_errors=True)
self.assertEqual(404, resp.status_int)
def test_migrate_with_unknown_store_returns_404(self):
secret_uuid = self._create_secret_with_payload()
resp = self._put_migrate(
secret_uuid,
uuidutils.generate_uuid(),
expect_errors=True)
self.assertEqual(404, resp.status_int)
def test_migrate_without_multiple_backends_returns_400(self):
secret_uuid = self._create_secret_with_payload()
store = self._create_store()
resp = self._put_migrate(
secret_uuid,
store.id,
expect_errors=True,
multiple_backends=False)
self.assertEqual(400, resp.status_int)
@mock.patch(
'barbican.plugin.resources.rewrap_secret',
autospec=True,
side_effect=exception.SecretPayloadNotFound())
def test_migrate_metadata_only_secret_returns_400(self, _mock_rewrap):
secret_uuid = self._create_secret_with_payload()
store = self._create_store()
resp = self._put_migrate(
secret_uuid,
store.id,
expect_errors=True)
self.assertEqual(400, resp.status_int)
def test_migrate_without_microversion_returns_404(self):
utils.set_version(self.app, '1.2')
secret_uuid = self._create_secret_with_payload()
store = self._create_store()
resp = self._put_migrate(
secret_uuid,
store.id,
expect_errors=True)
self.assertEqual(404, resp.status_int)
def test_migrate_without_store_id_returns_404(self):
secret_uuid = self._create_secret_with_payload()
with mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=True):
resp = self.app.put(
'/secrets/{0}/secret-store'.format(secret_uuid),
expect_errors=True)
self.assertEqual(404, resp.status_int)
class WhenTestingComputedSecretStoreFields(utils.BarbicanAPIBaseTestCase):
def setUp(self):
super(WhenTestingComputedSecretStoreFields, self).setUp()
utils.set_version(self.app, '1.3')
def _get_secret(self, secret_uuid, multiple_backends=True,
expect_errors=False):
with mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=multiple_backends):
return self.app.get(
'/secrets/{0}'.format(secret_uuid),
expect_errors=expect_errors)
def test_get_includes_null_store_fields_when_backends_disabled(self):
resp, secret_uuid = test_secrets.create_secret(
self.app, payload='payload', content_type='text/plain')
self.assertEqual(201, resp.status_int)
resp = self._get_secret(secret_uuid, multiple_backends=False)
self.assertEqual(200, resp.status_int)
self.assertIn('secret_store_id', resp.json)
self.assertIn('secret_store_ref', resp.json)
self.assertIsNone(resp.json['secret_store_id'])
self.assertIsNone(resp.json['secret_store_ref'])
def test_get_includes_null_store_fields_for_metadata_only_secret(self):
resp, secret_uuid = test_secrets.create_secret(
self.app, name='metadata-only')
self.assertEqual(201, resp.status_int)
resp = self._get_secret(secret_uuid, multiple_backends=True)
self.assertEqual(200, resp.status_int)
self.assertIsNone(resp.json['secret_store_id'])
self.assertIsNone(resp.json['secret_store_ref'])
@mock.patch(
'barbican.plugin.resources.resolve_secret_store_for_secret',
autospec=True)
def test_get_includes_resolved_store_fields(self, mock_resolve):
store = mock.MagicMock()
store.id = '93869b0f-60eb-4830-adb9-e2f7154a080b'
mock_resolve.return_value = store
resp, secret_uuid = test_secrets.create_secret(
self.app, payload='payload', content_type='text/plain')
self.assertEqual(201, resp.status_int)
resp = self._get_secret(secret_uuid, multiple_backends=True)
self.assertEqual(200, resp.status_int)
self.assertEqual(store.id, resp.json['secret_store_id'])
self.assertIn(store.id, resp.json['secret_store_ref'])
@mock.patch(
'barbican.plugin.resources.resolve_secret_store_for_secret',
autospec=True,
side_effect=exception.SecretStoreNotResolved(
secret_id='ignored'))
def test_get_returns_500_when_store_unresolved(self, _mock_resolve):
resp, secret_uuid = test_secrets.create_secret(
self.app, payload='payload', content_type='text/plain')
self.assertEqual(201, resp.status_int)
resp = self._get_secret(
secret_uuid, multiple_backends=True, expect_errors=True)
self.assertEqual(500, resp.status_int)
def test_get_omits_store_fields_on_microversion_1_2(self):
utils.set_version(self.app, '1.2')
resp, secret_uuid = test_secrets.create_secret(
self.app, payload='payload', content_type='text/plain')
self.assertEqual(201, resp.status_int)
resp = self._get_secret(secret_uuid, multiple_backends=True)
self.assertEqual(200, resp.status_int)
self.assertNotIn('secret_store_id', resp.json)
self.assertNotIn('secret_store_ref', resp.json)
def test_list_includes_store_fields(self):
resp, secret_uuid = test_secrets.create_secret(
self.app, payload='payload', content_type='text/plain')
self.assertEqual(201, resp.status_int)
with mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=False):
resp = self.app.get('/secrets/')
self.assertEqual(200, resp.status_int)
listed = resp.json['secrets']
self.assertGreater(len(listed), 0)
self.assertIn('secret_store_id', listed[0])
self.assertIn('secret_store_ref', listed[0])
self.assertIsNone(listed[0]['secret_store_id'])
def test_consumer_post_includes_store_fields(self):
resp, secret_uuid = test_secrets.create_secret(
self.app, payload='payload', content_type='text/plain')
self.assertEqual(201, resp.status_int)
with mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=False):
consumer_resp, _ = test_secrets.create_secret_consumer(
self.app,
secret_id=secret_uuid,
service='cinder',
resource_type='volume',
resource_id='vol-1')
self.assertEqual(200, consumer_resp.status_int)
self.assertIn('secret_store_id', consumer_resp.json)
self.assertIn('secret_store_ref', consumer_resp.json)
self.assertIsNone(consumer_resp.json['secret_store_id'])
self.assertIn('consumers', consumer_resp.json)
class WhenTestingEffectiveSecretStoreId(utils.BaseTestCase):
def test_returns_project_preferred_store_id(self):
project = mock.MagicMock()
project.id = 'proj-id'
preferred = mock.MagicMock()
preferred.id = 'preferred-uuid'
project_store = mock.MagicMock()
project_store.secret_store = preferred
proj_repo = mock.MagicMock()
proj_repo.get_secret_store_for_project.return_value = project_store
with mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=True), mock.patch(
'barbican.plugin.resources.repos.'
'get_project_secret_store_repository',
return_value=proj_repo):
store_id = plugin.get_effective_secret_store_id_for_project(
project)
self.assertEqual('preferred-uuid', store_id)
def test_falls_back_to_global_default_store_id(self):
project = mock.MagicMock()
project.id = 'proj-id'
proj_repo = mock.MagicMock()
proj_repo.get_secret_store_for_project.return_value = None
default_store = mock.MagicMock()
default_store.id = 'global-default-uuid'
with mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=True), mock.patch(
'barbican.plugin.resources.repos.'
'get_project_secret_store_repository',
return_value=proj_repo), mock.patch(
'barbican.plugin.util.multiple_backends.'
'get_global_default_secret_store',
return_value=default_store):
store_id = plugin.get_effective_secret_store_id_for_project(
project)
self.assertEqual('global-default-uuid', store_id)
def test_returns_none_without_preferred_or_global_default(self):
project = mock.MagicMock()
project.id = 'proj-id'
proj_repo = mock.MagicMock()
proj_repo.get_secret_store_for_project.return_value = None
with mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=True), mock.patch(
'barbican.plugin.resources.repos.'
'get_project_secret_store_repository',
return_value=proj_repo), mock.patch(
'barbican.plugin.util.multiple_backends.'
'get_global_default_secret_store',
return_value=None):
store_id = plugin.get_effective_secret_store_id_for_project(
project)
self.assertIsNone(store_id)
def test_returns_none_when_multiple_backends_disabled(self):
project = mock.MagicMock()
with mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=False):
store_id = plugin.get_effective_secret_store_id_for_project(
project)
self.assertIsNone(store_id)
class WhenTestingCredentialsForPolicy(utils.BaseTestCase):
def test_migrate_copies_path_store_id_onto_creds(self):
ctx = context.RequestContext(
policy_enforcer=mock.Mock(),
user_id='user-1',
project_id='proj-1',
roles=['member'])
dest = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
creds = controllers._credentials_for_policy(
ctx, 'secret:migrate_secretstore',
{'req_secret_store_id': dest})
self.assertEqual(dest, creds['secret_store_id'])
self.assertIsNot(creds, ctx)
self.assertNotIn('secret_store_id', ctx.to_policy_values())
def test_other_actions_leave_context_unchanged(self):
ctx = context.RequestContext(
policy_enforcer=mock.Mock(),
user_id='user-1',
project_id='proj-1',
roles=['member'])
creds = controllers._credentials_for_policy(
ctx, 'secret:get',
{'req_secret_store_id': 'ignored'})
self.assertIs(creds, ctx)
class WhenTestingSecretMigrateMemberPolicy(
utils.BarbicanAPIBaseTestCase,
SecretStoresMixin,
test_policy.BaseTestCase):
def setUp(self):
super(WhenTestingSecretMigrateMemberPolicy, self).setUp()
config.CONF.set_override(
'enforce_new_defaults', True, group='oslo_policy')
utils.set_version(self.app, '1.3')
self.secret_stores_repo = repos.get_secret_stores_repository()
self.proj_store_repo = repos.get_project_secret_store_repository()
def _create_secret_for_member_policy(self):
return test_secrets.create_secret(
self.app,
payload='migrate-me',
content_type='text/plain')[1]
def _enable_multiple_backends(self):
self._init_multiple_backends(global_default_index=1)
self.secret_stores_repo = repos.get_secret_stores_repository()
def _member_environ(self):
return {
'barbican.context': self._build_context(
self.project_id,
roles=['member'],
user_id='member-user',
is_admin=False,
policy_enforcer=self.policy_enforcer),
'key-manager.microversion': '1.3',
}
def _put_migrate_as_member(self, secret_uuid, store_id,
expect_errors=False):
self.app.extra_environ = self._member_environ()
return self.app.put(
'/secrets/{0}/secret-store/{1}'.format(secret_uuid, store_id),
expect_errors=expect_errors)
@mock.patch('barbican.plugin.resources.rewrap_secret', autospec=True)
def test_member_migrate_to_preferred_store_returns_204(
self, mock_rewrap):
secret_uuid = self._create_secret_for_member_policy()
self._enable_multiple_backends()
stores = self.secret_stores_repo.get_all()
preferred_id = stores[0].id
other_id = stores[2].id
project = repos.get_secret_repository().get(
secret_uuid, self.project_id).project
self._create_project_store(project.id, preferred_id)
resp = self._put_migrate_as_member(secret_uuid, preferred_id)
self.assertEqual(204, resp.status_int)
self.assertTrue(mock_rewrap.called)
resp = self._put_migrate_as_member(
secret_uuid, other_id, expect_errors=True)
self.assertEqual(403, resp.status_int)
@mock.patch('barbican.plugin.resources.rewrap_secret', autospec=True)
def test_member_migrate_to_global_default_when_no_preferred(
self, mock_rewrap):
secret_uuid = self._create_secret_for_member_policy()
self._enable_multiple_backends()
stores = self.secret_stores_repo.get_all()
global_default_id = next(s.id for s in stores if s.global_default)
other_id = next(s.id for s in stores if not s.global_default)
resp = self._put_migrate_as_member(secret_uuid, global_default_id)
self.assertEqual(204, resp.status_int)
resp = self._put_migrate_as_member(
secret_uuid, other_id, expect_errors=True)
self.assertEqual(403, resp.status_int)
@mock.patch('barbican.plugin.resources.rewrap_secret', autospec=True)
def test_member_migrate_denied_when_no_preferred_or_default(
self, _mock_rewrap):
secret_uuid = self._create_secret_for_member_policy()
self._enable_multiple_backends()
store = self.secret_stores_repo.get_all()[0]
with mock.patch(
'barbican.plugin.resources.'
'get_effective_secret_store_id_for_project',
autospec=True,
return_value=None):
resp = self._put_migrate_as_member(
secret_uuid, store.id, expect_errors=True)
self.assertEqual(403, resp.status_int)
@@ -88,6 +88,23 @@ class WhenTestingSecretStores(utils.BarbicanAPIBaseTestCase,
self.assertIsNotNone(secret_data['updated'])
self.assertEqual(models.States.ACTIVE, secret_data['status'])
def test_should_get_all_secret_stores_with_id_on_microversion_1_3(self):
g_index = 2
self._init_multiple_backends(global_default_index=g_index)
utils.set_version(self.app, '1.3')
resp = self.app.get('/secret-stores', expect_errors=False)
self.assertEqual(200, resp.status_int)
secret_stores_data = resp.json.get('secret_stores')
self.assertEqual(3, len(secret_stores_data))
for secret_data in secret_stores_data:
store_id = secret_data.get('secret_store_id')
self.assertIsNotNone(store_id)
self.assertIn(store_id, secret_data['secret_store_ref'])
self.assertIsNone(secret_data.get('id'))
def test_get_all_secret_stores_when_multiple_backends_not_enabled(self):
self._init_multiple_backends(enabled=False)
@@ -120,12 +137,28 @@ class WhenTestingSecretStores(utils.BarbicanAPIBaseTestCase,
self.assertTrue(resp_data['global_default'])
self.assertIn('kmip', resp_data['name'].lower())
self.assertIsNotNone(resp_data['secret_store_ref'])
self.assertIsNone(resp_data.get('secret_store_id'))
self.assertIsNotNone(resp_data['secret_store_plugin'])
self.assertIsNone(resp_data['crypto_plugin'])
self.assertIsNotNone(resp_data['created'])
self.assertIsNotNone(resp_data['updated'])
self.assertEqual(models.States.ACTIVE, resp_data['status'])
def test_should_get_global_default_with_id_on_microversion_1_3(self):
self._init_multiple_backends(global_default_index=1)
utils.set_version(self.app, '1.3')
stores = self.secret_store_repo.get_all()
expected_id = stores[1].id
resp = self.app.get('/secret-stores/global-default',
expect_errors=False)
self.assertEqual(200, resp.status_int)
resp_data = resp.json
self.assertTrue(resp_data['global_default'])
self.assertEqual(expected_id, resp_data.get('secret_store_id'))
self.assertIn(expected_id, resp_data['secret_store_ref'])
def test_get_global_default_when_multiple_backends_not_enabled(self):
self._init_multiple_backends(enabled=False)
@@ -159,10 +192,30 @@ class WhenTestingSecretStores(utils.BarbicanAPIBaseTestCase,
resp_data['global_default'])
self.assertIn('/secret-stores/{0}'.format(secret_stores[0].id),
resp_data['secret_store_ref'])
self.assertIsNone(resp_data.get('secret_store_id'))
self.assertIsNotNone(resp_data['created'])
self.assertIsNotNone(resp_data['updated'])
self.assertEqual(models.States.ACTIVE, resp_data['status'])
def test_get_preferred_with_id_on_microversion_1_3(self):
self._init_multiple_backends(global_default_index=1)
secret_stores = self.secret_store_repo.get_all()
project1 = self._create_project()
self._create_project_store(project1.id, secret_stores[0].id)
self.app.extra_environ = {
'barbican.context': self._build_context(project1.external_id)
}
utils.set_version(self.app, '1.3')
resp = self.app.get('/secret-stores/preferred',
expect_errors=False)
self.assertEqual(200, resp.status_int)
resp_data = resp.json
self.assertEqual(secret_stores[0].id,
resp_data.get('secret_store_id'))
self.assertIn(secret_stores[0].id, resp_data['secret_store_ref'])
def test_get_preferred_when_preferred_is_not_set(self):
self._init_multiple_backends(global_default_index=1)
project1 = self._create_project()
@@ -211,11 +264,25 @@ class WhenTestingSecretStore(utils.BarbicanAPIBaseTestCase,
self.assertEqual(store.name, data['name'])
self.assertIn('/secret-stores/{0}'.format(store.id),
data['secret_store_ref'])
self.assertIsNone(data.get('secret_store_id'))
self.assertIsNotNone(data['secret_store_plugin'])
self.assertIsNotNone(data['created'])
self.assertIsNotNone(data['updated'])
self.assertEqual(models.States.ACTIVE, data['status'])
def test_get_a_secret_store_with_id_on_microversion_1_3(self):
self._init_multiple_backends()
utils.set_version(self.app, '1.3')
store = self.secret_store_repo.get_all()[0]
resp = self.app.get('/secret-stores/{0}'.format(store.id),
expect_errors=False)
self.assertEqual(200, resp.status_int)
data = resp.json
self.assertEqual(store.id, data.get('secret_store_id'))
self.assertIn(store.id, data['secret_store_ref'])
def test_invalid_uri_for_secret_stores_subresource(self):
self._init_multiple_backends()
+149 -1
View File
@@ -84,6 +84,10 @@ class SecretResource(TestableResource):
controller_cls = secrets.SecretController
class SecretMigrateStoreResource(TestableResource):
controller_cls = secrets.SecretMigrateStoreController
class OrdersResource(TestableResource):
controller_cls = orders.OrdersController
@@ -1290,7 +1294,7 @@ class WhenTestingSecretStoreResource(BaseTestCase):
self.setup_project_repository_mock(self.project_repo)
secret_store_res = mock.MagicMock()
secret_store_res.to_dict_fields = mock.MagicMock(side_effect=IOError)
secret_store_res.to_api_dict = mock.MagicMock(side_effect=IOError)
secret_store_res.id = self.store_id
self.resource = SecretStoreResource(secret_store_res)
@@ -1457,3 +1461,147 @@ class WhenTestingSecretConsumerResource(BaseTestCase):
def _invoke_on_get(self):
self.resource.on_get(self.req, self.resp)
class WhenTestingSecretMigrateStoreResource(BaseTestCase):
"""RBAC tests for SecretMigrateStoreController."""
def setUp(self):
super(WhenTestingSecretMigrateStoreResource, self).setUp()
self.external_project_id = '12345project'
self.secret_id = '12345secret'
self.store_id = '93869b0f-60eb-4830-adb9-e2f7154a080b'
self.user_id = '123456user'
self.creator_user_id = '123456CreatorUser'
self.mock_enable_patcher = mock.patch(
'barbican.common.utils.is_multiple_backends_enabled',
autospec=True,
return_value=True)
self.mock_enable_patcher.start()
self.addCleanup(self.mock_enable_patcher.stop)
config.CONF.set_override(
'enforce_new_defaults', True, group='oslo_policy')
self.resolve_store_patcher = mock.patch(
'barbican.plugin.resources.resolve_secret_store_for_secret',
autospec=True,
return_value=None)
self.resolve_store_patcher.start()
self.addCleanup(self.resolve_store_patcher.stop)
self.preferred_store_patcher = mock.patch(
'barbican.plugin.resources.'
'get_effective_secret_store_id_for_project',
autospec=True,
return_value=None)
self.preferred_store_patcher.start()
self.addCleanup(self.preferred_store_patcher.stop)
# Force an error on PUT calls that pass RBAC; business logic is tested
# elsewhere.
self.secret_stores_repo = mock.MagicMock()
fail_method = mock.MagicMock(
return_value=None,
side_effect=self._generate_get_error())
self.secret_stores_repo.get = fail_method
self.setup_secret_stores_repository_mock(self.secret_stores_repo)
secret = mock.MagicMock()
secret.id = self.secret_id
secret.project.external_id = self.external_project_id
secret.creator_id = self.creator_user_id
self.resource = SecretMigrateStoreResource(secret, self.store_id)
def _set_preferred_store_id(self, preferred_store_id):
self.preferred_store_patcher.stop()
self.preferred_store_patcher = mock.patch(
'barbican.plugin.resources.'
'get_effective_secret_store_id_for_project',
autospec=True,
return_value=preferred_store_id)
self.preferred_store_patcher.start()
def test_admin_passes_migrate_to_preferred_store(self):
self._set_preferred_store_id(self.store_id)
self._assert_pass_rbac(
['admin'],
self._invoke_on_put,
user_id=self.user_id,
project_id=self.external_project_id)
def test_admin_passes_migrate_to_global_default_store(self):
global_default_id = '77777777-7777-7777-7777-777777777777'
self._set_preferred_store_id(global_default_id)
self.resource = SecretMigrateStoreResource(
self.resource.controller.secret, global_default_id)
self._assert_pass_rbac(
['admin'],
self._invoke_on_put,
user_id=self.user_id,
project_id=self.external_project_id)
def test_admin_passes_migrate_to_non_preferred_store(self):
other_store_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
self._set_preferred_store_id(self.store_id)
self.resource = SecretMigrateStoreResource(
self.resource.controller.secret, other_store_id)
self._assert_pass_rbac(
['admin'],
self._invoke_on_put,
user_id=self.user_id,
project_id=self.external_project_id)
def test_member_passes_migrate_to_preferred_store(self):
self._set_preferred_store_id(self.store_id)
self._assert_pass_rbac(
['member'],
self._invoke_on_put,
user_id=self.user_id,
project_id=self.external_project_id)
def test_member_passes_migrate_to_global_default_store(self):
"""Member may migrate when helper resolves global default as target."""
global_default_id = '77777777-7777-7777-7777-777777777777'
self._set_preferred_store_id(global_default_id)
self.resource = SecretMigrateStoreResource(
self.resource.controller.secret, global_default_id)
self._assert_pass_rbac(
['member'],
self._invoke_on_put,
user_id=self.user_id,
project_id=self.external_project_id)
def test_member_fails_migrate_to_non_preferred_store(self):
other_store_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
self._set_preferred_store_id(self.store_id)
self.resource = SecretMigrateStoreResource(
self.resource.controller.secret, other_store_id)
self._assert_fail_rbac(
['member'],
self._invoke_on_put,
user_id=self.user_id,
project_id=self.external_project_id)
def test_member_fails_migrate_when_no_preferred_or_default(self):
self._set_preferred_store_id(None)
self._assert_fail_rbac(
['member'],
self._invoke_on_put,
user_id=self.user_id,
project_id=self.external_project_id)
def test_should_raise_migrate_secretstore(self):
# Preferred store is unset in setUp, so member is denied here too.
# Stick to SRBAC roles (admin/member/reader); legacy creator/observer/
# audit coverage is being removed upstream.
self._assert_fail_rbac(
[None, 'reader', 'member', 'bogus'],
self._invoke_on_put,
user_id=self.user_id,
project_id=self.external_project_id)
def _invoke_on_put(self):
self.resource.on_put(self.req, self.resp)
@@ -99,6 +99,22 @@ class WhenTestingSecretRepository(database_utils.RepositoryTestCase):
db_secret = self.repo.get_secret_by_id(secret.id)
self.assertIsNotNone(db_secret)
def test_get_secret_by_id_with_for_update(self):
session = self.repo.get_session()
project = models.Project()
project.external_id = "my keystone id"
project.save(session=session)
secret_model = models.Secret()
secret_model.project_id = project.id
secret = self.repo.create_from(secret_model, session=session)
session.commit()
db_secret = self.repo.get_secret_by_id(
secret.id, for_update=True)
self.assertEqual(secret.id, db_secret.id)
def test_should_raise_notfound_exception(self):
self.assertRaises(exception.NotFound, self.repo.get_secret_by_id,
"invalid_id", suppress_exception=False)
+37
View File
@@ -15,6 +15,7 @@
import datetime
import unittest
from unittest import mock
from oslo_utils import timeutils
@@ -703,6 +704,42 @@ class WhenCreatingNewSecretStores(utils.BaseTestCase):
ss.to_dict_fields()['status'])
self.assertEqual(name, ss.to_dict_fields()['name'])
@mock.patch(
'barbican.common.hrefs.convert_secret_stores_to_href',
autospec=True,
return_value='https://example/v1/secret-stores/ss-id')
def test_secret_stores_to_api_dict_hides_id_before_1_3(
self, mock_href):
ss = models.SecretStores(
'pkcs11 backend', 'store_crypto', 'p11_crypto',
global_default=True)
ss.id = 'ss-id'
data = ss.to_api_dict(microversion='1.2')
self.assertEqual('store_crypto', data['secret_store_plugin'])
self.assertNotIn('store_plugin', data)
self.assertNotIn('secret_store_id', data)
self.assertEqual(
'https://example/v1/secret-stores/ss-id',
data['secret_store_ref'])
mock_href.assert_called_once_with('ss-id')
@mock.patch(
'barbican.common.hrefs.convert_secret_stores_to_href',
autospec=True,
return_value='https://example/v1/secret-stores/ss-id')
def test_secret_stores_to_api_dict_keeps_id_at_1_3(self, mock_href):
ss = models.SecretStores(
'pkcs11 backend', 'store_crypto', 'p11_crypto',
global_default=True)
ss.id = 'ss-id'
data = ss.to_api_dict(microversion='1.3')
self.assertEqual('ss-id', data['secret_store_id'])
self.assertEqual('store_crypto', data['secret_store_plugin'])
self.assertNotIn('store_plugin', data)
self.assertEqual(
'https://example/v1/secret-stores/ss-id',
data['secret_store_ref'])
class WhenCreatingNewProjectSecretStore(utils.BaseTestCase):
def setUp(self):
@@ -100,6 +100,18 @@ class WhenTestingManager(utils.BaseTestCase):
self.manager.get_plugin_retrieve,
self.plugin_name)
def test_get_plugin_by_name(self):
self.plugin_loaded.name = 'simple_crypto'
self.assertEqual(
self.plugin_returned,
self.manager.get_plugin_by_name('simple_crypto'))
def test_get_plugin_by_name_not_found(self):
self.assertRaises(
base.CryptoPluginNotFound,
self.manager.get_plugin_by_name,
'missing-plugin')
def test_get_manager_with_multi_threads(self):
self.manager.extensions = []
self.manager = None
@@ -122,6 +122,22 @@ class WhenTestingSecretStorePluginManager(utils.BaseTestCase):
None, plugin_name=common_utils.generate_fullname_for(plugin))
self.assertEqual(plugin, plugin_found)
def test_get_plugin_by_name(self):
plugin = TestSecretStore([ss.KeyAlgorithm.AES])
plugin_mock = mock.Mock()
plugin_mock.obj = plugin
plugin_mock.name = 'store_crypto'
self.manager.extensions = [plugin_mock]
self.assertEqual(
plugin, self.manager.get_plugin_by_name('store_crypto'))
def test_get_plugin_by_name_not_found(self):
self.manager.extensions = []
self.assertRaises(
ss.SecretStorePluginNotFound,
self.manager.get_plugin_by_name,
'missing-plugin')
def test_get_generate_supported_plugin(self):
plugin = TestSecretStore([ss.KeyAlgorithm.AES])
plugin_mock = mock.MagicMock(obj=plugin)
+438
View File
@@ -15,9 +15,12 @@
import base64
from unittest import mock
from sqlalchemy import exc as sa_exc
import testtools
from barbican.common import exception
from barbican.model import models
from barbican.plugin.crypto import base as crypto_base
from barbican.plugin.interface import secret_store
from barbican.plugin import resources
from barbican.plugin import store_crypto
@@ -223,3 +226,438 @@ class WhenTestingPluginResource(testtools.TestCase,
self.secret_repo.delete_entity_by_id.assert_called_once_with(
entity_id=secret_model.id, external_project_id=project_id)
def _make_secret_store(self, store_plugin='kmip_plugin',
crypto_plugin=None):
secret_store_model = mock.MagicMock()
secret_store_model.id = 'ss-1'
secret_store_model.store_plugin = store_plugin
secret_store_model.crypto_plugin = crypto_plugin
return secret_store_model
def _make_secret(self, encrypted_data=None, secret_id='sid'):
secret_model = mock.MagicMock()
secret_model.id = secret_id
secret_model.encrypted_data = encrypted_data or []
secret_model.secret_store_metadata = {}
self.secret_repo.get_secret_by_id.return_value = secret_model
return secret_model
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_already_on_target_is_noop(self, mock_fullname):
mock_fullname.return_value = 'plugin.Full.Name'
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.secret_meta_repo.get_metadata_for_secret.return_value = {
'plugin_name': 'plugin.Full.Name',
'content_type': 'application/octet-stream',
}
secret_model = self._make_secret()
resources.rewrap_secret(
secret_model, self.project_model, self._make_secret_store())
self.secret_repo.get_secret_by_id.assert_called_once_with(
'sid', for_update=True)
self.moc_plugin.store_secret.assert_not_called()
self.moc_plugin.delete_secret.assert_not_called()
def test_rewrap_secret_without_payload_raises(self):
self.secret_meta_repo.get_metadata_for_secret.return_value = {}
secret_model = self._make_secret()
self.assertRaises(
exception.SecretPayloadNotFound,
resources.rewrap_secret,
secret_model,
self.project_model,
self._make_secret_store())
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_concurrent_second_migrate_is_noop(
self, mock_fullname):
"""After FOR UPDATE, a finished peer migrate makes this a no-op."""
mock_fullname.return_value = 'NewPlugin'
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
# Locked row already reflects the destination plugin.
self.secret_meta_repo.get_metadata_for_secret.return_value = {
'plugin_name': 'NewPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'already-moved',
}
secret_model = self._make_secret()
resources.rewrap_secret(
secret_model, self.project_model, self._make_secret_store())
self.secret_repo.get_secret_by_id.assert_called_once_with(
'sid', for_update=True)
self.moc_plugin.store_secret.assert_not_called()
self.moc_plugin.delete_secret.assert_not_called()
@mock.patch('barbican.plugin.resources._get_secret', autospec=True)
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_raises_when_decrypt_fails(
self, mock_fullname, mock_get):
mock_fullname.return_value = 'NewPlugin'
mock_get.side_effect = RuntimeError('decrypt failed')
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.secret_meta_repo.get_metadata_for_secret.return_value = {
'plugin_name': 'OldPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'old-obj',
}
secret_model = self._make_secret()
self.assertRaises(
RuntimeError,
resources.rewrap_secret,
secret_model,
self.project_model,
self._make_secret_store())
self.moc_plugin.store_secret.assert_not_called()
self.moc_plugin.delete_secret.assert_not_called()
@mock.patch('barbican.plugin.resources._get_secret', autospec=True)
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_raises_when_store_plugin_fails(
self, mock_fullname, mock_get):
mock_fullname.return_value = 'NewPlugin'
dto = secret_store.SecretDTO(
secret_store.SecretType.OPAQUE,
'c2VjcmV0',
secret_store.KeySpec(),
'application/octet-stream')
mock_get.return_value = dto
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.moc_plugin.store_secret.side_effect = (
exception.BarbicanException('store plugin failed'))
self.secret_meta_repo.get_metadata_for_secret.return_value = {
'plugin_name': 'OldPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'old-obj',
}
secret_model = self._make_secret()
self.assertRaises(
exception.BarbicanException,
resources.rewrap_secret,
secret_model,
self.project_model,
self._make_secret_store())
self.moc_plugin.store_secret.assert_called_once()
# Nothing was stored successfully, so no rollback delete and
# the source plugin object must remain.
self.moc_plugin.delete_secret.assert_not_called()
self.secret_meta_repo.delete_for_secret.assert_not_called()
@mock.patch('barbican.plugin.resources._get_secret', autospec=True)
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_stores_then_deletes_old_object(
self, mock_fullname, mock_get):
mock_fullname.return_value = 'NewPlugin'
dto = secret_store.SecretDTO(
secret_store.SecretType.OPAQUE,
'c2VjcmV0',
secret_store.KeySpec(),
'application/octet-stream')
mock_get.return_value = dto
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.moc_plugin.store_secret.return_value = {'secret_id': 'new-obj'}
old_meta = {
'plugin_name': 'OldPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'old-obj',
}
new_meta = {
'plugin_name': 'NewPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'new-obj',
}
# already-on-target check, current_meta, then saved_meta
self.secret_meta_repo.get_metadata_for_secret.side_effect = [
old_meta, old_meta, new_meta,
]
old_datum = mock.MagicMock()
old_datum.id = 'd1'
old_datum.deleted = False
secret_model = self._make_secret(encrypted_data=[old_datum])
resources.rewrap_secret(
secret_model, self.project_model, self._make_secret_store())
self.secret_repo.get_secret_by_id.assert_called_once_with(
'sid', for_update=True)
self.moc_plugin.store_secret.assert_called_once()
self.secret_meta_repo.delete_for_secret.assert_called_once_with('sid')
old_datum.delete.assert_called_once()
self.moc_plugin.delete_secret.assert_called_once_with({
'plugin_name': 'OldPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'old-obj',
})
@mock.patch('barbican.plugin.resources._get_secret', autospec=True)
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_skips_delete_when_object_identity_matches(
self, mock_fullname, mock_get):
mock_fullname.return_value = 'NewPlugin'
dto = secret_store.SecretDTO(
secret_store.SecretType.OPAQUE,
'c2VjcmV0',
secret_store.KeySpec(),
'application/octet-stream')
mock_get.return_value = dto
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.moc_plugin.store_secret.return_value = None
old_meta = {
'plugin_name': 'OldPlugin',
'content_type': 'application/octet-stream',
}
new_meta = {
'plugin_name': 'NewPlugin',
'content_type': 'application/octet-stream',
}
self.secret_meta_repo.get_metadata_for_secret.side_effect = [
old_meta, old_meta, new_meta,
]
old_datum = mock.MagicMock()
old_datum.id = 'd1'
old_datum.deleted = False
secret_model = self._make_secret(encrypted_data=[old_datum])
resources.rewrap_secret(
secret_model, self.project_model, self._make_secret_store())
self.moc_plugin.store_secret.assert_called_once()
self.moc_plugin.delete_secret.assert_not_called()
@mock.patch('barbican.plugin.resources._get_secret', autospec=True)
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_rolls_back_new_object_on_failure(
self, mock_fullname, mock_get):
mock_fullname.return_value = 'NewPlugin'
dto = secret_store.SecretDTO(
secret_store.SecretType.OPAQUE,
'c2VjcmV0',
secret_store.KeySpec(),
'application/octet-stream')
mock_get.return_value = dto
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.moc_plugin.store_secret.return_value = {'secret_id': 'new-obj'}
self.secret_meta_repo.get_metadata_for_secret.return_value = {
'plugin_name': 'OldPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'old-obj',
}
self.secret_meta_repo.save.side_effect = sa_exc.SQLAlchemyError(
'db fail')
secret_model = self._make_secret()
self.assertRaises(
sa_exc.SQLAlchemyError,
resources.rewrap_secret,
secret_model,
self.project_model,
self._make_secret_store())
self.moc_plugin.delete_secret.assert_called_once_with(
{'secret_id': 'new-obj'})
@mock.patch('barbican.plugin.resources._get_secret', autospec=True)
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_does_not_rollback_unexpected_errors(
self, mock_fullname, mock_get):
mock_fullname.return_value = 'NewPlugin'
dto = secret_store.SecretDTO(
secret_store.SecretType.OPAQUE,
'c2VjcmV0',
secret_store.KeySpec(),
'application/octet-stream')
mock_get.return_value = dto
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.moc_plugin.store_secret.return_value = {'secret_id': 'new-obj'}
self.secret_meta_repo.get_metadata_for_secret.return_value = {
'plugin_name': 'OldPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'old-obj',
}
self.secret_meta_repo.save.side_effect = RuntimeError('unexpected')
secret_model = self._make_secret()
self.assertRaises(
RuntimeError,
resources.rewrap_secret,
secret_model,
self.project_model,
self._make_secret_store())
self.moc_plugin.delete_secret.assert_not_called()
@mock.patch('barbican.plugin.resources.crypto_mgr.get_manager',
autospec=True)
def test_rewrap_secret_raises_crypto_plugin_not_found(
self, mock_crypto_mgr):
mock_crypto_mgr.return_value.get_plugin_by_name.side_effect = (
crypto_base.CryptoPluginNotFound())
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.secret_meta_repo.get_metadata_for_secret.return_value = {
'plugin_name': 'OldPlugin',
'content_type': 'application/octet-stream',
}
secret_model = self._make_secret()
self.assertRaises(
crypto_base.CryptoPluginNotFound,
resources.rewrap_secret,
secret_model,
self.project_model,
self._make_secret_store(crypto_plugin='missing_crypto'))
self.moc_plugin.store_secret.assert_not_called()
@mock.patch('barbican.plugin.resources._get_secret', autospec=True)
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_drops_none_metadata_and_defaults_content_type(
self, mock_fullname, mock_get):
"""store_crypto returns None; k8s retrieve may omit content_type."""
mock_fullname.return_value = 'NewPlugin'
dto = secret_store.SecretDTO(
secret_store.SecretType.OPAQUE,
'c2VjcmV0',
secret_store.KeySpec(),
None)
mock_get.return_value = dto
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.moc_plugin.store_secret.return_value = {
'secret_id': 'new-obj',
'optional': None,
}
old_meta = {
'plugin_name': 'K8sSecretStore',
'namespace': 'barbican-secrets',
'secret_name': 'barbican-secret-1',
}
new_meta = {
'plugin_name': 'NewPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'new-obj',
}
# payload check (no datums), already-on-target,
# current_meta, then saved_meta
self.secret_meta_repo.get_metadata_for_secret.side_effect = [
old_meta, old_meta, old_meta, new_meta,
]
secret_model = self._make_secret()
resources.rewrap_secret(
secret_model, self.project_model, self._make_secret_store())
saved = self.secret_meta_repo.save.call_args[0][0]
self.assertNotIn(None, saved.values())
self.assertEqual('application/octet-stream', saved['content_type'])
self.assertEqual('new-obj', saved['secret_id'])
self.assertNotIn('optional', saved)
self.secret_meta_repo.delete_for_secret.assert_called_once_with('sid')
@mock.patch('barbican.plugin.resources._get_secret', autospec=True)
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_kmip_style_metadata(self, mock_fullname, mock_get):
"""KMIP keeps the payload remotely; local DB has object metadata."""
mock_fullname.return_value = 'NewPlugin'
dto = secret_store.SecretDTO(
secret_store.SecretType.OPAQUE,
'c2VjcmV0',
secret_store.KeySpec(),
'application/octet-stream')
mock_get.return_value = dto
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.moc_plugin.store_secret.return_value = {
'secret_id': 'kmip-new',
}
old_meta = {
'plugin_name': 'KMIPSecretStore',
'content_type': 'application/octet-stream',
'secret_id': 'kmip-old',
}
new_meta = {
'plugin_name': 'NewPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'kmip-new',
}
# payload check (no datums), already-on-target,
# current_meta, then saved_meta
self.secret_meta_repo.get_metadata_for_secret.side_effect = [
old_meta, old_meta, old_meta, new_meta,
]
secret_model = self._make_secret()
resources.rewrap_secret(
secret_model, self.project_model,
self._make_secret_store(store_plugin='kmip_plugin'))
self.moc_plugin.store_secret.assert_called_once()
self.moc_plugin.delete_secret.assert_called_once_with(old_meta)
@mock.patch('barbican.plugin.resources._get_secret', autospec=True)
@mock.patch('barbican.plugin.resources.utils.generate_fullname_for',
autospec=True)
def test_rewrap_secret_vault_style_metadata(self, mock_fullname, mock_get):
"""Vault/OpenBao keep the payload remotely like KMIP."""
mock_fullname.return_value = 'NewPlugin'
dto = secret_store.SecretDTO(
secret_store.SecretType.OPAQUE,
'c2VjcmV0',
secret_store.KeySpec(),
'application/octet-stream')
mock_get.return_value = dto
self.moc_plugin_manager.return_value.get_plugin_by_name.return_value \
= self.moc_plugin
self.moc_plugin.store_secret.return_value = {
'secret_id': 'vault-new',
}
old_meta = {
'plugin_name': 'VaultSecretStore',
'content_type': 'application/octet-stream',
'secret_id': 'vault-old',
}
new_meta = {
'plugin_name': 'NewPlugin',
'content_type': 'application/octet-stream',
'secret_id': 'vault-new',
}
# payload check (no datums), already-on-target,
# current_meta, then saved_meta
self.secret_meta_repo.get_metadata_for_secret.side_effect = [
old_meta, old_meta, old_meta, new_meta,
]
secret_model = self._make_secret()
resources.rewrap_secret(
secret_model, self.project_model,
self._make_secret_store(store_plugin='vault_plugin'))
self.moc_plugin.store_secret.assert_called_once()
self.moc_plugin.delete_secret.assert_called_once_with(old_meta)
+11 -4
View File
@@ -32,10 +32,10 @@ from barbican.api import app
from barbican.common import config
import barbican.context
from barbican.model import repositories
from barbican.plugin.crypto import manager as cm
from barbican.plugin.crypto import manager as crypto_manager
from barbican.plugin.crypto import p11_crypto
from barbican.plugin.crypto import simple_crypto
from barbican.plugin.interface import secret_store
from barbican.plugin.interface import secret_store as secret_store_plugin
from barbican.plugin import kmip_secret_store as kss
from barbican.tests import database_utils
from barbican.tests import fixture as barbican_fixture
@@ -90,6 +90,8 @@ class BarbicanAPIBaseTestCase(oslotest.BaseTestCase):
self.project_id = generate_test_valid_uuid()
self._setup_kek_conf()
secret_store_plugin._SECRET_STORE = None
crypto_manager._PLUGIN_MANAGER = None
# Build the test app
wsgi_app = app.build_wsgi_app(
controller=self.root_controller,
@@ -102,6 +104,11 @@ class BarbicanAPIBaseTestCase(oslotest.BaseTestCase):
}
def tearDown(self):
ss_conf = config.get_module_config('secretstore')
ss_conf.clear_override("enable_multiple_secret_stores",
group='secretstore')
secret_store_plugin._SECRET_STORE = None
crypto_manager._PLUGIN_MANAGER = None
database_utils.in_memory_cleanup()
super().tearDown()
@@ -584,8 +591,8 @@ class MultipleBackendsTestCase(database_utils.RepositoryTestCase):
multiple_support_enabled=enabled)
# clear globals if already set in previous tests
secret_store._SECRET_STORE = None # clear secret store manager
cm._PLUGIN_MANAGER = None # clear crypto manager
secret_store_plugin._SECRET_STORE = None # clear secret store manager
crypto_manager._PLUGIN_MANAGER = None # clear crypto manager
self._mock_plugin_settings()
def _get_secret_store_entry(self, store_plugin, crypto_plugin):
+1
View File
@@ -11,6 +11,7 @@ management of secrets.
access_control
barbican_manage
secret_store_migrate
database_cleaning
upgrade
pkcs11keygeneration
+114
View File
@@ -0,0 +1,114 @@
==========================================
Migrating secrets between secret stores
==========================================
Barbican can run with more than one secret store plugin (for example
software crypto, PKCS#11, and KMIP). Changing the global default or a
project preferred store only affects **new** secrets. Existing payloads
stay on the store that created them until they are migrated.
Use the migrate API to move a payload onto another configured store
**without** changing the secret UUID. Consuming services that already
hold the UUID do not need to be updated.
This is not the same as PKCS#11 MKEK rotation.
:command:`barbican-manage hsm rewrap_pkek` rewraps project KEKs after
you rotate keys on the **same** HSM. It does not move a secret to a
different backend.
When to migrate
===============
Migrate when you need the same secret UUID on a different backend, for
example:
* Moving project secrets from a central HSM onto a project-dedicated
HSM or KMIP server.
* Moving a subset of secrets from software crypto onto PKCS#11.
* Draining one backend so it can be removed.
Recreate (store a new secret and retarget consumers) when you cannot
enable multiple backends, or when the secret has no payload
(metadata-only secrets cannot be migrated).
Prerequisites
=============
* ``enable_multiple_secret_stores = True`` with at least two stores.
See :doc:`/configuration/plugin_backends`.
* A token that passes policy ``secret:migrate_secretstore``
(default: project ``admin`` to any store, or project ``member``
only to the project preferred store).
* The destination store listed by ``GET /v1/secret-stores``.
See :doc:`/api/reference/store_backends`.
The migrate API does not persist a ``secret_store_id`` on the secret
row. After a successful migrate, the payload is stored only on the
destination plugin. Secret metadata GET (microversion 1.3) always
returns computed ``secret_store_id`` / ``secret_store_ref`` (``null``
when multiple backends are disabled or the secret has no payload).
A live payload that cannot be mapped to exactly one catalogue store
is an error (HTTP 500).
Which tool to use
=================
OSC (one secret, tenant or operator)
------------------------------------
Project members and project administrators can migrate a single
secret::
$ openstack secret migrate \
<secret-uuid> \
--secret-store <store-uuid>
Members may migrate only onto the **project preferred** store (or the
global default when the project has no preferred store). Migrating to
any other backend returns HTTP **403**. Project **admins** may target
any configured store.
This requires python-barbicanclient with microversion 1.3 support.
python-barbicanclient accepts either a secret UUID or an HREF; prefer
the UUID form shown above.
Operator command (bulk)
-----------------------
For bulk work on the Barbican API node (every payload in a project, or
every payload on a source backend), use the
``barbican-manage secret migrate`` subcommand. That command is added in
a follow-on change; it calls the same HTTP API, continues after
per-secret failures, and never prints payloads.
API
---
Microversion 1.3::
PUT /v1/secrets/{secret-id}/secret-store/{secret-store-id}
OpenStack-API-Version: key-manager 1.3
The request body is empty. Success is ``204 No Content``. See
:doc:`/api/microversion_history`.
Failure handling
================
* A secret that is already on the destination store succeeds (no-op).
* Concurrent migrates of the same secret UUID are serialized with a
``SELECT ... FOR UPDATE`` row lock. The second request re-checks
after the lock and no-ops when the first already moved the payload.
* Metadata-only secrets return ``400``.
* Decrypt or destination ``store_secret`` failures leave the secret on
the source store (no database rewrite has started yet).
* If a migrate fails after the destination plugin accepted the payload,
the API rolls back the Barbican database change and deletes the new
plugin object. The secret remains on the source store.
See also
========
* :doc:`/admin/barbican_manage`
* :doc:`/configuration/plugin_backends`
* :doc:`/api/reference/store_backends`
+27
View File
@@ -34,3 +34,30 @@ additional ``consumers`` key, which contains references to Secret Consumers.
Deleting a secret that has consumers is rejected in the
API unless the ``force`` query parameter is sent.
1.3
---
Added ``PUT /v1/secrets/{secret-id}/secret-store/{secret-store-id}``
to migrate an existing secret payload onto a named secret store
without changing the secret UUID, ACLs, consumers, or container
membership.
The request body is empty. A successful migration returns
``204 No Content``. Multiple secret store backends must be enabled.
Default policy allows project admins to target any store, and
project members to migrate only to the project's preferred store.
The same microversion always adds computed ``secret_store_id`` /
``secret_store_ref`` fields on secret metadata GET and list
responses. Values are ``null`` when multiple backends are disabled
or the secret has no payload. A live payload that cannot be mapped
to exactly one catalogue store returns HTTP 500. These fields are
not persisted on the secret.
Secret-store catalogue responses
(``GET /v1/secret-stores``, get-by-id, preferred, and global-default)
also include ``secret_store_id`` alongside ``secret_store_ref``, so
clients can pass a UUID to migrate without parsing the href.
Operator usage is documented in :doc:`/admin/secret_store_migrate`.
+42 -26
View File
@@ -49,6 +49,7 @@ Request/Response:
"updated": "2016-08-22T23:46:45.114283",
"name": "PKCS11 HSM",
"created": "2016-08-22T23:46:45.114283",
"secret_store_id": "4d27b7a7-b82f-491d-88c0-746bd67dadc8",
"secret_store_ref": "http://localhost:9311/v1/secret-stores/4d27b7a7-b82f-491d-88c0-746bd67dadc8",
"global_default": True,
"crypto_plugin": "p11_crypto",
@@ -59,6 +60,7 @@ Request/Response:
"updated": "2016-08-22T23:46:45.124554",
"name": "KMIP HSM",
"created": "2016-08-22T23:46:45.124554",
"secret_store_id": "93869b0f-60eb-4830-adb9-e2f7154a080b",
"secret_store_ref": "http://localhost:9311/v1/secret-stores/93869b0f-60eb-4830-adb9-e2f7154a080b",
"global_default": False,
"crypto_plugin": None,
@@ -69,6 +71,7 @@ Request/Response:
"updated": "2016-08-22T23:46:45.127866",
"name": "Software Only Crypto",
"created": "2016-08-22T23:46:45.127866",
"secret_store_id": "0da45858-9420-42fe-a269-011f5f35deaa",
"secret_store_ref": "http://localhost:9311/v1/secret-stores/0da45858-9420-42fe-a269-011f5f35deaa",
"global_default": False,
"crypto_plugin": "simple_crypto",
@@ -76,23 +79,30 @@ Request/Response:
}
}
.. note::
``secret_store_id`` is included when the request asks for
microversion **1.3** or newer. Older microversions omit it and
return only ``secret_store_ref``.
.. _get_secret_stores_response_attributes:
Response Attributes
*******************
+---------------+--------+---------------------------------------------+
| Name | Type | Description |
+===============+========+=============================================+
| secret_stores | list | A list of secret store references |
+---------------+--------+---------------------------------------------+
| name | string | store and crypto plugin name delimited by + |
| | | (plus) sign. |
+---------------+--------+---------------------------------------------+
| secret_store | string | URL for referencing a specific secret store |
| _ref | | |
+---------------+--------+---------------------------------------------+
+------------------+--------+------------------------------------------------+
| Name | Type | Description |
+==================+========+================================================+
| secret_stores | list | A list of secret store references |
+------------------+--------+------------------------------------------------+
| name | string | store and crypto plugin name delimited by + |
| | | (plus) sign. |
+------------------+--------+------------------------------------------------+
| secret_store_ref | string | URL for referencing a specific secret store |
+------------------+--------+------------------------------------------------+
| secret_store_id | string | UUID of the secret store (microversion 1.3+) |
+------------------+--------+------------------------------------------------+
.. _get_secret_stores_status_codes:
@@ -153,21 +163,23 @@ Request/Response:
Response Attributes
*******************
+------------------+---------+---------------------------------------------------------------+
| Name | Type | Description |
+==================+=========+===============================================================+
| name | string | store and crypto plugin name delimited by '+' (plus) sign |
+------------------+---------+---------------------------------------------------------------+
| global_default | boolean | flag indicating if this secret store is global default or not |
+------------------+---------+---------------------------------------------------------------+
| status | list | Status of the secret store |
+------------------+---------+---------------------------------------------------------------+
| updated | time | Date and time secret store was last updated |
+------------------+---------+---------------------------------------------------------------+
| created | time | Date and time secret store was created |
+------------------+---------+---------------------------------------------------------------+
| secret_store_ref | string | URL for referencing a specific secret store |
+------------------+---------+---------------------------------------------------------------+
+------------------+---------+----------------------------------------------------+
| Name | Type | Description |
+==================+=========+====================================================+
| name | string | store and crypto plugin name delimited by '+' |
+------------------+---------+----------------------------------------------------+
| global_default | boolean | flag indicating if this store is global default |
+------------------+---------+----------------------------------------------------+
| status | list | Status of the secret store |
+------------------+---------+----------------------------------------------------+
| updated | time | Date and time secret store was last updated |
+------------------+---------+----------------------------------------------------+
| created | time | Date and time secret store was created |
+------------------+---------+----------------------------------------------------+
| secret_store_ref | string | URL for referencing a specific secret store |
+------------------+---------+----------------------------------------------------+
| secret_store_id | string | UUID of the secret store (microversion 1.3+) |
+------------------+---------+----------------------------------------------------+
.. _get_secret_stores_id_status_codes:
@@ -239,6 +251,8 @@ Response Attributes
+==================+========+===============================================+
| secret_store_ref | string | A URL that references a specific secret store |
+------------------+--------+-----------------------------------------------+
| secret_store_id | string | UUID of the secret store (microversion 1.3+) |
+------------------+--------+-----------------------------------------------+
.. _get_secret_stores_preferred_status_codes:
@@ -396,6 +410,8 @@ Response Attributes
+==================+========+===============================================+
| secret_store_ref | string | A URL that references a specific secret store |
+------------------+--------+-----------------------------------------------+
| secret_store_id | string | UUID of the secret store (microversion 1.3+) |
+------------------+--------+-----------------------------------------------+
.. _get_secret_stores_global_default_status_codes:
@@ -0,0 +1,18 @@
---
features:
- |
Added microversion 1.3 with
``PUT /v1/secrets/{secret-id}/secret-store/{secret-store-id}``
to migrate an existing secret onto another configured secret
store without changing the secret UUID. The request body is
empty. Project admins may target any store; project members may
migrate only to the project preferred store. Multiple secret
store backends must be enabled. Secret metadata GET/list always
return computed ``secret_store_id`` / ``secret_store_ref`` on
microversion 1.3+ (``null`` when multiple backends are disabled
or the secret has no payload). A live payload that cannot be
mapped to exactly one catalogue store returns HTTP 500.
``GET /v1/secret-stores`` (and get-by-id / preferred /
global-default) also return ``secret_store_id`` so clients need
not parse ``secret_store_ref``. See blueprint
admin-migrate-secret-store.