Skip to content
Draft
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
11 changes: 11 additions & 0 deletions doc/ref/states/requisites.rst
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,17 @@ if any of the watched states changes.
In the example above, ``cmd.run`` will run only if there are changes in the
``file.managed`` state.

.. note::

When multiple state declarations share the same ID, ``onchanges`` still
resolves by the referenced state type and name. If a requisite resolves
back to the same state (self-reference), Salt ignores it to avoid
recursive requisites and logs a warning. Use distinct IDs if you need to
make ordering explicit or if name-based matching is ambiguous. If you
prefer ID-based matching, use the ``id`` requisite key explicitly to
avoid ambiguity between IDs and names. Running ``state.show_lowstate``
can help verify how a requisite resolves during compilation.

An easy mistake to make is using ``onchanges_in`` when ``onchanges`` is the
correct choice, as seen in this next example.

Expand Down
17 changes: 11 additions & 6 deletions salt/modules/cmdmod.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@

import salt.platform.win
from salt.utils.win_functions import escape_argument as _cmd_quote
from salt.utils.win_runas import runas as win_runas
import salt.utils.win_runas as win_runas

HAS_WIN_RUNAS = True
else:
Expand Down Expand Up @@ -788,7 +788,7 @@ def _run(
if change_windows_codepage:
salt.utils.win_chcp.set_codepage_id(windows_codepage)
try:
proc = win_runas(cmd, runas, password, **new_kwargs)
proc = win_runas.runas(cmd, runas, password, **new_kwargs)
except (OSError, pywintypes.error) as exc:
msg = "Unable to run command '{}' with the context '{}', reason: {}".format(
cmd if output_loglevel is not None else "REDACTED",
Expand Down Expand Up @@ -3023,16 +3023,21 @@ def _cleanup_tempfile(path):

win_cwd = False
if salt.utils.platform.is_windows() and runas:
# Let's make sure the user exists first
if not __salt__["user.info"](runas):
# Resolve the user for domain/UPN support before creating the temp dir
try:
resolved_runas = win_runas.resolve_logon_credentials(runas)
except CommandExecutionError as exc:
msg = f"Invalid user: {runas}"
raise CommandExecutionError(msg)
raise CommandExecutionError(msg) from exc

if cwd is None:
# Create a temp working directory
cwd = tempfile.mkdtemp(dir=__opts__["cachedir"])
win_cwd = True
salt.utils.win_dacl.set_permissions(
obj_name=cwd, principal=runas, permissions="full_control"
obj_name=cwd,
principal=resolved_runas.get("sam_name") or runas,
permissions="full_control",
)

(_, ext) = os.path.splitext(salt.utils.url.split_env(source)[0])
Expand Down
9 changes: 9 additions & 0 deletions salt/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -2686,6 +2686,7 @@ def _check_requisites(self, low: LowChunk, running: dict[str, dict[str, Any]]):
"""
reqs = {}
pending = False
prereq_run_dict = self.pre
for req_type, chunk in self.dependency_dag.get_dependencies(low):
reqs.setdefault(req_type, []).append(chunk)
fun_stats = set()
Expand All @@ -2701,6 +2702,14 @@ def _check_requisites(self, low: LowChunk, running: dict[str, dict[str, Any]]):
for chunk in chunks:
tag = _gen_tag(chunk)
run_dict_chunk = run_dict.get(tag)
if (
run_dict_chunk is None
and r_type_base == RequisiteType.ONCHANGES.value
and chunk.get("__prereq__")
):
# If the requisite only ran in prereq (test) mode, use that
# result for onchanges to avoid recursive unmet requisites.
run_dict_chunk = prereq_run_dict.get(tag)
if run_dict_chunk:
filtered_run_dict[tag] = run_dict_chunk
run_dict = filtered_run_dict
Expand Down
48 changes: 44 additions & 4 deletions salt/utils/requisite.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,26 @@ def _chunk_str(self, chunk: LowChunk) -> str:
node_dict["NAME"] = chunk["name"]
return str(node_dict)

def _filter_self_reqs(
self,
node_tag: str,
req_tags: Iterable[str],
req_type: RequisiteType,
low: LowChunk,
req_key: str,
req_val: str,
) -> set[str]:
filtered = {tag for tag in req_tags if tag != node_tag}
if len(filtered) != len(req_tags):
log.warning(
"Ignoring %s requisite on %s that points to itself (%s: %s)",
req_type.value,
self._chunk_str(low),
req_key,
req_val,
)
return filtered

def add_chunk(self, low: LowChunk, allow_aggregate: bool) -> None:
node_id = _gen_tag(low)
self.dag.add_node(
Expand Down Expand Up @@ -269,12 +289,22 @@ def add_dependency(
for sls, req_tags in self.sls_to_nodes.items():
if fnmatch.fnmatch(sls, req_val):
found = True
self._add_reqs(node_tag, has_prereq_node, req_type, req_tags)
req_tags = self._filter_self_reqs(
node_tag, req_tags, req_type, low, req_key, req_val
)
if req_tags:
self._add_reqs(
node_tag, has_prereq_node, req_type, req_tags
)
else:
node_tag = _gen_tag(low)
if req_tags := self.sls_to_nodes.get(req_val, []):
found = True
self._add_reqs(node_tag, has_prereq_node, req_type, req_tags)
req_tags = self._filter_self_reqs(
node_tag, req_tags, req_type, low, req_key, req_val
)
if req_tags:
self._add_reqs(node_tag, has_prereq_node, req_type, req_tags)
elif self._is_fnmatch_pattern(req_val):
# This iterates over every chunk to check
# if any match instead of doing a look up since
Expand All @@ -283,11 +313,21 @@ def add_dependency(
for (state_type, name_or_id), req_tags in self.nodes_lookup_map.items():
if req_key == state_type and (fnmatch.fnmatch(name_or_id, req_val)):
found = True
self._add_reqs(node_tag, has_prereq_node, req_type, req_tags)
req_tags = self._filter_self_reqs(
node_tag, req_tags, req_type, low, req_key, req_val
)
if req_tags:
self._add_reqs(
node_tag, has_prereq_node, req_type, req_tags
)
elif req_tags := self.nodes_lookup_map.get((req_key, req_val)):
found = True
node_tag = _gen_tag(low)
self._add_reqs(node_tag, has_prereq_node, req_type, req_tags)
req_tags = self._filter_self_reqs(
node_tag, req_tags, req_type, low, req_key, req_val
)
if req_tags:
self._add_reqs(node_tag, has_prereq_node, req_type, req_tags)
return found

def add_requisites(self, low: LowChunk, disabled_reqs: Sequence[str]) -> str | None:
Expand Down
69 changes: 69 additions & 0 deletions salt/utils/win_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,75 @@ def get_sam_name(username):
return "\\".join([domain, username])


def _candidate_account_names(username):
"""
Build candidate account names to resolve, including UPN and DOMAIN/user forms.
"""
if username is None:
return []

candidate_names = [str(username)]
name = candidate_names[0]

if "/" in name and "\\" not in name:
candidate_names.append(name.replace("/", "\\"))

if (
"@" in name
and hasattr(win32security, "TranslateName")
and hasattr(win32security, "NameUserPrincipal")
and hasattr(win32security, "NameSamCompatible")
):
try:
candidate_names.append(
win32security.TranslateName(
name,
win32security.NameUserPrincipal,
win32security.NameSamCompatible,
)
)
except pywintypes.error:
pass

# Preserve order but remove duplicates
return list(dict.fromkeys(candidate_names))


def resolve_username(username):
"""
Resolve a username into account details usable for validation and logon.

Returns a dict with account_name, domain, sid, sam_name, and lookup_name.
"""
if not username:
raise CommandExecutionError("Username is required")

last_error = None
for candidate in _candidate_account_names(username):
try:
sid, domain, _ = win32security.LookupAccountName(None, candidate)
account_name, lookup_domain, _ = win32security.LookupAccountSid(None, sid)
resolved_domain = lookup_domain or domain
sam_name = (
f"{resolved_domain}\\{account_name}"
if resolved_domain
else account_name
)
return {
"account_name": account_name,
"domain": resolved_domain,
"sid": sid,
"sam_name": sam_name,
"lookup_name": candidate,
}
except pywintypes.error as exc:
last_error = exc
continue

detail = f": {last_error}" if last_error else ""
raise CommandExecutionError(f"User {username} not found{detail}")


def enable_ctrl_logoff_handler():
"""
Set the control handler on the console
Expand Down
89 changes: 59 additions & 30 deletions salt/utils/win_runas.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import winerror

import salt.platform.win
import salt.utils.win_functions

HAS_WIN32 = True
except ImportError:
Expand Down Expand Up @@ -78,9 +79,51 @@ def split_username(username):
# Domain users with Down-Level Logon Name: DOMAIN\user
if "\\" in user_name:
domain, user_name = user_name.split("\\", maxsplit=1)
elif "/" in user_name:
domain, user_name = user_name.split("/", maxsplit=1)
return str(user_name), str(domain)


def resolve_logon_credentials(username):
"""
Resolve a username into values suitable for Windows logon APIs.
"""
if not isinstance(username, str):
username = str(username)

if not HAS_WIN32:
raise CommandExecutionError("win_runas requires pywin32 to resolve users")

resolved = salt.utils.win_functions.resolve_username(username)
sam_name = resolved.get("sam_name") or username
logon_name, logon_domain = split_username(sam_name)

return {
"input_name": username,
"account_name": resolved["account_name"],
"domain_name": resolved["domain"],
"sid": resolved["sid"],
"sam_name": sam_name,
"lookup_name": resolved["lookup_name"],
"logon_name": logon_name,
"logon_domain": logon_domain,
}


def validate_username(username, raise_on_error=False):
"""
Validate that a username can be resolved on the system.
"""
try:
resolve_logon_credentials(username)
except CommandExecutionError as exc:
if raise_on_error:
raise
log.error("Invalid user '%s': %s", username, exc)
return False
return True


def create_env(user_token, inherit, timeout=1):
"""
CreateEnvironmentBlock might fail when we close a login session and then
Expand Down Expand Up @@ -169,17 +212,10 @@ def runas(cmd, username, password=None, **kwargs):
Commands are run in with the highest level privileges possible for the
account provided.
"""
# Sometimes this comes in as an int. LookupAccountName can't handle an int
# Let's make it a string if it's anything other than a string
if not isinstance(username, str):
username = str(username)
# Validate the domain and sid exist for the username
try:
_, domain, _ = win32security.LookupAccountName(None, username)
username, _ = split_username(username)
except pywintypes.error as exc:
message = win32api.FormatMessage(exc.winerror).rstrip("\n")
raise CommandExecutionError(message)
resolved = resolve_logon_credentials(username)
username = resolved["account_name"]
logon_name = resolved["logon_name"]
logon_domain = resolved["logon_domain"]

# Elevate the token from the current process
access = win32security.TOKEN_QUERY | win32security.TOKEN_ADJUST_PRIVILEGES
Expand Down Expand Up @@ -212,28 +248,28 @@ def runas(cmd, username, password=None, **kwargs):
log.debug("No impersonation token, using unprivileged runas")
return runas_unpriv(cmd, username, password, **kwargs)

if domain == "NT AUTHORITY":
if logon_domain == "NT AUTHORITY":
# Logon as a system level account, SYSTEM, LOCAL SERVICE, or NETWORK
# SERVICE.
user_token = win32security.LogonUser(
username,
domain,
logon_name,
logon_domain,
"",
win32con.LOGON32_LOGON_SERVICE,
win32con.LOGON32_PROVIDER_DEFAULT,
)
elif password:
# Login with a password.
user_token = win32security.LogonUser(
username,
domain,
logon_name,
logon_domain,
password,
win32con.LOGON32_LOGON_INTERACTIVE,
win32con.LOGON32_PROVIDER_DEFAULT,
)
else:
# Login without a password. This always returns an elevated token.
user_token = salt.platform.win.logon_msv1_s4u(username).Token
user_token = salt.platform.win.logon_msv1_s4u(logon_name).Token

# Get a linked user token to elevate if needed
elevation_type = win32security.GetTokenInformation(
Expand Down Expand Up @@ -370,17 +406,10 @@ def runas_unpriv(cmd, username, password, **kwargs):
"""
Runas that works for non-privileged users
"""
# Sometimes this comes in as an int. LookupAccountName can't handle an int
# Let's make it a string if it's anything other than a string
if not isinstance(username, str):
username = str(username)
# Validate the domain and sid exist for the username
try:
_, domain, _ = win32security.LookupAccountName(None, username)
username, _ = split_username(username)
except pywintypes.error as exc:
message = win32api.FormatMessage(exc.winerror).rstrip("\n")
raise CommandExecutionError(message)
resolved = resolve_logon_credentials(username)
username = resolved["account_name"]
logon_name = resolved["logon_name"]
logon_domain = resolved["logon_domain"]

# Create inheritable copy of the stdin
stdin = salt.platform.win.kernel32.GetStdHandle(
Expand Down Expand Up @@ -445,8 +474,8 @@ def runas_unpriv(cmd, username, password, **kwargs):
try:
# Run command and return process info structure
process_info = salt.platform.win.CreateProcessWithLogonW(
username=username,
domain=domain,
username=logon_name,
domain=logon_domain,
password=password,
logonflags=salt.platform.win.LOGON_WITH_PROFILE,
applicationname=None,
Expand Down
Loading