Implementation of root-enable, root-disable in redis.

Implement root-enable, root-disable for redis to manage redis
authentication.

Change-Id: If88092c24c51192a19eeec8312701e2c6d709db9
Implements: blueprint root-enable-in-redis
Signed-off-by: Fan Zhang <zh.f@outlook.com>
This commit is contained in:
Fan Zhang 2017-11-03 09:58:27 +08:00
parent 8aad3ee2e4
commit a57bf8816b
17 changed files with 570 additions and 2 deletions

View File

@ -0,0 +1,4 @@
---
features:
- OpenStack Trove now supports enable or disable authentication for Redis
datastore via the root-enable and root-disable API's.

View File

@ -854,7 +854,7 @@ redis_opts = [
help='Class that implements datastore-specific Guest Agent API '
'logic.'),
cfg.StrOpt('root_controller',
default='trove.extensions.common.service.DefaultRootController',
default='trove.extensions.redis.service.RedisRootController',
help='Root controller implementation for redis.'),
cfg.StrOpt('guest_log_exposed_logs', default='',
help='List of Guest Logs to expose for publishing.'),

View File

View File

@ -0,0 +1,28 @@
# Copyright 2017 Eayun, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
from trove.common.db import models
class RedisRootUser(models.DatastoreModelsBase):
def verify_dict(self):
pass
def __init__(self, password=None):
self._name = '-'
self._password = password
super(RedisRootUser, self).__init__()

View File

@ -671,3 +671,9 @@ class DatastoreVersionAlreadyExists(BadRequest):
class LogAccessForbidden(Forbidden):
message = _("You must be admin to %(action)s log '%(log)s'.")
class SlaveOperationNotSupported(TroveError):
message = _("The '%(operation)s' operation is not supported for slaves in "
"replication.")

View File

View File

@ -0,0 +1,28 @@
# Copyright 2017 Eayun, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
from trove.common.remote import create_guest_client
from trove.extensions.common.models import load_and_verify
from trove.extensions.common.models import Root
class RedisRoot(Root):
@classmethod
def get_auth_password(cls, context, instance_id):
load_and_verify(context, instance_id)
password = create_guest_client(context,
instance_id).get_root_password()
return password

View File

@ -0,0 +1,185 @@
# Copyright 2017 Eayun, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
from oslo_log import log as logging
from trove.common import cfg
from trove.common import exception
from trove.common.i18n import _
from trove.common.i18n import _LE
from trove.common.i18n import _LI
from trove.common import wsgi
from trove.extensions.common.service import DefaultRootController
from trove.extensions.redis.models import RedisRoot
from trove.extensions.redis.views import RedisRootCreatedView
from trove.instance.models import DBInstance
LOG = logging.getLogger(__name__)
CONF = cfg.CONF
MANAGER = CONF.datastore_manager if CONF.datastore_manager else 'redis'
class RedisRootController(DefaultRootController):
def root_create(self, req, body, tenant_id, instance_id, is_cluster):
"""Enable authentication for a redis instance and its replicas if any
"""
self._validate_can_perform_action(tenant_id, instance_id, is_cluster,
"enable_root")
password = DefaultRootController._get_password_from_body(body)
slave_instances = self._get_slaves(tenant_id, instance_id)
return self._instance_root_create(req, instance_id, password,
slave_instances)
def root_delete(self, req, tenant_id, instance_id, is_cluster):
"""Disable authentication for a redis instance and its replicas if any
"""
self._validate_can_perform_action(tenant_id, instance_id, is_cluster,
"disable_root")
slave_instances = self._get_slaves(tenant_id, instance_id)
return self._instance_root_delete(req, instance_id, slave_instances)
def _instance_root_create(self, req, instance_id, password,
slave_instances=None):
LOG.info(_LI("Enabling authentication for instance '%s'."),
instance_id)
LOG.info(_LI("req : '%s'\n\n"), req)
context = req.environ[wsgi.CONTEXT_KEY]
user_name = context.user
original_auth_password = self._get_original_auth_password(
context, instance_id)
# Do root-enable and roll back once if operation fails.
try:
root = RedisRoot.create(context, instance_id,
user_name, password)
if not password:
password = root.password
except exception.TroveError:
self._rollback_once(req, instance_id, original_auth_password)
raise exception.TroveError(
_LE("Failed to do root-enable for instance "
"'%(instance_id)s'.") % {'instance_id': instance_id}
)
failed_slaves = []
for slave_id in slave_instances:
try:
LOG.info(_LI("Enabling authentication for slave instance "
"'%s'."), slave_id)
RedisRoot.create(context, slave_id, user_name, password)
except exception.TroveError:
failed_slaves.append(slave_id)
return wsgi.Result(
RedisRootCreatedView(root, failed_slaves).data(), 200)
def _instance_root_delete(self, req, instance_id, slave_instances=None):
LOG.info(_LI("Disabling authentication for instance '%s'."),
instance_id)
LOG.info(_LI("req : '%s'\n\n"), req)
context = req.environ[wsgi.CONTEXT_KEY]
original_auth_password = self._get_original_auth_password(
context, instance_id)
# Do root-disable and roll back once if operation fails.
try:
RedisRoot.delete(context, instance_id)
except exception.TroveError:
self._rollback_once(req, instance_id, original_auth_password)
raise exception.TroveError(
_LE("Failed to do root-disable for instance "
"'%(instance_id)s'.") % {'instance_id': instance_id}
)
failed_slaves = []
for slave_id in slave_instances:
try:
LOG.info(_LI("Disabling authentication for slave instance "
"'%s'."), slave_id)
RedisRoot.delete(context, slave_id)
except exception.TroveError:
failed_slaves.append(slave_id)
if len(failed_slaves) > 0:
result = {
'failed_slaves': failed_slaves
}
return wsgi.Result(result, 200)
return wsgi.Result(None, 204)
@staticmethod
def _rollback_once(req, instance_id, original_auth_password):
LOG.info(_LI("Rolling back enable/disable authentication "
"for instance '%s'."), instance_id)
context = req.environ[wsgi.CONTEXT_KEY]
user_name = context.user
try:
if not original_auth_password:
# Instance never did root-enable before.
RedisRoot.delete(context, instance_id)
else:
# Instance has done root-enable successfully before.
# So roll back with original password.
RedisRoot.create(context, instance_id, user_name,
original_auth_password)
except exception.TroveError:
LOG.exception(_("Rolling back failed for instance '%s'"),
instance_id)
@staticmethod
def _is_slave(tenant_id, instance_id):
args = {'id': instance_id, 'tenant_id': tenant_id}
instance_info = DBInstance.find_by(**args)
return instance_info.slave_of_id
@staticmethod
def _get_slaves(tenant_id, instance_or_cluster_id, deleted=False):
LOG.info(_LI("Getting non-deleted slaves of instance '%s', "
"if any."), instance_or_cluster_id)
args = {'slave_of_id': instance_or_cluster_id, 'tenant_id': tenant_id,
'deleted': deleted}
db_infos = DBInstance.find_all(**args)
slaves = []
for db_info in db_infos:
slaves.append(db_info.id)
return slaves
@staticmethod
def _get_original_auth_password(context, instance_id):
# Check if instance did root-enable before and get original password.
password = None
if RedisRoot.load(context, instance_id):
try:
password = RedisRoot.get_auth_password(context, instance_id)
except exception.TroveError:
raise exception.TroveError(
_LE("Failed to get original auth password of instance "
"'%(instance_id)s'.") % {'instance_id': instance_id}
)
return password
def _validate_can_perform_action(self, tenant_id, instance_id, is_cluster,
operation):
if is_cluster:
raise exception.ClusterOperationNotSupported(
operation=operation)
is_slave = self._is_slave(tenant_id, instance_id)
if is_slave:
raise exception.SlaveOperationNotSupported(
operation=operation)

View File

@ -0,0 +1,30 @@
# Copyright 2017 Eayun, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
from trove.extensions.common.views import UserView
class RedisRootCreatedView(UserView):
def __init__(self, user, failed_slaves):
self.failed_slaves = failed_slaves
super(RedisRootCreatedView, self).__init__(user)
def data(self):
user_dict = {
"name": self.user.name,
"password": self.user.password
}
return {"user": user_dict, "failed_slaves": self.failed_slaves}

View File

@ -237,6 +237,15 @@ class API(object):
self._cast("delete_database", version=version, database=database)
def get_root_password(self):
"""Make a synchronous call to get root password of instance.
"""
LOG.debug("Get root password of instance %s.", self.id)
version = self.API_BASE_VERSION
return self._call("get_root_password", self.agent_high_timeout,
version=version)
def enable_root(self):
"""Make a synchronous call to enable the root user for
access from anywhere

View File

@ -267,3 +267,19 @@ class Manager(manager.Manager):
LOG.debug("Executing cluster_addslots to assign hash slots %s-%s.",
first_slot, last_slot)
self._app.cluster_addslots(first_slot, last_slot)
def enable_root(self, context):
LOG.debug("Enabling authentication.")
return self._app.enable_root()
def enable_root_with_password(self, context, root_password=None):
LOG.debug("Enabling authentication with password.")
return self._app.enable_root(root_password)
def disable_root(self, context):
LOG.debug("Disabling authentication.")
return self._app.disable_root()
def get_root_password(self, context):
LOG.debug("Getting auth password.")
return self._app.get_auth_password()

View File

@ -20,6 +20,7 @@ from redis.exceptions import BusyLoadingError, ConnectionError
from oslo_log import log as logging
from trove.common import cfg
from trove.common.db.redis.models import RedisRootUser
from trove.common import exception
from trove.common.i18n import _
from trove.common import instance as rd_instance
@ -37,6 +38,7 @@ LOG = logging.getLogger(__name__)
TIME_OUT = 1200
CONF = cfg.CONF
CLUSTER_CFG = 'clustering'
SYS_OVERRIDES_AUTH = 'auth_password'
packager = pkg.Package()
@ -401,6 +403,31 @@ class RedisApp(object):
LOG.exception(_('Error removing node from cluster.'))
raise
def enable_root(self, password=None):
if not password:
password = utils.generate_random_password()
redis_password = RedisRootUser(password=password)
try:
self.configuration_manager.apply_system_override(
{'requirepass': password, 'masterauth': password},
change_id=SYS_OVERRIDES_AUTH)
self.apply_overrides(
self.admin, {'requirepass': password, 'masterauth': password})
except exception.TroveError:
LOG.exception(_('Error enabling authentication for instance.'))
raise
return redis_password.serialize()
def disable_root(self):
try:
self.configuration_manager.remove_system_override(
change_id=SYS_OVERRIDES_AUTH)
self.apply_overrides(self.admin,
{'requirepass': '', 'masterauth': ''})
except exception.TroveError:
LOG.exception(_('Error disabling authentication for instance.'))
raise
class RedisAdmin(object):
"""Handles administrative tasks on the Redis database.

View File

@ -730,6 +730,11 @@ class Manager(periodic_task.PeriodicTasks):
raise exception.DatastoreOperationNotSupported(
operation='change_passwords', datastore=self.manager)
def get_root_password(self, context):
LOG.debug("Getting root password.")
raise exception.DatastoreOperationNotSupported(
operation='get_root_password', datastore=self.manager)
def enable_root(self, context):
LOG.debug("Enabling root.")
raise exception.DatastoreOperationNotSupported(

View File

@ -404,7 +404,8 @@ register(
["redis_supported"],
single=[common_groups,
backup_groups,
configuration_groups, ],
configuration_groups,
root_actions_groups, ],
multi=[replication_promote_groups, ]
# multi=[cluster_actions_groups,
# cluster_negative_actions_groups,

View File

@ -14,6 +14,7 @@
from mock import DEFAULT, MagicMock, Mock, patch
from trove.common import utils as utils
from trove.guestagent import backup
from trove.guestagent.common import configuration
from trove.guestagent.common.configuration import ImportOverrideStrategy
@ -329,3 +330,26 @@ class RedisGuestAgentManagerTest(DatastoreManagerTest):
self.manager._get_repl_info = MagicMock(return_value=repl_info)
self.manager.wait_for_txn(self.context, expected_txn_id)
self.manager._get_repl_info.assert_any_call()
@patch.object(configuration.ConfigurationManager, 'apply_system_override')
@patch.object(redis_service.RedisApp, 'apply_overrides')
@patch.object(utils, 'generate_random_password',
return_value='password')
def test_enable_root(self, *mock):
root_user = {'_name': '-',
'_password': 'password'}
result = self.manager.enable_root(self.context)
self.assertEqual(root_user, result)
@patch.object(redis_service.RedisApp, 'disable_root')
def test_disable_root(self, disable_root_mock):
self.manager.disable_root(self.context)
disable_root_mock.assert_any_call()
@patch.object(redis_service.RedisApp, 'get_auth_password',
return_value="password")
def test_get_root_password(self, get_auth_password_mock):
result = self.manager.get_root_password(self.context)
self.assertTrue(get_auth_password_mock.called)
self.assertEqual('password', result)

View File

View File

@ -0,0 +1,205 @@
# Copyright 2017 Eayun, Inc.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
import uuid
from mock import Mock, patch
from trove.common import exception
from trove.datastore import models as datastore_models
from trove.extensions.common import models
from trove.extensions.redis.service import RedisRootController
from trove.instance import models as instance_models
from trove.instance.models import DBInstance
from trove.instance.tasks import InstanceTasks
from trove.taskmanager import api as task_api
from trove.tests.unittests import trove_testtools
from trove.tests.unittests.util import util
class TestRedisRootController(trove_testtools.TestCase):
@patch.object(task_api.API, 'get_client', Mock(return_value=Mock()))
def setUp(self):
util.init_db()
self.context = trove_testtools.TroveTestContext(self, is_admin=True)
self.datastore = datastore_models.DBDatastore.create(
id=str(uuid.uuid4()),
name='redis' + str(uuid.uuid4()),
)
self.datastore_version = (
datastore_models.DBDatastoreVersion.create(
id=str(uuid.uuid4()),
datastore_id=self.datastore.id,
name="3.2" + str(uuid.uuid4()),
manager="redis",
image_id="image_id",
packages="",
active=True))
self.tenant_id = "UUID"
self.single_db_info = DBInstance.create(
id="redis-single",
name="redis-single",
flavor_id=1,
datastore_version_id=self.datastore_version.id,
tenant_id=self.tenant_id,
volume_size=None,
task_status=InstanceTasks.NONE)
self.master_db_info = DBInstance.create(
id="redis-master",
name="redis-master",
flavor_id=1,
datastore_version_id=self.datastore_version.id,
tenant_id=self.tenant_id,
volume_size=None,
task_status=InstanceTasks.NONE)
self.slave_db_info = DBInstance.create(
id="redis-slave",
name="redis-slave",
flavor_id=1,
datastore_version_id=self.datastore_version.id,
tenant_id=self.tenant_id,
volume_size=None,
task_status=InstanceTasks.NONE,
slave_of_id=self.master_db_info.id)
super(TestRedisRootController, self).setUp()
self.controller = RedisRootController()
def tearDown(self):
self.datastore.delete()
self.datastore_version.delete()
self.master_db_info.delete()
self.slave_db_info.delete()
super(TestRedisRootController, self).tearDown()
@patch.object(instance_models.Instance, "load")
@patch.object(models.Root, "create")
def test_root_create_on_single_instance(self, root_create, *args):
user = Mock()
context = Mock()
context.user = Mock()
context.user.__getitem__ = Mock(return_value=user)
req = Mock()
req.environ = Mock()
req.environ.__getitem__ = Mock(return_value=context)
tenant_id = self.tenant_id
instance_id = self.single_db_info.id
is_cluster = False
password = Mock()
body = {"password": password}
self.controller.root_create(req, body, tenant_id,
instance_id, is_cluster)
root_create.assert_called_with(context, instance_id,
context.user, password)
@patch.object(instance_models.Instance, "load")
@patch.object(models.Root, "create")
def test_root_create_on_master_instance(self, root_create, *args):
user = Mock()
context = Mock()
context.user = Mock()
context.user.__getitem__ = Mock(return_value=user)
req = Mock()
req.environ = Mock()
req.environ.__getitem__ = Mock(return_value=context)
tenant_id = self.tenant_id
instance_id = self.master_db_info.id
slave_instance_id = self.slave_db_info.id
is_cluster = False
password = Mock()
body = {"password": password}
self.controller.root_create(req, body, tenant_id,
instance_id, is_cluster)
root_create.assert_called_with(context, slave_instance_id,
context.user, password)
def test_root_create_on_slave(self):
user = Mock()
context = Mock()
context.user = Mock()
context.user.__getitem__ = Mock(return_value=user)
req = Mock()
req.environ = Mock()
req.environ.__getitem__ = Mock(return_value=context)
tenant_id = self.tenant_id
instance_id = self.slave_db_info.id
is_cluster = False
body = {}
self.assertRaises(
exception.SlaveOperationNotSupported,
self.controller.root_create,
req, body, tenant_id, instance_id, is_cluster)
def test_root_create_with_cluster(self):
req = Mock()
tenant_id = self.tenant_id
instance_id = self.master_db_info.id
is_cluster = True
body = {}
self.assertRaises(
exception.ClusterOperationNotSupported,
self.controller.root_create,
req, body, tenant_id, instance_id, is_cluster)
@patch.object(instance_models.Instance, "load")
@patch.object(models.Root, "delete")
def test_root_delete_on_single_instance(self, root_delete, *args):
context = Mock()
req = Mock()
req.environ = Mock()
req.environ.__getitem__ = Mock(return_value=context)
tenant_id = self.tenant_id
instance_id = self.single_db_info.id
is_cluster = False
self.controller.root_delete(req, tenant_id, instance_id, is_cluster)
root_delete.assert_called_with(context, instance_id)
@patch.object(instance_models.Instance, "load")
@patch.object(models.Root, "delete")
def test_root_delete_on_master_instance(self, root_delete, *args):
context = Mock()
req = Mock()
req.environ = Mock()
req.environ.__getitem__ = Mock(return_value=context)
tenant_id = self.tenant_id
instance_id = self.master_db_info.id
slave_instance_id = self.slave_db_info.id
is_cluster = False
self.controller.root_delete(req, tenant_id, instance_id, is_cluster)
root_delete.assert_called_with(context, slave_instance_id)
def test_root_delete_on_slave(self):
context = Mock()
req = Mock()
req.environ = Mock()
req.environ.__getitem__ = Mock(return_value=context)
tenant_id = self.tenant_id
instance_id = self.slave_db_info.id
is_cluster = False
self.assertRaises(
exception.SlaveOperationNotSupported,
self.controller.root_delete,
req, tenant_id, instance_id, is_cluster)
def test_root_delete_with_cluster(self):
req = Mock()
tenant_id = self.tenant_id
instance_id = self.master_db_info.id
is_cluster = True
self.assertRaises(
exception.ClusterOperationNotSupported,
self.controller.root_delete,
req, tenant_id, instance_id, is_cluster)