Add secret consumers
This change adds the ability to add or remove consumers to a managed object to allow services to indicate which object is associated with a specific secret. At this time, only barbican supports consumers. This code cannot be merged without a corresponding release and bump of version for the barbicanclient. Co-Authored-By: Mauricio Harley <mharley@redhat.com> Depends-On: https://review.opendev.org/c/openstack/requirements/+/873906 Change-Id: Ic25ac329f87db5992e32ef0b2d7d4020f37b2dee
This commit is contained in:
parent
fe10397ac0
commit
bc6d87b969
@ -37,7 +37,6 @@ from castellan.common.objects import opaque_data as op_data
|
||||
from castellan.i18n import _
|
||||
from castellan.key_manager import key_manager
|
||||
|
||||
|
||||
from barbicanclient import client as barbican_client_import
|
||||
from barbicanclient import exceptions as barbican_exceptions
|
||||
from oslo_utils import timeutils
|
||||
@ -160,7 +159,6 @@ class BarbicanKeyManager(key_manager.KeyManager):
|
||||
self._base_url = self._create_base_url(auth,
|
||||
sess,
|
||||
self._barbican_endpoint)
|
||||
|
||||
return self._barbican_client
|
||||
|
||||
def _get_keystone_auth(self, context):
|
||||
@ -552,17 +550,20 @@ class BarbicanKeyManager(key_manager.KeyManager):
|
||||
created = calendar.timegm(time_stamp)
|
||||
|
||||
if issubclass(secret_type, key_base_class.Key):
|
||||
return secret_type(secret.algorithm,
|
||||
secret.bit_length,
|
||||
secret_data,
|
||||
secret.name,
|
||||
created,
|
||||
object_id)
|
||||
return secret_type(algorithm=secret.algorithm,
|
||||
bit_length=secret.bit_length,
|
||||
key=secret_data,
|
||||
name=secret.name,
|
||||
created=created,
|
||||
id=object_id,
|
||||
consumers=secret.consumers)
|
||||
else:
|
||||
# Opaque Data or Passphrase
|
||||
return secret_type(secret_data,
|
||||
secret.name,
|
||||
created,
|
||||
object_id)
|
||||
name=secret.name,
|
||||
created=created,
|
||||
id=object_id,
|
||||
consumers=secret.consumers)
|
||||
|
||||
def _get_secret(self, context, object_id):
|
||||
"""Returns the metadata of the secret.
|
||||
@ -628,7 +629,6 @@ class BarbicanKeyManager(key_manager.KeyManager):
|
||||
:raises ManagedObjectNotFoundError: if the object could not be found
|
||||
"""
|
||||
barbican_client = self._get_barbican_client(context)
|
||||
|
||||
try:
|
||||
secret_ref = self._create_secret_ref(managed_object_id)
|
||||
barbican_client.secrets.delete(secret_ref)
|
||||
@ -642,6 +642,50 @@ class BarbicanKeyManager(key_manager.KeyManager):
|
||||
else:
|
||||
raise exception.KeyManagerError(reason=e)
|
||||
|
||||
def add_consumer(self, context, managed_object_id, consumer_data):
|
||||
"""Add a consumer to the specified managed object
|
||||
|
||||
:param context: contains information of the user and the environment
|
||||
for the request (castellan/context.py)
|
||||
:param managed_object_id: the UUID of the object to update
|
||||
:param consumer_data: dict containing consumer data
|
||||
:raises KeyManagerError: if object deletion fails
|
||||
:raises ManagedObjectNotFoundError: if the object could not be found
|
||||
"""
|
||||
|
||||
barbican_client = self._get_barbican_client(context)
|
||||
try:
|
||||
secret_ref = self._create_secret_ref(managed_object_id)
|
||||
barbican_client.secrets.register_consumer(
|
||||
secret_ref, **consumer_data)
|
||||
|
||||
except (barbican_exceptions.HTTPAuthError,
|
||||
barbican_exceptions.HTTPClientError,
|
||||
barbican_exceptions.HTTPServerError) as e:
|
||||
LOG.error("Error adding consumer: %s", e)
|
||||
if self._is_secret_not_found_error(e):
|
||||
raise exception.ManagedObjectNotFoundError(
|
||||
uuid=managed_object_id)
|
||||
else:
|
||||
raise exception.KeyManagerError(reason=e)
|
||||
|
||||
def remove_consumer(self, context, managed_object_id, consumer_data):
|
||||
|
||||
barbican_client = self._get_barbican_client(context)
|
||||
try:
|
||||
secret_ref = self._create_secret_ref(managed_object_id)
|
||||
barbican_client.secrets.remove_consumer(
|
||||
secret_ref, **consumer_data)
|
||||
except (barbican_exceptions.HTTPAuthError,
|
||||
barbican_exceptions.HTTPClientError,
|
||||
barbican_exceptions.HTTPServerError) as e:
|
||||
LOG.error("Error removing consumer: %s", e)
|
||||
if self._is_secret_not_found_error(e):
|
||||
raise exception.ManagedObjectNotFoundError(
|
||||
uuid=managed_object_id)
|
||||
else:
|
||||
raise exception.KeyManagerError(reason=e)
|
||||
|
||||
def list(self, context, object_type=None, metadata_only=False):
|
||||
"""Retrieves a list of managed objects that match the criteria.
|
||||
|
||||
|
@ -122,6 +122,40 @@ class KeyManager(object, metaclass=abc.ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def add_consumer(self, context, managed_object_id, consumer_data):
|
||||
"""Add a consumer to a managed object.
|
||||
|
||||
Implementations should verify that the caller has permission to
|
||||
add a consumer to the managed object by checking the context object
|
||||
(context). A NotAuthorized exception should be raised if the caller
|
||||
lacks permission.
|
||||
|
||||
If the specified object does not exist, then a KeyError should be
|
||||
raised. Implementations should preclude users from discerning the
|
||||
UUIDs of objects that belong to other users by repeatedly calling this
|
||||
method. That is, objects that belong to other users should be
|
||||
considered "non-existent" and completely invisible.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def remove_consumer(self, context, managed_object_id, consumer_data):
|
||||
"""Remove a consumer from a managed object.
|
||||
|
||||
Implementations should verify that the caller has permission to
|
||||
remove a consumer to the managed object by checking the context object
|
||||
(context). A NotAuthorized exception should be raised if the caller
|
||||
lacks permission.
|
||||
|
||||
If the specified object does not exist, then a KeyError should be
|
||||
raised. Implementations should preclude users from discerning the
|
||||
UUIDs of objects that belong to other users by repeatedly calling this
|
||||
method. That is, objects that belong to other users should be
|
||||
considered "non-existent" and completely invisible.
|
||||
"""
|
||||
pass
|
||||
|
||||
def list(self, context, object_type=None, metadata_only=False):
|
||||
"""Lists the managed objects given the criteria.
|
||||
|
||||
|
@ -48,5 +48,11 @@ class NotImplementedKeyManager(key_manager.KeyManager):
|
||||
def list(self, context, object_type=None):
|
||||
raise NotImplementedError()
|
||||
|
||||
def delete(self, context, managed_object_id, **kwargs):
|
||||
def delete(self, context, managed_object_id):
|
||||
raise NotImplementedError()
|
||||
|
||||
def add_consumer(self, context, managed_object_id, consumer_data):
|
||||
raise NotImplementedError()
|
||||
|
||||
def remove_consumer(self, context, managed_object_id, consumer_data):
|
||||
raise NotImplementedError()
|
||||
|
@ -355,6 +355,16 @@ class VaultKeyManager(key_manager.KeyManager):
|
||||
if resp.status_code == requests.codes['not_found']:
|
||||
raise exception.ManagedObjectNotFoundError(uuid=key_id)
|
||||
|
||||
def add_consumer(self, context, managed_object_id, consumer_data):
|
||||
raise NotImplementedError(
|
||||
"VaultKeyManager does not implement adding consumers"
|
||||
)
|
||||
|
||||
def remove_consumer(self, context, managed_object_id, consumer_data):
|
||||
raise NotImplementedError(
|
||||
"VaultKeyManager does not implement deleting consumers"
|
||||
)
|
||||
|
||||
def list(self, context, object_type=None, metadata_only=False):
|
||||
"""Lists the managed objects given the criteria."""
|
||||
|
||||
|
@ -199,6 +199,22 @@ class MockKeyManager(key_manager.KeyManager):
|
||||
|
||||
del self.keys[managed_object_id]
|
||||
|
||||
def add_consumer(self, context, managed_object_id, consumer_data):
|
||||
if context is None:
|
||||
raise exception.Forbidden()
|
||||
if managed_object_id not in self.keys:
|
||||
raise exception.ManagedObjectNotFoundError()
|
||||
self.keys[managed_object_id].consumers.append(consumer_data)
|
||||
|
||||
def remove_consumer(self, context, managed_object_id, consumer_data):
|
||||
if context is None:
|
||||
raise exception.Forbidden()
|
||||
if managed_object_id not in self.keys:
|
||||
raise exception.ManagedObjectNotFoundError()
|
||||
self.keys[managed_object_id].consumers = [
|
||||
c for c in self.keys[managed_object_id].consumers
|
||||
if c != consumer_data]
|
||||
|
||||
def _generate_password(self, length, symbolgroups):
|
||||
"""Generate a random password from the supplied symbol groups.
|
||||
|
||||
|
@ -24,6 +24,7 @@ from keystoneauth1 import identity
|
||||
from keystoneauth1 import service_token
|
||||
from oslo_context import context
|
||||
from oslo_utils import timeutils
|
||||
from oslo_utils import uuidutils
|
||||
|
||||
from castellan.common import exception
|
||||
from castellan.common.objects import symmetric_key as sym_key
|
||||
@ -78,8 +79,13 @@ class BarbicanKeyManagerTestCase(test_key_manager.KeyManagerTestCase):
|
||||
self.create = self.mock_barbican.secrets.create
|
||||
self.list = self.mock_barbican.secrets.list
|
||||
|
||||
self.add_consumer = self.mock_barbican.secrets.add_consumer
|
||||
self.remove_consumer = self.mock_barbican.secrets.remove_consumer
|
||||
self.list_versions = self.mock_barbican.versions.list_versions
|
||||
|
||||
self.key_mgr._barbican_client = self.mock_barbican
|
||||
self.key_mgr._current_context = self.ctxt
|
||||
self.key_mgr._version_client = self.mock_barbican
|
||||
|
||||
def test_barbican_endpoint(self):
|
||||
endpoint_data = mock.Mock()
|
||||
@ -634,3 +640,245 @@ class BarbicanKeyManagerTestCase(test_key_manager.KeyManagerTestCase):
|
||||
self.assertIn('cafile', barbican_service_user_opts)
|
||||
# From auth common opts.
|
||||
self.assertIn('auth_section', barbican_service_user_opts)
|
||||
|
||||
def _test_consumer_expects_error(
|
||||
self, Error, method, ctxt, obj_ref, service="storage",
|
||||
resource_type='volume', resource_id=uuidutils.generate_uuid()):
|
||||
consumer_data = self._get_custom_consumer_data(
|
||||
service=service, resource_type=resource_type,
|
||||
resource_id=resource_id)
|
||||
self.assertRaises(
|
||||
Error, method, ctxt, obj_ref, consumer_data)
|
||||
|
||||
def _mock_list_versions_and_test_consumer_expects_error(
|
||||
self, Error, method, ctxt, obj_ref, service="storage",
|
||||
resource_type='volume', resource_id=uuidutils.generate_uuid()):
|
||||
self._mock_list_versions()
|
||||
self._test_consumer_expects_error(
|
||||
Error, method, ctxt, obj_ref, service=service,
|
||||
resource_type=resource_type, resource_id=resource_id)
|
||||
|
||||
def _mock_list_versions_and_test_add_consumer_expects_error(
|
||||
self, Error, ctxt, obj_ref, side_effect=None, service="storage",
|
||||
resource_type='volume', resource_id=uuidutils.generate_uuid()):
|
||||
self.mock_barbican.secrets.register_consumer = mock.Mock(
|
||||
side_effect=side_effect)
|
||||
self._mock_list_versions_and_test_consumer_expects_error(
|
||||
Error, self.key_mgr.add_consumer, ctxt,
|
||||
obj_ref, service=service, resource_type=resource_type,
|
||||
resource_id=resource_id)
|
||||
|
||||
def _mock_list_versions_and_test_remove_consumer_expects_error(
|
||||
self, Error, ctxt, obj_ref, side_effect=None, service="storage",
|
||||
resource_type='volume', resource_id=uuidutils.generate_uuid()):
|
||||
self.mock_barbican.secrets.remove_consumer = mock.Mock(
|
||||
side_effect=side_effect)
|
||||
self._mock_list_versions_and_test_consumer_expects_error(
|
||||
Error, self.key_mgr.remove_consumer, ctxt,
|
||||
obj_ref, service=service, resource_type=resource_type,
|
||||
resource_id=resource_id)
|
||||
|
||||
def _mock_list_versions(self):
|
||||
list_versions = [{
|
||||
'id': 'v1', 'status': 'CURRENT', 'min_version': '1.0',
|
||||
'max_version': '1.1', 'links': []}
|
||||
]
|
||||
self.list_versions.return_value = list_versions
|
||||
|
||||
def _get_custom_consumer_data(
|
||||
self, service="storage", resource_type='volume',
|
||||
resource_id=uuidutils.generate_uuid()):
|
||||
return {
|
||||
'service': service, 'resource_type': resource_type,
|
||||
'resource_id': resource_id}
|
||||
|
||||
def test_add_consumer_without_context_fails(self):
|
||||
self.key_mgr._barbican_client = None
|
||||
self._test_consumer_expects_error(
|
||||
exception.Forbidden, self.key_mgr.add_consumer, None,
|
||||
self.secret_ref)
|
||||
|
||||
def test_add_consumer_with_different_project_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Forbidden: SecretConsumer creation attempt not allowed - "
|
||||
"please review your user/project privileges")
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect)
|
||||
|
||||
def test_add_consumer_with_null_managed_object_id_fails(self):
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, None)
|
||||
|
||||
def test_add_consumer_with_empty_managed_object_id_fails(self):
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, "")
|
||||
|
||||
def test_add_consumer_with_invalid_managed_object_id_fails(self):
|
||||
side_effect = ValueError("Secret incorrectly specified.")
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
ValueError, self.ctxt, uuidutils.generate_uuid()[:-1],
|
||||
side_effect=side_effect)
|
||||
|
||||
def test_add_consumer_with_inexistent_managed_object_id_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Not Found: Secret not found.", status_code=404)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.ManagedObjectNotFoundError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect)
|
||||
|
||||
def test_add_consumer_with_null_service_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': None is not of type 'string'. Invalid "
|
||||
"property: 'service'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, service=None)
|
||||
|
||||
def test_add_consumer_with_empty_service_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': '' is too short. Invalid property: 'service'",
|
||||
status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, service="")
|
||||
|
||||
def test_add_consumer_with_null_resource_type_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': None is not of type 'string'. "
|
||||
"Invalid property: 'resource_type'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, resource_type=None)
|
||||
|
||||
def test_add_consumer_with_empty_resource_type_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': '' is too short. Invalid property: "
|
||||
"'resource_type'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, resource_type="")
|
||||
|
||||
def test_add_consumer_with_null_resource_id_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': None is not of type 'string'. "
|
||||
"Invalid property: 'resource_id'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, resource_id=None)
|
||||
|
||||
def test_add_consumer_with_empty_resource_id_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': '' is too short. Invalid property: "
|
||||
"'resource_id'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, resource_id="")
|
||||
|
||||
def test_add_consumer_with_valid_parameters_doesnt_fail(self):
|
||||
self._mock_list_versions()
|
||||
self.key_mgr.add_consumer(
|
||||
self.ctxt, self.secret_ref, self._get_custom_consumer_data())
|
||||
|
||||
def test_remove_consumer_without_context_fails(self):
|
||||
self.key_mgr._barbican_client = None
|
||||
self._test_consumer_expects_error(
|
||||
exception.Forbidden, self.key_mgr.remove_consumer, None,
|
||||
self.secret_ref)
|
||||
|
||||
def test_remove_consumer_with_different_project_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Forbidden: SecretConsumer creation attempt not allowed - "
|
||||
"please review your user/project privileges")
|
||||
self._mock_list_versions_and_test_remove_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect)
|
||||
|
||||
def test_remove_consumer_with_null_managed_object_id_fails(self):
|
||||
side_effect = ValueError("secret incorrectly specified.")
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, None,
|
||||
side_effect=side_effect)
|
||||
|
||||
def test_remove_consumer_with_empty_managed_object_id_fails(self):
|
||||
side_effect = ValueError("secret incorrectly specified.")
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, "", side_effect=side_effect)
|
||||
|
||||
def test_remove_consumer_with_invalid_managed_object_id_fails(self):
|
||||
side_effect = ValueError("Secret incorrectly specified.")
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
ValueError, self.ctxt, uuidutils.generate_uuid()[:-1],
|
||||
side_effect=side_effect)
|
||||
|
||||
def test_remove_consumer_without_registered_managed_object_id_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Not Found: Secret not found.", status_code=404)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.ManagedObjectNotFoundError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect)
|
||||
|
||||
def test_remove_consumer_with_null_service_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': None is not of type 'string'. Invalid "
|
||||
"property: 'service'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, service=None)
|
||||
|
||||
def test_remove_consumer_with_empty_service_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': '' is too short. Invalid property: 'service'",
|
||||
status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, service="")
|
||||
|
||||
def test_remove_consumer_with_null_resource_type_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': None is not of type 'string'. "
|
||||
"Invalid property: 'resource_type'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, resource_type=None)
|
||||
|
||||
def test_remove_consumer_with_empty_resource_type_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': '' is too short. Invalid property: "
|
||||
"'resource_type'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, resource_type="")
|
||||
|
||||
def test_remove_consumer_with_null_resource_id_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': None is not of type 'string'. "
|
||||
"Invalid property: 'resource_id'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, resource_id=None)
|
||||
|
||||
def test_remove_consumer_with_empty_resource_id_fails(self):
|
||||
side_effect = barbican_exceptions.HTTPClientError(
|
||||
"Bad Request: Provided object does not match schema "
|
||||
"'Secret Consumer': '' is too short. Invalid property: "
|
||||
"'resource_id'", status_code=400)
|
||||
self._mock_list_versions_and_test_add_consumer_expects_error(
|
||||
exception.KeyManagerError, self.ctxt, self.secret_ref,
|
||||
side_effect=side_effect, resource_id="")
|
||||
|
||||
def test_remove_consumer_with_valid_parameters_doesnt_fail(self):
|
||||
self._mock_list_versions()
|
||||
self.key_mgr.remove_consumer(
|
||||
self.ctxt, self.secret_ref, self._get_custom_consumer_data())
|
||||
|
@ -4,7 +4,7 @@
|
||||
|
||||
pbr!=2.1.0,>=2.0.0 # Apache-2.0
|
||||
cryptography>=2.7 # BSD/Apache-2.0
|
||||
python-barbicanclient>=4.5.2 # Apache-2.0
|
||||
python-barbicanclient>=5.5.0 # Apache-2.0
|
||||
oslo.config>=6.4.0 # Apache-2.0
|
||||
oslo.context>=2.19.2 # Apache-2.0
|
||||
oslo.i18n>=3.15.3 # Apache-2.0
|
||||
|
Loading…
Reference in New Issue
Block a user