Skip to content

Commit

Permalink
stage(kickstart): implement sudo support for users setting
Browse files Browse the repository at this point in the history
This commit adds support in the kickstart stage to have options like:
```json
{"users": {"foo": {"sudo": {}}}}
{"users": {"foo": {"sudo": {"nopasswd": true}}}}
```
Users with "sudo" will get added to the users that can run sudo
via the `/etc/sudoers.d/{user}-ks` snippet. Kickstart does not
have native support for sudo so this is implemented via a targeted
`%post` script.
  • Loading branch information
mvo5 committed Nov 16, 2023
1 parent 7b201db commit 7125f85
Show file tree
Hide file tree
Showing 2 changed files with 88 additions and 0 deletions.
21 changes: 21 additions & 0 deletions stages/org.osbuild.kickstart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ commands are supported here.
"""

import os
import shlex
import sys
from typing import Dict, List

Expand Down Expand Up @@ -115,6 +116,17 @@ SCHEMA = r"""
"key": {
"description": "SSH Public Key to add to ~/.ssh/authorized_keys",
"type": "string"
},
"sudo": {
"description": "Configure sudo for the given user",
"type": "object",
"additionalProperties": false,
"properties": {
"nopasswd": {
"description": "Allow use of sudo without a password",
"type": "boolean"
}
}
}
}
}
Expand Down Expand Up @@ -325,6 +337,15 @@ def make_users(users: Dict) -> List[str]:
if key:
res.append(f'sshkey --username {name} "{key}"')

sudo = opts.get("sudo")
if sudo is not None:
# the schema makes this unnecessary but paranoia++
if shlex.quote(name) != name:
raise ValueError(f"invalid username '{name}': requires shell quoting")
nopasswd = "\\tNOPASSWD:" if sudo.get("nopasswd", False) else ""
# not using "echo" as it's semantic depends on the shell
res.append(f'%post\nprintf "{name}\\tALL=(ALL){nopasswd} ALL\\n" >> /etc/sudoers.d/{name}-ks')

return res


Expand Down
67 changes: 67 additions & 0 deletions stages/test/test_kickstart.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#!/usr/bin/python3

import os.path
import pathlib
import subprocess
import sys

import pytest

Expand All @@ -10,6 +12,10 @@
from osbuild.testutil.imports import import_module_from_path

TEST_INPUT = [
({"users": {"foo": {}}}, "user --name foo"),
({"users": {"foo": {"sudo": {}}}}, 'user --name foo\n%post\nprintf "foo\\tALL=(ALL) ALL\\n" >> /etc/sudoers.d/foo-ks'),
({"users": {"foo": {"sudo": {"nopasswd": True}}}},
'user --name foo\n%post\nprintf "foo\\tALL=(ALL)\\tNOPASSWD: ALL\\n" >> /etc/sudoers.d/foo-ks'),
({"lang": "en_US.UTF-8"}, "lang en_US.UTF-8"),
({"keyboard": "us"}, "keyboard us"),
({"timezone": "UTC"}, "timezone UTC"),
Expand Down Expand Up @@ -249,3 +255,64 @@ def test_schema_validation_bad_apples(test_data, expected_err):
assert len(res.errors) == 1
err_msgs = [e.as_dict()["message"] for e in res.errors]
assert expected_err in err_msgs[0]


@pytest.mark.parametrize("user,test_sudo_options,expected_sudoers_content", [
("foo", {"sudo": {}}, "foo\tALL=(ALL) ALL\n"),
("bar", {"sudo": {"nopasswd": True}}, "bar\tALL=(ALL)\tNOPASSWD: ALL\n"),
])
def test_kickstart_sudo(tmp_path, user, test_sudo_options, expected_sudoers_content):
ks_stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.kickstart")
ks_stage = import_module_from_path("ks_stage", ks_stage_path)

ks_path = "kickstart/kfs.cfg"
options = {
"path": ks_path,
"users": {user: {}},
}
options["users"][user].update(test_sudo_options)

ks_stage.main(tmp_path, options)

ks_path = os.path.join(tmp_path, ks_path)
with open(ks_path, encoding="utf-8") as fp:
ks_content = fp.read()
# extract the actual script
script = ks_content.split("%post")[-1]
script = script.replace("/etc/", f"{tmp_path}/etc/")
fake_sudoers_d_path = pathlib.Path(f"{tmp_path}/etc/sudoers.d/{user}-ks")
fake_sudoers_d_path.parent.mkdir(parents=True)
# and run it (this is slightly dangerous)
subprocess.run(script, shell=True)
assert fake_sudoers_d_path.read_text() == expected_sudoers_content
# check that sudo is happy
subprocess.run(
["visudo", "-cf", os.fspath(fake_sudoers_d_path)],
stdout=sys.stdout,
stderr=sys.stdout,
check=True,
)


# note that the schema should prevent this but paranoia
@pytest.mark.parametrize("bad_username", [
'foo"', 'bar;', 'baz";exit 1',
])
def test_kickstart_sudo_validates_sh(tmp_path, bad_username):
ks_stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.kickstart")
ks_stage = import_module_from_path("ks_stage", ks_stage_path)

ks_path = "kickstart/kfs.cfg"
options = {
"path": ks_path,
"users": {
bad_username: {
"sudo": {},
},
},
}

with pytest.raises(Exception) as e:
ks_stage.main(tmp_path, options)
assert e.type is ValueError
assert e.value.args[0] == f"invalid username '{bad_username}': requires shell quoting"

0 comments on commit 7125f85

Please sign in to comment.