diff --git a/apidocs/src/samples/db-disable-root-user-request-json.txt b/apidocs/src/samples/db-disable-root-user-request-json.txt new file mode 100644 index 0000000000..04702abeae --- /dev/null +++ b/apidocs/src/samples/db-disable-root-user-request-json.txt @@ -0,0 +1,7 @@ +DELETE /v1.0/1234/instances/44b277eb-39be-4921-be31-3d61b43651d7/root HTTP/1.1 +User-Agent: python-troveclient +Host: troveapi.org +X-Auth-Token: 87c6033c-9ff6-405f-943e-2deb73f278b7 +Accept: application/json +Content-Type: application/json + diff --git a/apidocs/src/samples/db-disable-root-user-response-json.txt b/apidocs/src/samples/db-disable-root-user-response-json.txt new file mode 100644 index 0000000000..031611acd7 --- /dev/null +++ b/apidocs/src/samples/db-disable-root-user-response-json.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json +Content-Length: 0 +Date: Mon, 18 Mar 2013 19:09:17 GMT + diff --git a/trove/common/cfg.py b/trove/common/cfg.py index 0a4a03d2db..2f37d896db 100644 --- a/trove/common/cfg.py +++ b/trove/common/cfg.py @@ -535,7 +535,7 @@ mysql_opts = [ deprecated_name='backup_incremental_strategy', deprecated_group='DEFAULT'), cfg.StrOpt('root_controller', - default='trove.extensions.common.service.DefaultRootController', + default='trove.extensions.mysql.service.MySQLRootController', help='Root controller implementation for mysql.'), cfg.ListOpt('ignore_users', default=['os_admin', 'root'], help='Users to exclude when listing users.', diff --git a/trove/extensions/common/models.py b/trove/extensions/common/models.py index 4b80fb409f..dfe77a6de7 100644 --- a/trove/extensions/common/models.py +++ b/trove/extensions/common/models.py @@ -76,6 +76,11 @@ class Root(object): return root_user + @classmethod + def delete(cls, context, instance_id): + load_and_verify(context, instance_id) + create_guest_client(context, instance_id).disable_root() + class RootHistory(object): diff --git a/trove/extensions/common/service.py b/trove/extensions/common/service.py index 5c3d1641df..2aff4c6ceb 100644 --- a/trove/extensions/common/service.py +++ b/trove/extensions/common/service.py @@ -49,6 +49,10 @@ class BaseDatastoreRootController(wsgi.Controller): def root_create(self, req, body, tenant_id, instance_id, is_cluster): pass + @abc.abstractmethod + def root_delete(self, req, tenant_id, instance_id, is_cluster): + pass + class DefaultRootController(BaseDatastoreRootController): @@ -79,6 +83,22 @@ class DefaultRootController(BaseDatastoreRootController): user_name, password) return wsgi.Result(views.RootCreatedView(root).data(), 200) + def root_delete(self, req, tenant_id, instance_id, is_cluster): + if is_cluster: + raise exception.ClusterOperationNotSupported( + operation='disable_root') + LOG.info(_LI("Disabling root for instance '%s'.") % instance_id) + LOG.info(_LI("req : '%s'\n\n") % req) + context = req.environ[wsgi.CONTEXT_KEY] + try: + found_user = self._find_root_user(context, instance_id) + except (ValueError, AttributeError) as e: + raise exception.BadRequest(msg=str(e)) + if not found_user: + raise exception.UserNotFound(uuid="root") + models.Root.delete(context, instance_id) + return wsgi.Result(None, 200) + class RootController(wsgi.Controller): """Controller for instance functionality.""" @@ -102,6 +122,16 @@ class RootController(wsgi.Controller): else: raise NoSuchOptError + def delete(self, req, tenant_id, instance_id): + datastore_manager, is_cluster = self._get_datastore(tenant_id, + instance_id) + root_controller = self.load_root_controller(datastore_manager) + if root_controller is not None: + return root_controller.root_delete(req, tenant_id, + instance_id, is_cluster) + else: + raise NoSuchOptError + def _get_datastore(self, tenant_id, instance_or_cluster_id): """ Returns datastore manager and a boolean diff --git a/trove/extensions/mysql/models.py b/trove/extensions/mysql/models.py index e79c70c189..d818085c38 100644 --- a/trove/extensions/mysql/models.py +++ b/trove/extensions/mysql/models.py @@ -46,9 +46,12 @@ class User(object): self.databases = databases @classmethod - def load(cls, context, instance_id, username, hostname): + def load(cls, context, instance_id, username, hostname, root_user=False): load_and_verify(context, instance_id) - validate = guest_models.MySQLUser() + if root_user: + validate = guest_models.RootUser() + else: + validate = guest_models.MySQLUser() validate.name = username validate.host = hostname client = create_guest_client(context, instance_id) diff --git a/trove/extensions/mysql/service.py b/trove/extensions/mysql/service.py index c57f80ad93..e69410e008 100644 --- a/trove/extensions/mysql/service.py +++ b/trove/extensions/mysql/service.py @@ -26,6 +26,7 @@ from trove.common.i18n import _ from trove.common import pagination from trove.common.utils import correct_id_with_req from trove.common import wsgi +from trove.extensions.common.service import DefaultRootController from trove.extensions.mysql.common import populate_users from trove.extensions.mysql.common import populate_validated_databases from trove.extensions.mysql.common import unquote_user_host @@ -294,3 +295,12 @@ class SchemaController(wsgi.Controller): def show(self, req, tenant_id, instance_id, id): raise webob.exc.HTTPNotImplemented() + + +class MySQLRootController(DefaultRootController): + + def _find_root_user(self, context, instance_id): + user = guest_models.MySQLRootUser() + return models.User.load(context, instance_id, + user.name, user.host, + root_user=True) diff --git a/trove/extensions/routes/mysql.py b/trove/extensions/routes/mysql.py index 8041463329..c0cb7ba9e6 100644 --- a/trove/extensions/routes/mysql.py +++ b/trove/extensions/routes/mysql.py @@ -71,7 +71,8 @@ class Mysql(extensions.ExtensionDescriptor): 'root', common_service.RootController(), parent={'member_name': 'instance', - 'collection_name': '{tenant_id}/instances'}) + 'collection_name': '{tenant_id}/instances'}, + collection_actions={'delete': 'DELETE'}) resources.append(resource) resource = extensions.ResourceExtension( diff --git a/trove/extensions/vertica/service.py b/trove/extensions/vertica/service.py index a23bf801db..59181cf730 100644 --- a/trove/extensions/vertica/service.py +++ b/trove/extensions/vertica/service.py @@ -15,6 +15,8 @@ from oslo_log import log as logging +from trove.common import cfg +from trove.common import exception from trove.common.i18n import _LI from trove.common import wsgi from trove.extensions.common.service import BaseDatastoreRootController @@ -23,6 +25,8 @@ from trove.extensions.vertica import models from trove.instance.models import DBInstance LOG = logging.getLogger(__name__) +CONF = cfg.CONF +MANAGER = CONF.datastore_manager class VerticaRootController(BaseDatastoreRootController): @@ -74,6 +78,10 @@ class VerticaRootController(BaseDatastoreRootController): return self.instance_root_create(req, body, master_instance_id, cluster_instances) + def delete(self, req, tenant_id, instance_id): + raise exception.DatastoreOperationNotSupported( + operation='disable_root', datastore=MANAGER) + def _get_cluster_instance_id(self, tenant_id, cluster_id): args = {'tenant_id': tenant_id, 'cluster_id': cluster_id} cluster_instances = DBInstance.find_all(**args).all() diff --git a/trove/guestagent/datastore/experimental/postgresql/service/root.py b/trove/guestagent/datastore/experimental/postgresql/service/root.py index d29fd57b0d..e83db66fbf 100644 --- a/trove/guestagent/datastore/experimental/postgresql/service/root.py +++ b/trove/guestagent/datastore/experimental/postgresql/service/root.py @@ -87,3 +87,10 @@ class PgSqlRoot(PgSqlUsers): ) pgutil.psql(query, timeout=30) return user + + def disable_root(self, context): + """Generate a new random password for the public superuser account. + Do not disable its access rights. Once enabled the account should + stay that way. + """ + self.enable_root(context) diff --git a/trove/guestagent/datastore/manager.py b/trove/guestagent/datastore/manager.py index 4b8c51bb4b..4cca839f45 100644 --- a/trove/guestagent/datastore/manager.py +++ b/trove/guestagent/datastore/manager.py @@ -587,6 +587,11 @@ class Manager(periodic_task.PeriodicTasks): raise exception.DatastoreOperationNotSupported( operation='enable_root_with_password', datastore=self.manager) + def disable_root(self, context): + LOG.debug("Disabling root.") + raise exception.DatastoreOperationNotSupported( + operation='disable_root', datastore=self.manager) + def is_root_enabled(self, context): LOG.debug("Checking if root was ever enabled.") raise exception.DatastoreOperationNotSupported( diff --git a/trove/guestagent/datastore/mysql_common/manager.py b/trove/guestagent/datastore/mysql_common/manager.py index cb212ad15c..a70e9b7136 100644 --- a/trove/guestagent/datastore/mysql_common/manager.py +++ b/trove/guestagent/datastore/mysql_common/manager.py @@ -175,6 +175,9 @@ class MySqlManager(manager.Manager): def is_root_enabled(self, context): return self.mysql_admin().is_root_enabled() + def disable_root(self, context): + return self.mysql_admin().disable_root() + def _perform_restore(self, backup_info, context, restore_location, app): LOG.info(_("Restoring database from backup %s.") % backup_info['id']) try: diff --git a/trove/guestagent/datastore/mysql_common/service.py b/trove/guestagent/datastore/mysql_common/service.py index 2c7a335112..8207964550 100644 --- a/trove/guestagent/datastore/mysql_common/service.py +++ b/trove/guestagent/datastore/mysql_common/service.py @@ -416,6 +416,11 @@ class BaseMySqlAdmin(object): """ return self.mysql_root_access.enable_root(root_password) + def disable_root(self): + """Disable the root user global access + """ + return self.mysql_root_access.disable_root() + def list_databases(self, limit=None, marker=None, include_marker=False): """List databases the user created on this mysql instance.""" LOG.debug("---Listing Databases---") @@ -1011,10 +1016,7 @@ class BaseMySqlRootAccess(object): """Enable the root user global access and/or reset the root password. """ - user = models.RootUser() - user.name = "root" - user.host = "%" - user.password = root_password or utils.generate_random_password() + user = models.MySQLRootUser(root_password) with self.local_sql_client(self.mysql_app.get_engine()) as client: print(client) try: @@ -1044,3 +1046,9 @@ class BaseMySqlRootAccess(object): t = text(str(g)) client.execute(t) return user.serialize() + + def disable_root(self): + """Disable the root user global access + """ + with self.local_sql_client(self.mysql_app.get_engine()) as client: + client.execute(text(sql_query.REMOVE_ROOT)) diff --git a/trove/guestagent/db/models.py b/trove/guestagent/db/models.py index 5d06e7c3b4..04bbe4ce08 100644 --- a/trove/guestagent/db/models.py +++ b/trove/guestagent/db/models.py @@ -22,6 +22,7 @@ import netaddr from trove.common import cfg from trove.common import exception from trove.common.i18n import _ +from trove.common import utils CONF = cfg.CONF @@ -840,6 +841,19 @@ class MySQLUser(Base): class RootUser(MySQLUser): """Overrides _ignore_users from the MySQLUser class.""" - +# _ignore_users = [] def __init__(self): self._ignore_users = [] + + +class MySQLRootUser(RootUser): + """Represents the MySQL root user.""" + + def __init__(self, password=None): + super(MySQLRootUser, self).__init__() + self._name = "root" + self._host = "%" + if password is None: + self._password = utils.generate_random_password() + else: + self._password = password diff --git a/trove/tests/api/root.py b/trove/tests/api/root.py index 3ab574795c..f18a29e36b 100644 --- a/trove/tests/api/root.py +++ b/trove/tests/api/root.py @@ -101,6 +101,14 @@ class TestRoot(object): self._verify_root_timestamp(instance_info.id) @test(depends_on=[test_root_initially_disabled_details]) + def test_root_disable_when_root_not_enabled(self): + reh = self.dbaas_admin.management.root_enabled_history + self.root_enabled_timestamp = reh(instance_info.id).enabled + assert_raises(exceptions.NotFound, self.dbaas.root.delete, + instance_info.id) + self._verify_root_timestamp(instance_info.id) + + @test(depends_on=[test_root_disable_when_root_not_enabled]) def test_enable_root(self): self._root() @@ -170,3 +178,11 @@ class TestRoot(object): """Even if root was enabled, the user root cannot be deleted.""" assert_raises(exceptions.BadRequest, self.dbaas.users.delete, instance_info.id, "root") + + @test(depends_on=[test_root_still_enabled_details]) + def test_root_disable(self): + reh = self.dbaas_admin.management.root_enabled_history + self.root_enabled_timestamp = reh(instance_info.id).enabled + self.dbaas.root.delete(instance_info.id) + assert_equal(200, self.dbaas.last_http_code) + self._verify_root_timestamp(instance_info.id) diff --git a/trove/tests/api/root_on_create.py b/trove/tests/api/root_on_create.py index b606f4cb3c..f0d0225973 100644 --- a/trove/tests/api/root_on_create.py +++ b/trove/tests/api/root_on_create.py @@ -116,3 +116,16 @@ class TestRootOnCreate(object): enabled = self.enabled(self.instance_id).rootEnabled assert_equal(200, self.dbaas.last_http_code) assert_true(enabled) + + @test(depends_on=[test_root_still_enabled]) + def test_root_disable(self): + """ + After root disable ensure the the history enabled flag + is still enabled. + """ + self.dbaas.root.delete(self.instance_id) + assert_equal(200, self.dbaas.last_http_code) + + enabled = self.enabled(self.instance_id).rootEnabled + assert_equal(200, self.dbaas.last_http_code) + assert_true(enabled) diff --git a/trove/tests/examples/snippets.py b/trove/tests/examples/snippets.py index 3d739d894c..535e1bf150 100644 --- a/trove/tests/examples/snippets.py +++ b/trove/tests/examples/snippets.py @@ -497,6 +497,17 @@ class Root(Example): lambda client: client.root.is_root_enabled(json_instance.id)) assert_equal(results[JSON_INDEX].rootEnabled, True) + @test(depends_on=[get_check_root_access]) + def delete_disable_root_access(self): + self.snippet( + "disable_root_user", + "/instances/%s/root" % json_instance.id, + "DELETE", 200, "OK", + lambda client: client.root.delete(json_instance.id)) + + # restore root for subsequent tests + self.post_enable_root_access() + class ActiveMixin(Example): """Adds a method to wait for instance status to become ACTIVE.""" diff --git a/trove/tests/fakes/guestagent.py b/trove/tests/fakes/guestagent.py index d13b6650c3..43fb11e6f2 100644 --- a/trove/tests/fakes/guestagent.py +++ b/trove/tests/fakes/guestagent.py @@ -157,6 +157,11 @@ class FakeGuest(object): "_databases": [], }) + def disable_root(self): + self.delete_user({ + "_name": "root", + "_host": "%"}) + def delete_user(self, user): username = user['_name'] self._check_username(username) diff --git a/trove/tests/unittests/guestagent/test_dbaas.py b/trove/tests/unittests/guestagent/test_dbaas.py index 95d13c8db2..37194e891d 100644 --- a/trove/tests/unittests/guestagent/test_dbaas.py +++ b/trove/tests/unittests/guestagent/test_dbaas.py @@ -42,6 +42,7 @@ from trove.conductor import api as conductor_api from trove.guestagent.common.configuration import ImportOverrideStrategy from trove.guestagent.common import operating_system from trove.guestagent.common.operating_system import FileMode +from trove.guestagent.common import sql_query from trove.guestagent.datastore.experimental.cassandra import ( service as cass_service) from trove.guestagent.datastore.experimental.cassandra import ( @@ -1636,10 +1637,14 @@ class MySqlRootStatusTest(trove_testtools.TestCase): mock_execute.assert_any_call(TextClauseMatcher( 'UPDATE mysql.user')) - def test_enable_root_failed(self): - with patch.object(models.MySQLUser, '_is_valid_user_name', - return_value=False): - self.assertRaises(ValueError, MySqlAdmin().enable_root) + def test_root_disable(self): + with patch.object(self.mock_client, + 'execute', return_value=None) as mock_execute: + # invocation + MySqlRootAccess().disable_root() + # verification + mock_execute.assert_any_call(TextClauseMatcher( + sql_query.REMOVE_ROOT)) class MockStats: diff --git a/trove/tests/unittests/guestagent/test_mysql_manager.py b/trove/tests/unittests/guestagent/test_mysql_manager.py index 6f803d582b..308f51c13b 100644 --- a/trove/tests/unittests/guestagent/test_mysql_manager.py +++ b/trove/tests/unittests/guestagent/test_mysql_manager.py @@ -167,6 +167,11 @@ class GuestAgentManagerTest(trove_testtools.TestCase): self.assertThat(user_id, Is(enable_root_mock.return_value)) enable_root_mock.assert_any_call() + @patch.object(dbaas.MySqlAdmin, 'disable_root') + def test_disable_root(self, disable_root_mock): + self.manager.disable_root(self.context) + disable_root_mock.assert_any_call() + @patch.object(dbaas.MySqlAdmin, 'is_root_enabled', return_value=True) def test_is_root_enabled(self, is_root_enabled_mock): is_enabled = self.manager.is_root_enabled(self.context)