Repository: osbuild/osbuild

Issue:
Set password expiry for users
I would like to add a user to my final image, but require that that user change their password on first login. In general you can achieve this with `passwd --expire <user>` or `useradd --expiredate`/`usermod --expiredate` or `chage --expiredate`. The expire date is stored in `/etc/shadow` but I don't know how to specifically drop that file. I think this makes sense to happen as part of the `org.osbuild.users` stage. Eventually I want to be able to control this from an osbuild-composer blueprint, but I know that is not part of this repo.

Discussion:
supakeen: Thanks, good addition to have. I've made an inline comment regarding the date format and there's a linter error for the line length of a function definition that needs to be addressed.

@mvo5 thoughts on any necessary tests?
mvo5: > Thanks, good addition to have. I've made an inline comment regarding the date format and there's a linter error for the line length of a function definition that needs to be addressed.
> 
> @mvo5 thoughts on any necessary tests?

Yeah, I will attach a small draft in a few minutes (or push directly to the PR if @andremarianiello is fine with that)
andremarianiello: > > Thanks, good addition to have. I've made an inline comment regarding the date format and there's a linter error for the line length of a function definition that needs to be addressed.
> > @mvo5 thoughts on any necessary tests?
> 
> Yeah, I attached a small draft here (or I push directly to the PR if @andremarianiello is fine with that)
> 
> [0001-test-add-minimal-test-for-expiredate-for-user-stage.patch.txt](https://github.com/osbuild/osbuild/files/14521176/0001-test-add-minimal-test-for-expiredate-for-user-stage.patch.txt)
> 
> [edit: added the patch]

Thank you! I have applied your patch to my local branch, and I will push it the next time I push.
andremarianiello: I think there were runner failures in the gitlab CI, but I can't retry them
supakeen: I clicked a retry button.

Patch:
diff --git a/stages/org.osbuild.users b/stages/org.osbuild.users
index 88a40f0b..c4e82eb9 100755
--- a/stages/org.osbuild.users
+++ b/stages/org.osbuild.users
@@ -70,6 +70,10 @@ SCHEMA = """
             "items": {
               "type": "string"
             }
+          },
+          "expiredate": {
+            "description": "The date on which the user account will be disabled. This date is represented as a number of days since January 1st, 1970.",
+            "type": "integer"
           }
         }
       }
@@ -89,7 +93,17 @@ def getpwnam(root, name):
         return None
 
 
-def useradd(root, name, uid=None, gid=None, groups=None, description=None, home=None, shell=None, password=None):
+def useradd(
+        root,
+        name,
+        uid=None,
+        gid=None,
+        groups=None,
+        description=None,
+        home=None,
+        shell=None,
+        password=None,
+        expiredate=None):
     arguments = []
     if uid is not None:
         arguments += ["--uid", str(uid), "-o"]
@@ -108,11 +122,13 @@ def useradd(root, name, uid=None, gid=None, groups=None, description=None, home=
         arguments += ["--shell", shell]
     if password is not None:
         arguments += ["--password", password]
+    if expiredate is not None:
+        arguments += ["--expiredate", str(expiredate)]
 
     subprocess.run(["chroot", root, "useradd", *arguments, name], check=True)
 
 
-def usermod(root, name, gid=None, groups=None, description=None, home=None, shell=None, password=None):
+def usermod(root, name, gid=None, groups=None, description=None, home=None, shell=None, password=None, expiredate=None):
     arguments = []
     if gid is not None:
         arguments += ["--gid", gid]
@@ -126,6 +142,8 @@ def usermod(root, name, gid=None, groups=None, description=None, home=None, shel
         arguments += ["--shell", shell]
     if password is not None:
         arguments += ["--password", password]
+    if expiredate is not None:
+        arguments += ["--expiredate", str(expiredate)]
 
     if arguments:
         subprocess.run(["chroot", root, "usermod", *arguments, name], check=True)
@@ -168,6 +186,7 @@ def main(tree, options):
         home = user_options.get("home")
         shell = user_options.get("shell")
         password = user_options.get("password")
+        expiredate = user_options.get("expiredate")
 
         passwd = getpwnam(tree, name)
         if passwd is not None:
@@ -175,13 +194,13 @@ def main(tree, options):
             if uid is not None and passwd[2] != str(uid):
                 print(f"Error: can't set uid of existing user '{name}'")
                 return 1
-            usermod(tree, name, gid, groups, description, home, shell, password)
+            usermod(tree, name, gid, groups, description, home, shell, password, expiredate)
 
             # ensure the home directory exists, see module doc string for details
             _, _, _, _, _, home, _ = getpwnam(tree, name)
             ensure_homedir(tree, name, home)
         else:
-            useradd(tree, name, uid, gid, groups, description, home, shell, password)
+            useradd(tree, name, uid, gid, groups, description, home, shell, password, expiredate)
 
         # following maintains backwards compatibility for handling a single ssh key
         key = user_options.get("key")   # Public SSH key


Test patch:
diff --git a/stages/test/test_users.py b/stages/test/test_users.py
new file mode 100644
index 00000000..1bc06ccd
--- /dev/null
+++ b/stages/test/test_users.py
@@ -0,0 +1,34 @@
+#!/usr/bin/python3
+
+from unittest.mock import patch
+
+import pytest
+
+from osbuild.testutil import make_fake_tree
+
+STAGE_NAME = "org.osbuild.users"
+
+
+@pytest.mark.parametrize("user_opts,expected_args", [
+    ({}, []),
+    ({"expiredate": 12345}, ["--expiredate", "12345"]),
+])
+@patch("subprocess.run")
+def test_users_happy(mocked_run, tmp_path, stage_module, user_opts, expected_args):
+    make_fake_tree(tmp_path, {
+        "/etc/passwd": "",
+    })
+
+    options = {
+        "users": {
+            "foo": {},
+        }
+    }
+    options["users"]["foo"].update(user_opts)
+
+    stage_module.main(tmp_path, options)
+
+    assert len(mocked_run.call_args_list) == 1
+    args, kwargs = mocked_run.call_args_list[0]
+    assert args[0] == ["chroot", tmp_path, "useradd"] + expected_args + ["foo"]
+    assert kwargs.get("check")