Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add password/account locking/unlocking in user.present state on supported operating systems #62857

Merged
merged 2 commits into from
Oct 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()