Skip to content

Commit

Permalink
fixes #62856 add password/account locking/unlocking in user.present s…
Browse files Browse the repository at this point in the history
…tate on supported operating systems
  • Loading branch information
nicholasmhughes authored and Megan Wilhite committed Oct 11, 2022
1 parent 8f27d32 commit 4e1bbec
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 0 deletions.
1 change: 1 addition & 0 deletions changelog/62856.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add password/account locking/unlocking in user.present state on supported operating systems
29 changes: 29 additions & 0 deletions salt/states/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -766,6 +794,7 @@ def _change_homedir(name, val):
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
Expand Down
146 changes: 146 additions & 0 deletions tests/pytests/unit/states/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

import salt.states.user as user
import salt.utils.platform
from tests.support.mock import MagicMock, Mock, patch

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -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()

0 comments on commit 4e1bbec

Please sign in to comment.