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:
1 parent
721dc68e7a
commit
9df3aa8848
29 files changed
+2048
-109
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -11,6 +11,7 @@ management of secrets.
|
||||
|
||||
access_control
|
||||
barbican_manage
|
||||
secret_store_migrate
|
||||
database_cleaning
|
||||
upgrade
|
||||
pkcs11keygeneration
|
||||
@@ -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`
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user