From 4e1bbec71d8220906721a3f2144550062618f098 Mon Sep 17 00:00:00 2001 From: nicholasmhughes Date: Mon, 10 Oct 2022 17:53:53 -0400 Subject: [PATCH] fixes saltstack/salt#62856 add password/account locking/unlocking in user.present state on supported operating systems --- changelog/62856.added | 1 + salt/states/user.py | 29 +++++ tests/pytests/unit/states/test_user.py | 146 +++++++++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 changelog/62856.added diff --git a/changelog/62856.added b/changelog/62856.added new file mode 100644 index 00000000000..94783785aa5 --- /dev/null +++ b/changelog/62856.added @@ -0,0 +1 @@ +Add password/account locking/unlocking in user.present state on supported operating systems diff --git a/salt/states/user.py b/salt/states/user.py index 2a603f46c70..ed2d5a05f48 100644 --- a/salt/states/user.py +++ b/salt/states/user.py @@ -78,6 +78,7 @@ def _changes( win_description=None, allow_uid_change=False, allow_gid_change=False, + password_lock=None, ): """ Return a dict of the changes required for a user if the user is present, @@ -158,6 +159,10 @@ def _changes( change["warndays"] = warndays if expire and lshad["expire"] != expire: change["expire"] = expire + if (password_lock and not lshad["passwd"].startswith("!")) or ( + password_lock is False and lshad["passwd"].startswith("!") + ): + change["password_lock"] = password_lock elif "shadow.info" in __salt__ and salt.utils.platform.is_windows(): if ( expire @@ -166,6 +171,8 @@ def _changes( != salt.utils.dateutils.strftime(expire) ): change["expire"] = expire + if password_lock is False and lusr["account_locked"]: + change["password_lock"] = password_lock # GECOS fields fullname = salt.utils.data.decode(fullname) @@ -266,6 +273,7 @@ def present( nologinit=False, allow_uid_change=False, allow_gid_change=False, + password_lock=None, ): """ Ensure that the named user is present with the specified properties @@ -368,6 +376,14 @@ def present( empty_password Set to True to enable password-less login for user, Default is ``False``. + password_lock + Set to ``False`` to unlock a user's password (or Windows account). On + non-Windows systems ONLY, this parameter can be set to ``True`` to lock + a user's password. Default is ``None``, which does not take action on + the password (or Windows account). + + .. versionadded:: 3006.0 + shell The login shell, defaults to the system default shell @@ -597,6 +613,7 @@ def present( win_description, allow_uid_change, allow_gid_change, + password_lock=password_lock, ) except CommandExecutionError as exc: ret["result"] = False @@ -633,6 +650,17 @@ def present( if changes.pop("empty_password", False) is True: __salt__["shadow.del_password"](name) + if "password_lock" in changes: + passlock = changes.pop("password_lock") + if not passlock and salt.utils.platform.is_windows(): + __salt__["shadow.unlock_account"](name) + elif not passlock: + __salt__["shadow.unlock_password"](name) + elif passlock and not salt.utils.platform.is_windows(): + __salt__["shadow.lock_password"](name) + else: + log.warning("Account locking is not available on Windows.") + if "date" in changes: del changes["date"] __salt__["shadow.set_date"](name, date) @@ -766,6 +794,7 @@ def present( win_description, allow_uid_change=True, allow_gid_change=True, + password_lock=password_lock, ) # allow_uid_change and allow_gid_change passed as True to avoid race # conditions where a uid/gid is modified outside of Salt. If an diff --git a/tests/pytests/unit/states/test_user.py b/tests/pytests/unit/states/test_user.py index 50dee9d959e..94e69d70ed0 100644 --- a/tests/pytests/unit/states/test_user.py +++ b/tests/pytests/unit/states/test_user.py @@ -7,6 +7,7 @@ import logging import pytest import salt.states.user as user +import salt.utils.platform from tests.support.mock import MagicMock, Mock, patch log = logging.getLogger(__name__) @@ -313,3 +314,148 @@ def test_gecos_field_changes_in_user_present(): ): res = user.present("Foo", homephone=44566, fullname="Bar Bar") assert res["changes"] == {"homephone": "44566", "fullname": "Bar Bar"} + + +def test_present_password_lock_test_mode(): + ret = { + "name": "salt", + "changes": {}, + "result": True, + "comment": "User salt is present and up to date", + } + mock_info = MagicMock( + return_value={ + "uid": 5000, + "gid": 5000, + "groups": [], + "home": "/home/salt", + "fullname": "Salty McSalterson", + } + ) + shadow_info = MagicMock( + side_effect=[ + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": "!"}, + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": ""}, + ] + ) + shadow_hash = MagicMock(return_value="abcd") + + with patch.dict(user.__grains__, {"kernel": "Linux"}), patch.dict( + user.__salt__, + { + "shadow.default_hash": shadow_hash, + "shadow.info": shadow_info, + "user.info": mock_info, + "file.gid_to_group": MagicMock(return_value=5000), + }, + ), patch.dict(user.__opts__, {"test": True}): + assert user.present("salt", createhome=False, password_lock=True) == ret + ret.update( + { + "comment": "The following user attributes are set to be changed:\npassword_lock: True\n" + } + ) + ret.update({"result": None}) + assert user.present("salt", createhome=False, password_lock=True) == ret + + +def test_present_password_lock(): + ret = { + "name": "salt", + "changes": {"passwd": "XXX-REDACTED-XXX"}, + "result": True, + "comment": "Updated user salt", + } + mock_info = MagicMock( + return_value={ + "uid": 5000, + "gid": 5000, + "groups": [], + "home": "/home/salt", + "fullname": "Salty McSalterson", + } + ) + shadow_info = MagicMock( + side_effect=[ + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": ""}, + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": ""}, + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": "!"}, + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": "!"}, + ] + ) + shadow_hash = MagicMock(return_value="abcd") + + unlock_account = MagicMock() + unlock_password = MagicMock() + lock_password = MagicMock() + + with patch.dict(user.__grains__, {"kernel": "Linux"}), patch.dict( + user.__salt__, + { + "shadow.default_hash": shadow_hash, + "shadow.info": shadow_info, + "user.info": mock_info, + "file.gid_to_group": MagicMock(return_value=5000), + "shadow.unlock_account": unlock_account, + "shadow.unlock_password": unlock_password, + "shadow.lock_password": lock_password, + }, + ), patch.dict(user.__opts__, {"test": False}): + assert user.present("salt", createhome=False, password_lock=True) == ret + unlock_password.assert_not_called() + unlock_account.assert_not_called() + if salt.utils.platform.is_windows(): + lock_password.assert_not_called() + else: + lock_password.assert_called_once() + + +def test_present_password_unlock(): + ret = { + "name": "salt", + "changes": {"passwd": "XXX-REDACTED-XXX"}, + "result": True, + "comment": "Updated user salt", + } + mock_info = MagicMock( + return_value={ + "uid": 5000, + "gid": 5000, + "groups": [], + "home": "/home/salt", + "fullname": "Salty McSalterson", + } + ) + shadow_info = MagicMock( + side_effect=[ + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": "!"}, + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": "!"}, + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": ""}, + {"min": 2, "max": 88888, "inact": 77, "warn": 14, "passwd": ""}, + ] + ) + shadow_hash = MagicMock(return_value="abcd") + + unlock_account = MagicMock() + unlock_password = MagicMock() + lock_password = MagicMock() + with patch.dict(user.__grains__, {"kernel": "Linux"}), patch.dict( + user.__salt__, + { + "shadow.default_hash": shadow_hash, + "shadow.info": shadow_info, + "user.info": mock_info, + "file.gid_to_group": MagicMock(return_value=5000), + "shadow.unlock_account": unlock_account, + "shadow.unlock_password": unlock_password, + "shadow.lock_password": lock_password, + }, + ), patch.dict(user.__opts__, {"test": False}): + assert user.present("salt", createhome=False, password_lock=False) == ret + lock_password.assert_not_called() + if salt.utils.platform.is_windows(): + unlock_account.assert_called_once() + unlock_password.assert_not_called() + else: + unlock_password.assert_called_once() + unlock_account.assert_not_called()