blob: 790373060bbdd878d2ba6485003d14e3f85fcba1 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test slurmdbd RPC authorization at every authentication level.
Issue 50454: slurmdbd serves RPCs over conmgr and caches the authenticated
identity (slurm_msg_auth_t) on the connection at REQUEST_PERSIST_INIT. These
tests pin the authorization semantics that rely on that cached level:
- connections that never authenticate get no service,
- unprivileged users get public reads only,
- operators cannot reach admin-only RPCs,
- non-root admins cannot reach slurmctld-internal RPCs (not CLI-drivable,
covered by the persist_conn unit test instead),
- root/SlurmUser can do everything.
"""
import os
import socket
import ssl
import struct
import time
import pytest
import atf
# Teardown slack when waiting for a connection the server should drop.
GRACE_SECONDS = 15
test_user = atf.properties["test-user"]
slurm_user = atf.properties["slurm-user"]
# Created by the module setup, in SlurmDBD and as local users.
op_user = "p4operator"
admin_user = "p4admin"
op_account = "p4operatoracct"
op_qos = "p4operatorqos"
# Sacrificial object the unprivileged user tries (and fails) to delete.
root_account = "p4rootacct"
@pytest.fixture(scope="module", autouse=True)
def setup():
"""Require accounting and create an operator and an admin user."""
atf.require_accounting()
atf.require_slurm_running()
for user in (op_user, admin_user):
atf.run_command(
f"id {user} || useradd -m -s /bin/bash {user}",
user="root",
fatal=True,
)
# Best effort: the user may not exist in the database yet.
atf.run_command(f"sacctmgr -i delete user {user}", user=slurm_user, quiet=True)
_assert_allowed(
_sacctmgr(
slurm_user,
f"add user {op_user} account=root AdminLevel=Operator",
)
)
_assert_allowed(
_sacctmgr(
slurm_user,
f"add user {admin_user} account=root AdminLevel=Admin",
)
)
_assert_allowed(_sacctmgr(slurm_user, f"add account {root_account}"))
yield
_sacctmgr(slurm_user, f"delete qos {op_qos}", quiet=True)
_sacctmgr(slurm_user, f"delete account {op_account}", quiet=True)
_sacctmgr(slurm_user, f"delete account {root_account}", quiet=True)
for user in (op_user, admin_user):
_sacctmgr(slurm_user, f"delete user {user}", quiet=True)
atf.run_command(f"userdel -r {user}", user="root", quiet=True)
def _sacctmgr(user, args, **kwargs):
"""Run a non-interactive sacctmgr command as the given user."""
return atf.run_command(f"sacctmgr -i {args}", user=user, **kwargs)
def _assert_denied(results):
assert results["exit_code"] != 0, "denied command unexpectedly succeeded"
output = (results["stdout"] + results["stderr"]).lower()
assert any(
phrase in output for phrase in ("denied", "privilege", "only admins")
), f"unexpected denial message: {output}"
def _assert_allowed(results):
assert (
results["exit_code"] == 0
), f"command failed: {results['stdout']} {results['stderr']}"
def _reconfigure(user):
"""Run sacctmgr reconfigure and return the raw results."""
return _sacctmgr(user, "reconfigure")
def _assert_reconfigure_allowed(results):
assert results["exit_code"] == 0, f"reconfigure failed: {results['stderr']}"
assert (
"dbd_fini" not in results["stderr"].lower()
), f"slurmdbd rejected a permitted DBD_RECONFIG: {results['stderr']}"
@pytest.fixture(scope="module")
def archive_dir():
"""World-writable dump dir, so only the server can reject the dump."""
path = os.path.abspath("p4archive")
atf.run_command(
f"mkdir -p {path} && chmod 777 {path}",
user="root",
fatal=True,
)
yield path
atf.run_command(f"rm -rf {path}", user="root", quiet=True, fatal=True)
@pytest.fixture
def max_query_time_range():
"""Set MaxQueryTimeRange; slurmdbd re-reads slurmdbd.conf on reconfigure."""
atf.set_config_parameter("MaxQueryTimeRange", "1:00:00", source="slurmdbd")
_assert_allowed(_sacctmgr(slurm_user, "reconfigure"))
yield
atf.set_config_parameter("MaxQueryTimeRange", None, source="slurmdbd")
_sacctmgr(slurm_user, "reconfigure", quiet=True)
@pytest.fixture(scope="module")
def dbd_config():
"""Use slurmdbd's config, not slurmctld's."""
return {key.lower(): value for key, value in atf.get_config(source="dbd").items()}
@pytest.fixture(scope="module")
def dbd_address(dbd_config):
return (
dbd_config.get("dbdaddr") or dbd_config.get("dbdhost", "localhost"),
int(dbd_config.get("dbdport", 6819)),
)
@pytest.fixture(scope="module")
def tls_enabled(dbd_config):
tls_type = dbd_config.get("tlstype")
return tls_type is not None and str(tls_type).lower() not in (
"tls/none",
"none",
"(null)",
)
@pytest.fixture(scope="module")
def tls_context():
"""Test TLS transport, not certificate identity."""
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
@pytest.fixture
def dbd_socket(dbd_address, tls_enabled, tls_context):
"""Connect, completing TLS if required."""
sock = socket.create_connection(dbd_address, timeout=GRACE_SECONDS)
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
if tls_enabled:
sock = tls_context.wrap_socket(sock, server_hostname=dbd_address[0])
yield sock
finally:
sock.close()
def _peer_closed(sock, wait_seconds):
"""Wait for EOF or reset."""
deadline = time.monotonic() + wait_seconds
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
sock.settimeout(remaining)
try:
if sock.recv(1) == b"":
return True
except socket.timeout:
return False
except (ConnectionResetError, ssl.SSLEOFError):
return True
@pytest.mark.skipif(
atf.get_version("sbin/slurmdbd") < (26, 11),
reason="Issue 50454: old slurmdbd did not close a connection on a non-init first RPC",
)
def test_message_before_init_is_rejected(dbd_socket):
"""A connection whose first frame is not an init is closed."""
# A framed non-init message: [size][type].
dbd_socket.sendall(struct.pack("!II", 4, 0))
assert _peer_closed(
dbd_socket, GRACE_SECONDS
), "slurmdbd served a connection that never authenticated"
def test_public_reads_allowed():
"""Unprivileged users can run public read-only queries."""
for args in (
"show accounts",
"show associations",
"show clusters",
"show qos",
"show tres",
):
_assert_allowed(_sacctmgr(test_user, args))
_assert_allowed(atf.run_command("sacct -X", user=test_user))
_assert_allowed(atf.run_command("sshare", user=test_user))
def test_user_writes_denied():
"""Unprivileged users cannot modify accounting data."""
for args in (
"add account p4deniedacct",
"add user p4denieduser account=root",
f"add qos {op_qos}",
"add cluster p4deniedcluster",
"modify account root set description=p4denied",
f"delete account {root_account}",
):
_assert_denied(_sacctmgr(test_user, args))
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Old sacctmgr exited 0 on a denied rollup",
)
def test_user_operator_reads_denied():
"""Unprivileged users cannot run operator-gated read RPCs."""
_assert_denied(_sacctmgr(test_user, "show wckeys"))
_assert_denied(_sacctmgr(test_user, "rollup"))
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Issue 50454: old sacctmgr exited 0 on a denied reconfigure",
)
def test_user_admin_rpcs_denied(archive_dir):
"""Unprivileged users cannot run admin-gated RPCs."""
_assert_denied(_sacctmgr(test_user, f"archive dump directory={archive_dir}"))
_assert_denied(_reconfigure(test_user))
def test_operator_writes_allowed():
"""Operators can modify ordinary accounting data."""
_assert_allowed(_sacctmgr(op_user, f"add account {op_account}"))
_assert_allowed(_sacctmgr(op_user, f"add user p4opuser account={op_account}"))
_assert_allowed(
_sacctmgr(op_user, f"modify account {op_account} set description=p4")
)
_assert_allowed(_sacctmgr(op_user, "delete user p4opuser"))
_assert_allowed(_sacctmgr(op_user, f"delete account {op_account}"))
def test_operator_reads_allowed():
"""Operators can run operator-gated read RPCs."""
_assert_allowed(_sacctmgr(op_user, "show wckeys"))
_assert_allowed(_sacctmgr(op_user, "rollup"))
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Issue 50454: old sacctmgr exited 0 on a denied reconfigure",
)
def test_operator_admin_rpcs_denied(archive_dir):
"""Operators cannot run admin-gated RPCs or manage QOS."""
_assert_denied(_sacctmgr(op_user, f"archive dump directory={archive_dir}"))
_assert_denied(_sacctmgr(op_user, f"add qos {op_qos}"))
_assert_denied(_reconfigure(op_user))
def test_admin_rpcs_allowed(archive_dir):
"""Non-root admins can run admin-gated RPCs and manage QOS."""
_assert_allowed(_sacctmgr(admin_user, f"archive dump directory={archive_dir}"))
_assert_allowed(_sacctmgr(admin_user, f"add qos {op_qos}"))
_assert_allowed(_sacctmgr(admin_user, f"delete qos {op_qos}"))
_assert_reconfigure_allowed(_reconfigure(admin_user))
def test_slurm_user_allowed(archive_dir):
"""SlurmUser can run everything, including admin-gated RPCs."""
_assert_allowed(_sacctmgr(slurm_user, "show wckeys"))
_assert_allowed(_sacctmgr(slurm_user, f"archive dump directory={archive_dir}"))
_assert_reconfigure_allowed(_reconfigure(slurm_user))
def test_max_query_time_range(max_query_time_range):
"""Only operators and above may exceed MaxQueryTimeRange."""
wide_query = "sacct -X -S 2020-01-01"
results = atf.run_command(wide_query, user=test_user)
assert results["exit_code"] != 0, "wide query from user not rejected"
assert (
"too wide" in (results["stdout"] + results["stderr"]).lower()
), f"unexpected rejection: {results['stdout']} {results['stderr']}"
_assert_allowed(atf.run_command(wide_query, user=op_user))