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 ' help='Class that implements datastore-specific Guest Agent API '
'logic.'), 'logic.'),
cfg.StrOpt('root_controller', cfg.StrOpt('root_controller',
default='trove.extensions.common.service.DefaultRootController', default='trove.extensions.redis.service.RedisRootController',
help='Root controller implementation for redis.'), help='Root controller implementation for redis.'),
cfg.StrOpt('guest_log_exposed_logs', default='', cfg.StrOpt('guest_log_exposed_logs', default='',
help='List of Guest Logs to expose for publishing.'), 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): class LogAccessForbidden(Forbidden):
message = _("You must be admin to %(action)s log '%(log)s'.") 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) 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): def enable_root(self):
"""Make a synchronous call to enable the root user for """Make a synchronous call to enable the root user for
access from anywhere access from anywhere

View File

@ -267,3 +267,19 @@ class Manager(manager.Manager):
LOG.debug("Executing cluster_addslots to assign hash slots %s-%s.", LOG.debug("Executing cluster_addslots to assign hash slots %s-%s.",
first_slot, last_slot) first_slot, last_slot)
self._app.cluster_addslots(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 oslo_log import log as logging
from trove.common import cfg from trove.common import cfg
from trove.common.db.redis.models import RedisRootUser
from trove.common import exception from trove.common import exception
from trove.common.i18n import _ from trove.common.i18n import _
from trove.common import instance as rd_instance from trove.common import instance as rd_instance
@ -37,6 +38,7 @@ LOG = logging.getLogger(__name__)
TIME_OUT = 1200 TIME_OUT = 1200
CONF = cfg.CONF CONF = cfg.CONF
CLUSTER_CFG = 'clustering' CLUSTER_CFG = 'clustering'
SYS_OVERRIDES_AUTH = 'auth_password'
packager = pkg.Package() packager = pkg.Package()
@ -401,6 +403,31 @@ class RedisApp(object):
LOG.exception(_('Error removing node from cluster.')) LOG.exception(_('Error removing node from cluster.'))
raise 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): class RedisAdmin(object):
"""Handles administrative tasks on the Redis database. """Handles administrative tasks on the Redis database.

View File

@ -730,6 +730,11 @@ class Manager(periodic_task.PeriodicTasks):
raise exception.DatastoreOperationNotSupported( raise exception.DatastoreOperationNotSupported(
operation='change_passwords', datastore=self.manager) 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): def enable_root(self, context):
LOG.debug("Enabling root.") LOG.debug("Enabling root.")
raise exception.DatastoreOperationNotSupported( raise exception.DatastoreOperationNotSupported(

View File

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

View File

@ -14,6 +14,7 @@
from mock import DEFAULT, MagicMock, Mock, patch from mock import DEFAULT, MagicMock, Mock, patch
from trove.common import utils as utils
from trove.guestagent import backup from trove.guestagent import backup
from trove.guestagent.common import configuration from trove.guestagent.common import configuration
from trove.guestagent.common.configuration import ImportOverrideStrategy 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._get_repl_info = MagicMock(return_value=repl_info)
self.manager.wait_for_txn(self.context, expected_txn_id) self.manager.wait_for_txn(self.context, expected_txn_id)
self.manager._get_repl_info.assert_any_call() 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)