diff --git a/landscape/client/manager/tests/test_usermanager.py b/landscape/client/manager/tests/test_usermanager.py index fe02d032b..ce6e16bf4 100644 --- a/landscape/client/manager/tests/test_usermanager.py +++ b/landscape/client/manager/tests/test_usermanager.py @@ -1,5 +1,6 @@ import os from unittest.mock import Mock +from unittest.mock import patch from landscape.client.manager.plugin import FAILED from landscape.client.manager.plugin import SUCCEEDED @@ -9,6 +10,7 @@ from landscape.client.tests.helpers import LandscapeTest from landscape.client.tests.helpers import ManagerHelper from landscape.client.user.provider import UserManagementError +from landscape.client.user.tests.helpers import FakeSnapdUserManagement from landscape.client.user.tests.helpers import FakeUserManagement from landscape.client.user.tests.helpers import FakeUserProvider from landscape.lib.persist import Persist @@ -36,14 +38,18 @@ def tearDown(self): for plugin in self.plugins: plugin.stop() - def setup_environment(self, users, groups, shadow_file): + def setup_environment(self, users, groups, shadow_file, is_core=False): provider = FakeUserProvider( users=users, groups=groups, shadow_file=shadow_file, ) user_monitor = UserMonitor(provider=provider) - management = FakeUserManagement(provider=provider) + + if is_core: + management = FakeSnapdUserManagement(provider=provider) + else: + management = FakeUserManagement(provider=provider) user_manager = UserManager( management=management, shadow_file=shadow_file, @@ -378,6 +384,64 @@ def handle_callback(result): result.addCallback(handle_callback) return result + @patch("landscape.client.manager.usermanager.IS_CORE", "1") + def test_add_user_event_on_core(self): + """ + When an C{add-user} event is received the user should be + added. Two messages should be generated: a C{users} message + with details about the change and an C{operation-result} with + details of the outcome of the operation. + """ + + def handle_callback(result): + messages = self.broker_service.message_store.get_pending_messages() + self.assertMessages( + messages, + [ + { + "type": "operation-result", + "status": SUCCEEDED, + "operation-id": 123, + "timestamp": 0, + "result-text": "add_user succeeded", + }, + { + "timestamp": 0, + "type": "users", + "operation-id": 123, + "create-users": [ + { + "home-phone": None, + "username": "john-doe", + "uid": 1000, + "enabled": True, + "location": None, + "work-phone": None, + "name": "john.doe@example.com", + "primary-gid": 1000, + }, + ], + }, + ], + ) + + shadow_file = self.makeFile("""st3v3nmw:*:19758:0:99999:7:::""") + self.setup_environment([], [], shadow_file, is_core=True) + + result = self.manager.dispatch_message( + { + "type": "add-user", + "username": "john-doe", + "email": "john.doe@example.com", + "sudoer": False, + "force-managed": True, + "operation-id": 123, + }, + ) + + result.addCallback(handle_callback) + return result + def test_edit_user_event(self): """ When a C{edit-user} message is received the user should be @@ -834,6 +898,62 @@ def handle_callback(result): result.addCallback(handle_callback) return result + @patch("landscape.client.manager.usermanager.IS_CORE", "1") + def test_remove_user_event_on_core(self): + """ + When a C{remove-user} event is received, the user should be removed. + Two messages should be generated: a C{users} message with details + about the change and an C{operation-result} with details of the + outcome of the operation. + """ + + def handle_callback(result): + messages = self.broker_service.message_store.get_pending_messages() + self.assertEqual(len(messages), 3) + # Ignore the message created by plugin.run. + self.assertMessages( + [messages[2], messages[1]], + [ + { + "timestamp": 0, + "delete-users": ["john-doe"], + "type": "users", + "operation-id": 39, + }, + { + "type": "operation-result", + "status": SUCCEEDED, + "operation-id": 39, + "timestamp": 0, + "result-text": "remove_user succeeded", + }, + ], + ) + + users = [ + ( + "john-doe", + "x", + 1000, + 1000, + "john.doe@example.com,BtrGAhK,,", + "/home/user", + "/bin/zsh", + ), + ] + shadow_file = self.makeFile("""st3v3nmw:*:19758:0:99999:7:::""") + self.setup_environment(users, [], shadow_file, is_core=True) + result = self.manager.dispatch_message( + { + "username": "john-doe", + "delete-home": True, + "type": "remove-user", + "operation-id": 39, + }, + ) + result.addCallback(handle_callback) + return result + def test_lock_user_event(self): """ When a C{lock-user} event is received the user should be diff --git a/landscape/client/manager/usermanager.py b/landscape/client/manager/usermanager.py index 8984ccfe6..db350261e 100644 --- a/landscape/client/manager/usermanager.py +++ b/landscape/client/manager/usermanager.py @@ -1,10 +1,12 @@ import logging +from landscape.client import IS_CORE from landscape.client.amp import ComponentConnector from landscape.client.amp import ComponentPublisher from landscape.client.amp import remote from landscape.client.manager.plugin import ManagerPlugin from landscape.client.monitor.usermonitor import RemoteUserMonitorConnector +from landscape.client.user.management import SnapdUserManagement from landscape.client.user.management import UserManagement @@ -13,20 +15,30 @@ class UserManager(ManagerPlugin): name = "usermanager" def __init__(self, management=None, shadow_file="/etc/shadow"): - self._management = management or UserManagement() + if IS_CORE: + management = management or SnapdUserManagement() + shadow_file = shadow_file or "/var/lib/extrausers/shadow" + self._message_types = { + "add-user": self._add_user_core, + "remove-user": self._remove_user_core, + } + else: + management = management or UserManagement() + self._message_types = { + "add-user": self._add_user, + "edit-user": self._edit_user, + "lock-user": self._lock_user, + "unlock-user": self._unlock_user, + "remove-user": self._remove_user, + "add-group": self._add_group, + "edit-group": self._edit_group, + "remove-group": self._remove_group, + "add-group-member": self._add_group_member, + "remove-group-member": self._remove_group_member, + } + + self._management = management self._shadow_file = shadow_file - self._message_types = { - "add-user": self._add_user, - "edit-user": self._edit_user, - "lock-user": self._lock_user, - "unlock-user": self._unlock_user, - "remove-user": self._remove_user, - "add-group": self._add_group, - "edit-group": self._edit_group, - "remove-group": self._remove_group, - "add-group-member": self._add_group_member, - "remove-group-member": self._remove_group_member, - } self._publisher = None def register(self, registry): @@ -118,6 +130,15 @@ def _add_user(self, message): message["home-number"], ) + def _add_user_core(self, message): + """Run an C{add-user} operation on Core.""" + return self._management.add_user( + message["username"], + message["email"], + message["sudoer"], + message["force-managed"], + ) + def _edit_user(self, message): """Run an C{edit-user} operation.""" return self._management.set_user_details( @@ -145,6 +166,10 @@ def _remove_user(self, message): message["delete-home"], ) + def _remove_user_core(self, message): + """Run a C{remove-user} operation on Core.""" + return self._management.remove_user(message["username"]) + def _add_group(self, message): """Run an C{add-group} operation.""" return self._management.add_group(message["groupname"]) diff --git a/landscape/client/monitor/tests/test_usermonitor.py b/landscape/client/monitor/tests/test_usermonitor.py index 1fddd9a69..d716134c3 100644 --- a/landscape/client/monitor/tests/test_usermonitor.py +++ b/landscape/client/monitor/tests/test_usermonitor.py @@ -1,6 +1,7 @@ import os from unittest.mock import ANY from unittest.mock import Mock +from unittest.mock import patch from twisted.internet.defer import fail @@ -208,6 +209,50 @@ def test_new_message_after_resynchronize_event(self): ], ) + @patch("landscape.client.manager.usermanager.IS_CORE", "1") + def test_new_message_after_resynchronize_event_on_core(self): + """ + When a 'resynchronize' reactor event is fired, a new session is + created and the UserMonitor creates a new message. + """ + self.provider.users = [ + ( + "john-doe", + "x", + 1000, + 1000, + "john.doe@example.com", + "/home/user", + "/bin/zsh", + ), + ] + self.broker_service.message_store.set_accepted_types(["users"]) + plugin = UserMonitor(self.provider) + self.monitor.add(plugin) + plugin.client.broker.message_store.drop_session_ids() + deferred = self.reactor.fire("resynchronize")[0] + self.successResultOf(deferred) + self.assertMessages( + self.broker_service.message_store.get_pending_messages(), + [ + { + "create-users": [ + { + "enabled": True, + "home-phone": None, + "location": None, + "name": "john.doe@example.com", + "primary-gid": 1000, + "uid": 1000, + "username": "john-doe", + "work-phone": None, + }, + ], + "type": "users", + }, + ], + ) + def test_wb_resynchronize_event_with_global_scope(self): """ When a C{resynchronize} event, with global scope, occurs we act exactly diff --git a/landscape/client/monitor/usermonitor.py b/landscape/client/monitor/usermonitor.py index 5d2175303..d58bd1d8e 100644 --- a/landscape/client/monitor/usermonitor.py +++ b/landscape/client/monitor/usermonitor.py @@ -3,6 +3,7 @@ from twisted.internet.defer import maybeDeferred +from landscape.client import IS_CORE from landscape.client.amp import ComponentConnector from landscape.client.amp import ComponentPublisher from landscape.client.amp import remote @@ -27,8 +28,14 @@ class UserMonitor(MonitorPlugin): name = "usermonitor" def __init__(self, provider=None): - if provider is None: - provider = UserProvider() + if IS_CORE: + provider = provider or UserProvider( + passwd_file="/var/lib/extrausers/passwd", + group_file="/var/lib/extrausers/group", + ) + else: + provider = provider or UserProvider() + self._provider = provider self._publisher = None diff --git a/landscape/client/user/management.py b/landscape/client/user/management.py index 2f67c9f3f..1b37671a3 100644 --- a/landscape/client/user/management.py +++ b/landscape/client/user/management.py @@ -3,9 +3,12 @@ # API, with thorough usage of exceptions and such, instead of pipes to # subprocesses. liboobs (i.e. System Tools) is a possibility, and has # documentation now in the 2.17 series, but is not wrapped to Python. +import json import logging import subprocess +from landscape.client import snap_http +from landscape.client.snap_http import SnapdHttpException from landscape.client.user.provider import UserManagementError from landscape.client.user.provider import UserProvider @@ -284,3 +287,38 @@ def call_popen(self, args): output = popen.stdout.read() result = popen.wait() return result, output + + +class SnapdUserManagement: + """Manage users via the Snapd API.""" + + def __init__(self, provider=None): + self._provider = provider or UserProvider( + passwd_file="/var/lib/extrausers/passwd", + group_file="/var/lib/extrausers/group", + ) + + def add_user(self, username, email, sudoer=False, force_managed=False): + """Add a user via the Snapd API.""" + try: + response = snap_http.add_user( + username, + email, + sudoer=sudoer, + force_managed=force_managed, + ) + except SnapdHttpException as e: + result = json.loads(e.args[0])["result"] + raise UserManagementError(result) + + return response.result + + def remove_user(self, username): + """Remove a user via the Snapd API.""" + try: + response = snap_http.remove_user(username) + except SnapdHttpException as e: + result = json.loads(e.args[0])["result"] + raise UserManagementError(result) + + return response.result diff --git a/landscape/client/user/tests/helpers.py b/landscape/client/user/tests/helpers.py index be3fbc561..cc4fbae56 100644 --- a/landscape/client/user/tests/helpers.py +++ b/landscape/client/user/tests/helpers.py @@ -191,6 +191,48 @@ def update_provider_from_groups(self): self.provider.groups = provider_list +class FakeSnapdUserManagement: + def __init__(self, provider=None): + self.shadow_file = getattr(provider, "shadow_file", None) + self.provider = provider + self._users = {} + self._next_uid = 1000 + + for data in self.provider.get_users(): + self._users[data["username"]] = data + self._next_uid = max(data["uid"], self._next_uid) + + def add_user(self, username, email, sudoer=False, force_managed=False): + self._users[self._next_uid] = { + "uid": self._next_uid, + "username": username, + "email": email, + "sudoer": sudoer, + "force-managed": force_managed, + } + userdata = ( + username, + "x", + self._next_uid, + self._next_uid, + email, + "/home/user", + "/bin/zsh", + ) + self.provider.users.append(userdata) + self._next_uid += 1 + return "add_user succeeded" + + def remove_user(self, username): + del self._users[username] + remaining_users = [] + for user in self.provider.users: + if user[0] != username: + remaining_users.append(user) + self.provider.users = remaining_users + return "remove_user succeeded" + + class FakeUserProvider(UserProviderBase): def __init__( self, diff --git a/landscape/client/user/tests/test_management.py b/landscape/client/user/tests/test_management.py index 0b08883c8..58edc18c2 100644 --- a/landscape/client/user/tests/test_management.py +++ b/landscape/client/user/tests/test_management.py @@ -1,4 +1,8 @@ +from unittest import mock + +from landscape.client.snap_http import SnapdHttpException from landscape.client.tests.helpers import LandscapeTest +from landscape.client.user.management import SnapdUserManagement from landscape.client.user.management import UserManagement from landscape.client.user.management import UserManagementError from landscape.client.user.provider import GroupNotFoundError @@ -833,3 +837,84 @@ def test_remove_group_fails(self): management.remove_group, "ubuntu", ) + + +class SnapdUserManagementTest(LandscapeTest): + def setUp(self): + LandscapeTest.setUp(self) + self.shadow_file = self.makeFile("""st3v3nmw:*:19758:0:99999:7:::""") + + self.snap_http = mock.patch( + "landscape.client.user.management.snap_http", + ).start() + + def tearDown(self): + mock.patch.stopall() + + def test_add_user(self): + """L{SnapdUserManagement.add_user} should add a user.""" + groups = [("users", "x", 1001, [])] + provider = FakeUserProvider(groups=groups, popen=MockPopen("")) + management = SnapdUserManagement(provider=provider) + management.add_user( + username="john-doe", + email="john.doe@example.com", + force_managed=True, + ) + + self.snap_http.add_user.assert_called_once_with( + "john-doe", + "john.doe@example.com", + sudoer=False, + force_managed=True, + ) + + def test_add_user_exception(self): + """ + L{SnapdUserManagement.add_user} should raise C{SnapdHttpException}. + """ + self.snap_http.add_user.side_effect = SnapdHttpException( + '{"type":"error","status-code":400,"status":"Bad Request","result"' + ':{"message":"cannot create user: device already managed"}}', + ) + + groups = [("users", "x", 1001, [])] + provider = FakeUserProvider(groups=groups, popen=MockPopen("")) + management = SnapdUserManagement(provider=provider) + + with self.assertRaises(UserManagementError): + management.add_user("john-doe", email="john.doe@example.com") + + self.snap_http.add_user.assert_called_once_with( + "john-doe", + "john.doe@example.com", + sudoer=False, + force_managed=False, + ) + + def test_remove_user(self): + """L{SnapdUserManagement.add_user} should remove a user.""" + groups = [("users", "x", 1001, [])] + provider = FakeUserProvider(groups=groups, popen=MockPopen("")) + management = SnapdUserManagement(provider=provider) + management.remove_user("john-doe") + + self.snap_http.remove_user.assert_called_once_with("john-doe") + + def test_remove_user_exception(self): + """ + L{SnapdUserManagement.remove_user} should raise C{SnapdHttpException}. + """ + self.snap_http.remove_user.side_effect = SnapdHttpException( + '{"type":"error","status-code":400,"status":"Bad Request",' + '"result":{"message":"user \\"asfd\\" is not known"}}', + ) + + groups = [("users", "x", 1001, [])] + provider = FakeUserProvider(groups=groups, popen=MockPopen("")) + management = SnapdUserManagement(provider=provider) + + with self.assertRaises(UserManagementError): + management.remove_user("jane-doe") + + self.snap_http.remove_user.assert_called_once_with("jane-doe")