Skip to content
Open
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
169 changes: 169 additions & 0 deletions .github/workflows/macos_cross_user_test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: macOS Cross-User Tests

# Runs the POSIX user-impersonation tests (which xfail when the OPENJD_TEST_SUDO_*
# environment variables are unset) on a macOS runner. This exercises the real
# `sudo -u <user> -i <python> -I -c <setsid shim>` cross-user path end to end:
# process launch as another user, new-process-group creation, signalling, and
# process-tree termination.
#
# The runner's passwordless sudo is used to provision the same user/group layout
# that testing_containers/localuser_sudo_environment/Dockerfile creates for Linux:
# runner -- runs the pytests (member of the shared group)
# openjd-target -- the impersonated user (member of the shared group)
# openjd-disjoint -- a user with no group in common (temp-dir permission tests)

on:
workflow_dispatch:
pull_request:
branches: [ mainline, release ]
paths:
- 'src/openjd/sessions/_subprocess.py'
- 'src/openjd/sessions/_linux/_sudo.py'
- 'src/openjd/sessions/_os_checker.py'
- 'src/openjd/sessions/_tempdir.py'
- '.github/workflows/macos_cross_user_test.yml'

env:
OPENJD_TEST_SUDO_TARGET_USER: openjd-target
OPENJD_TEST_SUDO_SHARED_GROUP: openjd-shared
OPENJD_TEST_SUDO_DISJOINT_USER: openjd-disjoint
OPENJD_TEST_SUDO_DISJOINT_GROUP: openjd-disjointgrp
# Hatch's default data dir is under ~/Library, which other users cannot traverse.
# The impersonation tests execute the hatch venv's python as the target user, so
# the venv must live somewhere world-traversable.
HATCH_DATA_DIR: /opt/hatch
# macOS's default per-user temp dir (/var/folders/<hash>/T, mode 700) is not
# traversable by the impersonated user, and /var is a symlink to /private/var
# (which TempDir resolves but gettempdir() does not). Use a dedicated
# world-writable, already-resolved temp root instead, which matches the /tmp
# semantics the impersonation tests get on Linux. Created during provisioning
# owned by runner:staff because BSD filesystems give new files the GROUP OF THE
# PARENT DIRECTORY (not the creator's gid), and the same-user TempDir test
# asserts the created directory has the creating process's gid.
TMPDIR: /private/tmp/openjd-tests

jobs:
macos-cross-user:
name: Python ${{ matrix.python-version }}
runs-on: macos-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.13']
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Provision test users and groups
run: |
set -euxo pipefail

# Groups
sudo dseditgroup -o create "${OPENJD_TEST_SUDO_SHARED_GROUP}"
sudo dseditgroup -o create "${OPENJD_TEST_SUDO_DISJOINT_GROUP}"

# Target user: impersonated by the tests; shares a group with runner
sudo sysadminctl -addUser "${OPENJD_TEST_SUDO_TARGET_USER}" \
-fullName "OpenJD Test Target" -password "OpenJD-ci-test-1!" -shell /bin/zsh
sudo createhomedir -c -u "${OPENJD_TEST_SUDO_TARGET_USER}" > /dev/null
sudo dseditgroup -o edit -a "${OPENJD_TEST_SUDO_TARGET_USER}" -t user "${OPENJD_TEST_SUDO_SHARED_GROUP}"
# Linux useradd gives every user a self-named group, and
# test_cleanup_posix_user chowns to "user:user"; macOS does not, so
# create the self-named group explicitly. runner must NOT be a member.
sudo dseditgroup -o create "${OPENJD_TEST_SUDO_TARGET_USER}"
sudo dseditgroup -o edit -a "${OPENJD_TEST_SUDO_TARGET_USER}" -t user "${OPENJD_TEST_SUDO_TARGET_USER}"

# Disjoint user: NO group in common with runner
sudo sysadminctl -addUser "${OPENJD_TEST_SUDO_DISJOINT_USER}" \
-fullName "OpenJD Test Disjoint" -password "OpenJD-ci-test-1!" -shell /bin/zsh
sudo createhomedir -c -u "${OPENJD_TEST_SUDO_DISJOINT_USER}" > /dev/null
sudo dseditgroup -o edit -a "${OPENJD_TEST_SUDO_DISJOINT_USER}" -t user "${OPENJD_TEST_SUDO_DISJOINT_GROUP}"

# The test-running user joins the shared group (matches the Docker layout)
sudo dseditgroup -o edit -a runner -t user "${OPENJD_TEST_SUDO_SHARED_GROUP}"

# Passwordless sudo from runner to the target user (and itself), mirroring
# the hostuser rule in the Linux test container
echo "runner ALL=(${OPENJD_TEST_SUDO_TARGET_USER},runner) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/openjd-cross-user-tests
sudo chmod 440 /etc/sudoers.d/openjd-cross-user-tests
sudo visudo -cf /etc/sudoers.d/openjd-cross-user-tests

# test_basic_operation runs a bare `python` as the target user via
# `sudo -i`; macOS ships python3 only, so provide the alias.
sudo mkdir -p /usr/local/bin
sudo ln -sf /usr/bin/python3 /usr/local/bin/python

# Flush Directory Services caches so the new users/groups resolve
sudo dscacheutil -flushcache

# World-writable temp root for the tests (see TMPDIR at the top of the file)
sudo mkdir -p "${TMPDIR}"
sudo chown runner:staff "${TMPDIR}"
sudo chmod 1777 "${TMPDIR}"

- name: Verify provisioning
run: |
set -euxo pipefail
id "${OPENJD_TEST_SUDO_TARGET_USER}"
id "${OPENJD_TEST_SUDO_DISJOINT_USER}"
id runner
# The isolation invariant the tests rely on: runner and the target user
# share OPENJD_TEST_SUDO_SHARED_GROUP; the disjoint user shares nothing.
id -Gn runner | tr ' ' '\n' | grep -qx "${OPENJD_TEST_SUDO_SHARED_GROUP}"
id -Gn "${OPENJD_TEST_SUDO_TARGET_USER}" | tr ' ' '\n' | grep -qx "${OPENJD_TEST_SUDO_SHARED_GROUP}"
if id -Gn "${OPENJD_TEST_SUDO_DISJOINT_USER}" | tr ' ' '\n' | grep -qx "${OPENJD_TEST_SUDO_SHARED_GROUP}"; then
echo "disjoint user must not be in the shared group" && exit 1
fi
# Cross-user execution works at all
sudo -u "${OPENJD_TEST_SUDO_TARGET_USER}" -i /usr/bin/true
sudo -u "${OPENJD_TEST_SUDO_TARGET_USER}" -i python -c 'import getpass; print("bare python runs as", getpass.getuser())'

- name: Install hatch
run: |
sudo mkdir -p "${HATCH_DATA_DIR}"
sudo chown runner "${HATCH_DATA_DIR}"
pip install hatch

- name: Create test environment
run: |
set -euxo pipefail
hatch env create
# The target user executes the venv python and reads test support files;
# make the venv and the workspace world-readable/traversable.
chmod -R o+rX "${HATCH_DATA_DIR}" "${GITHUB_WORKSPACE}"

- name: Report which interpreter the setsid shim resolves to
# NOTE: no braces in this inline script -- hatch run applies its own
# {...} template substitution to the arguments it receives.
run: |
hatch run python -c "
from openjd.sessions._subprocess import _macos_shim_interpreter, _MACOS_FALLBACK_SHIM_INTERPRETER
picked = _macos_shim_interpreter()
branch = 'FALLBACK' if picked == _MACOS_FALLBACK_SHIM_INTERPRETER else 'BASE-INTERPRETER'
print('shim interpreter:', picked, '(' + branch + ' branch)')
"

- name: Run cross-user impersonation tests
run: |
set -euxo pipefail
# -rxX lists (x)failed and (X)passed-unexpectedly tests in the summary so the
# next step can assert nothing silently xfailed back to a no-op.
hatch run test -- test/openjd/sessions_v0/test_subprocess.py test/openjd/sessions_v0/test_tempdir.py \
--no-cov -rxX 2>&1 | tee pytest-cross-user.log

- name: Assert impersonation tests actually ran
run: |
set -euxo pipefail
# If the OPENJD_TEST_SUDO_* wiring regresses, the impersonation tests xfail
# with this message instead of failing the job -- catch that here.
if grep -q "Must define environment vars OPENJD_TEST_SUDO" pytest-cross-user.log; then
echo "Impersonation tests were skipped (env vars not picked up); provisioning is broken."
exit 1
fi

- name: Run remaining tests
run: hatch run test -- --ignore test/openjd/sessions_v0/test_subprocess.py --ignore test/openjd/sessions_v0/test_tempdir.py --no-cov
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,15 @@ with passwordless `sudo` by, for example, adding a rule like follows to your
host ALL=(actions) NOPASSWD: ALL
```

On MacOS, the impersonated command is launched under a small Python shim because macOS
lacks the `setsid(1)` utility. The shim runs with the base interpreter behind the Python
that is running this library (for a virtual environment, the interpreter the venv was
created from) provided that interpreter is reachable and executable by other users;
otherwise it falls back to the operating system's `/usr/bin/python3`, which resolves to a
working interpreter only when the Xcode Command Line Tools (or Xcode) are present
(`xcode-select --install`). No separate Python installation is required when the base
interpreter is usable.

#### Impersonating a User: Windows Systems

To run an impersonated Session on Windows Systems modify the "Running a Session" example
Expand Down
6 changes: 6 additions & 0 deletions src/openjd/sessions/_linux/_sudo.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ def find_child_process_id_pgrep(
stdin=DEVNULL,
text=True,
)
# pgrep exit codes: 0 = one or more processes matched; 1 = no processes matched;
# >1 = an actual error (syntax/operational). Exit 1 is NOT an error here -- it just
# means sudo has not spawned its child yet, so we return None to let the caller's
# retry loop poll again.
if pgrep_result.returncode == 1:
return None
if pgrep_result.returncode != 0:
raise FindSignalTargetError("Unable to query child processes of sudo process")
results = pgrep_result.stdout.splitlines()
Expand Down
4 changes: 4 additions & 0 deletions src/openjd/sessions/_os_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ def is_windows() -> bool:
return os.name == WINDOWS


def is_macos() -> bool:
return sys.platform == MACOS


def check_os() -> None:
if not (is_posix() or is_windows()):
raise NotImplementedError(
Expand Down
105 changes: 103 additions & 2 deletions src/openjd/sessions/_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
import shlex
import signal
import stat
import sys
import time
from contextlib import nullcontext
Expand All @@ -16,7 +17,7 @@
from ._linux._capabilities import try_use_cap_kill
from ._linux._sudo import find_sudo_child_process_group_id
from ._logging import LoggerAdapter, LogContent, LogExtraInfo
from ._os_checker import is_linux, is_posix, is_windows
from ._os_checker import is_linux, is_macos, is_posix, is_windows
from ._session_user import PosixSessionUser, WindowsSessionUser, SessionUser
from ._action_filter import redact_openjd_redacted_env_requests

Expand All @@ -28,6 +29,84 @@

__all__ = ("LoggingSubprocess",)

# macOS has no `setsid(1)` binary (it is a Linux/util-linux tool), yet the new-session
# behavior it provides is still required: `sudo -u <user> -i <cmd>` places the workload in
# sudo's own (root-owned) process group, which the jobRunAsUser cannot signal and which
# openjd must not signal (it would hit the root sudo process). We reproduce `setsid` with a
# tiny pure-Python shim, run as the workload, that makes the workload a new session/process-
# group leader and then exec's the real command.
#
# Details:
# * `os.getpgrp() == os.getpid() or os.setsid()` calls setsid() only when the process is
# NOT already a group leader; os.setsid() raises EPERM if the caller already leads a
# group, so the short-circuit avoids that. Either way the workload ends up in a process
# group distinct from sudo's, which find_sudo_child_process_group_id() then discovers.
# * Single line (no newlines) so it passes cleanly through `sudo -i` argv without any
# shell-quoting fragility.
# * The interpreter that runs the shim is the base interpreter behind the one running this
# process (see _macos_shim_interpreter()), falling back to /usr/bin/python3. sys.executable
# itself is not used directly because it may live inside a virtual environment that the
# jobRunAsUser has no traverse/read permission on.
# * `-I` (isolated mode) drops the current working directory from sys.path and ignores
# PYTHON* environment variables, so a file such as os.py in the session working directory
# cannot be imported ahead of the standard library before os.execvp() runs.
#
# Signal-target discovery (find_sudo_child_process_group_id) locates the workload by walking
# sudo's single child and comparing process groups. This relies on `sudo -i` exec'ing the
# command into a single child rather than leaving extra long-lived processes in between; the
# same assumption already holds for the Linux `setsid -w` path.
_MACOS_SETSID_SHIM = (
"import os,sys;os.getpgrp()==os.getpid() or os.setsid();os.execvp(sys.argv[1],sys.argv[1:])"
)
Comment on lines +58 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagging this line, but really I'd like to learn more in general process management in macOS and what research we've done to know this is the correct solution.

From what I understand, macOS makes sids/pgids pretty hidden. Like you've discovered they don't even provide a utility to assign those values even though there's a syscall. Even when inspecting processes they don't really expose these values via ps -o sess= though a getsid syscall may produce the session id?

Basically, I want to know we are using the right tools provided to us for the OS to properly signal, inspect, manage, isolate, and terminate processes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haven't forgotten about this comment. doing some additional research this week to answer whether these are the right os-provided tools.

_MACOS_FALLBACK_SHIM_INTERPRETER = "/usr/bin/python3"


def _other_users_can_execute(path: str) -> bool:
"""Returns whether an arbitrary other user can execute the file at the given path based
on the world (other) permission bits: the file itself must be o+x and every directory on
the path must be o+x (traversable). A world-executable file under e.g. a 0o750 home
directory is still unreachable, so both checks are required.

This is a conservative approximation: it ignores group permissions and ACLs that might
also grant access, so it can return False for a path some specific user could execute.
"""
try:
mode = os.stat(path).st_mode
if not (stat.S_ISREG(mode) and mode & stat.S_IXOTH):
return False
parent = os.path.dirname(path)
while True:
if not os.stat(parent).st_mode & stat.S_IXOTH:
return False
next_parent = os.path.dirname(parent)
if next_parent == parent: # reached the filesystem root
return True
parent = next_parent
except OSError:
return False


def _macos_shim_interpreter() -> str:
"""Returns the path of the Python interpreter used to run _MACOS_SETSID_SHIM as the
jobRunAsUser.

Prefers the base interpreter behind the one running this process (sys._base_executable;
for a virtual environment this is the interpreter the venv was created from, in a system
location such as /usr/bin, /opt/homebrew, or a python.org framework install) so that no
separate Python installation is required on the host. The venv's own sys.executable is
not suitable: the jobRunAsUser typically has no traverse/read permission on the agent's
venv directory.

Falls back to /usr/bin/python3 (the Command Line Tools shim; requires the Command Line
Tools or Xcode to be installed) when the base interpreter cannot be determined or is not
reachable and executable by other users.
"""
base = os.path.realpath(getattr(sys, "_base_executable", None) or sys.executable)
if _other_users_can_execute(base):
return base
return _MACOS_FALLBACK_SHIM_INTERPRETER


# ========================================================================
# ========================================================================
# DEVELOPER NOTE:
Expand Down Expand Up @@ -257,7 +336,29 @@ def _start_subprocess(self) -> Optional[Popen]:
# same process group as the `sudo` command. If that happens, then
# we're stuck: 1/ Our user cannot kill processes by the self._user; and
# 2/ The self._user cannot kill the root-owned sudo process group.
command.extend(["sudo", "-u", user.user, "-i", "setsid", "-w"])
if is_macos():
# macOS has no setsid(1); use a pure-Python setsid shim (see
# _MACOS_SETSID_SHIM) run as the workload to get the same
# new-session behavior that `setsid -w` provides on Linux.
shim_interpreter = _macos_shim_interpreter()
self._logger.info(
f"Using {shim_interpreter} to run the setsid shim",
extra=LogExtraInfo(openjd_log_content=LogContent.PROCESS_CONTROL),
)
command.extend(
[
"sudo",
"-u",
user.user,
"-i",
shim_interpreter,
"-I",
"-c",
_MACOS_SETSID_SHIM,
]
)
else:
command.extend(["sudo", "-u", user.user, "-i", "setsid", "-w"])
elif is_windows():
user = cast(WindowsSessionUser, self._user) # type: ignore

Expand Down
12 changes: 11 additions & 1 deletion test/openjd/sessions_v0/test_os_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import unittest
from enum import Enum
from unittest.mock import patch
from openjd.sessions._os_checker import is_posix, is_windows, check_os
from openjd.sessions._os_checker import is_macos, is_posix, is_windows, check_os


class OSName(str, Enum):
Expand Down Expand Up @@ -32,6 +32,16 @@ def test_is_not_windows(self, mock_os):
mock_os.name = OSName.POSIX
self.assertFalse(is_windows())

@patch("openjd.sessions._os_checker.sys")
def test_is_macos(self, mock_sys):
mock_sys.platform = "darwin"
self.assertTrue(is_macos())

@patch("openjd.sessions._os_checker.sys")
def test_is_not_macos(self, mock_sys):
mock_sys.platform = "linux"
self.assertFalse(is_macos())

@patch("openjd.sessions._os_checker.os")
def test_check_os_posix(self, mock_os):
mock_os.name = OSName.POSIX
Expand Down
Loading
Loading