From: Dermot Bradley <dermot_bradley@yahoo.com>
Date: Thu, 26 Aug 2021 00:58 +0100
Subject: [PATCH] cloud-init: Add doas support

Add doas support to users_groups module.

---

diff -aur a/cloudinit/config/cc_users_groups.py b/cloudinit/config/cc_users_groups.py
--- a/cloudinit/config/cc_users_groups.py
+++ b/cloudinit/config/cc_users_groups.py
@@ -54,14 +54,20 @@
     if the cloud-config can be intercepted. SSH authentication is preferred.
 
 .. note::
+    If specifying a doas rule for a user, ensure that the syntax for the rule
+    is valid, as the only checking performed by cloud-init is to ensure that
+    the user referenced in the rule is the correct user.
+
+.. note::
     If specifying a sudo rule for a user, ensure that the syntax for the rule
     is valid, as it is not checked by cloud-init.
 
 .. note::
     Most of these configuration options will not be honored if the user
     already exists. The following options are the exceptions; they are applied
-    to already-existing users: ``plain_text_passwd``, ``hashed_passwd``,
-    ``lock_passwd``, ``sudo``, ``ssh_authorized_keys``, ``ssh_redirect_user``.
+    to already-existing users: ``plain_text_passwd``, ``doas``,
+    ``hashed_passwd``, ``lock_passwd``, ``sudo``, ``ssh_authorized_keys``,
+    ``ssh_redirect_user``.
 
 The ``user`` key can be used to override the ``default_user`` configuration
 defined in ``/etc/cloud/cloud.cfg``. The ``user`` value should be a dictionary
diff -aur a/cloudinit/config/schemas/schema-cloud-config-v1.json b/cloudinit/config/schemas/schema-cloud-config-v1.json
--- a/cloudinit/config/schemas/schema-cloud-config-v1.json
+++ b/cloudinit/config/schemas/schema-cloud-config-v1.json
@@ -140,6 +140,24 @@
           "description": "The user's login name. Required otherwise user creation will be skipped for this user.",
           "type": "string"
         },
+        "doas": {
+          "description": "List of Doas rules to use. Absence of a doas value or ``null`` will result in no doas rules added for this user.",
+          "oneOf": [
+            {
+              "type": "array",
+              "items": {
+                "type": "string"
+              },
+              "minItems": 1
+            },
+            {
+              "type": [
+                "string",
+                "null"
+              ]
+            }
+          ]
+        },
         "expiredate": {
           "default": null,
           "description": "Optional. Date on which the user's account will be disabled. Default: ``null``",
diff -aur a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py
--- a/cloudinit/distros/__init__.py
+++ b/cloudinit/distros/__init__.py
@@ -93,6 +93,7 @@
     pip_package_name = "python3-pip"
     usr_lib_exec = "/usr/lib"
     hosts_fn = "/etc/hosts"
+    ci_doas_fn = "/etc/doas.conf"
     ci_sudoers_fn = "/etc/sudoers.d/90-cloud-init-users"
     hostname_conf_fn = "/etc/hostname"
     tz_zone_dir = "/usr/share/zoneinfo"
@@ -655,6 +656,7 @@
         * ``plain_text_passwd``
         * ``hashed_passwd``
         * ``lock_passwd``
+        * ``doas``
         * ``sudo``
         * ``ssh_authorized_keys``
         * ``ssh_redirect_user``
@@ -680,6 +682,11 @@
         if kwargs.get("lock_passwd", True):
             self.lock_passwd(name)
 
+        # Configure doas access
+        if 'doas' in kwargs:
+            if kwargs['doas']:
+                self.write_doas_rules(name, kwargs['doas'])
+
         # Configure sudo access
         if "sudo" in kwargs:
             if kwargs["sudo"]:
@@ -785,6 +792,80 @@
         cmd = ["chpasswd"] + (["-e"] if hashed else [])
         subp.subp(cmd, payload)
 
+    def is_doas_rule_valid(self, user, rule):
+        rule_pattern = r"^(?:permit|deny)" + \
+                       r"(?:\s+(?:nolog|nopass|persist|keepenv|setenv \{[^}]+\})+)*" + \
+                       r"\s+([a-zA-Z0-9_]+)+" + \
+                       r"(?:\s+as\s+[a-zA-Z0-9_]+)*" + \
+                       r"(?:\s+cmd\s+[^\s]+(?:\s+args\s+[^\s]+(?:\s*[^\s]+)*)*)*" + \
+                       r"\s*$"
+
+        LOG.debug("Checking if user '%s' is referenced in doas rule %r",
+                  user, rule)
+
+        valid_match = re.search(rule_pattern, rule)
+        if valid_match:
+            LOG.debug("User '%s' referenced in doas rule",
+                      valid_match.group(1))
+            if valid_match.group(1) == user:
+                LOG.debug("Correct user is referenced in doas rule")
+                return True
+            else:
+                LOG.debug("Incorrect user '%s' is referenced in doas rule",
+                          valid_match.group(1))
+                return False
+        else:
+            LOG.debug("Doas rule does not appear to reference any user")
+            return False
+
+    def write_doas_rules(self, user, rules, doas_file=None):
+        if not doas_file:
+            doas_file = self.ci_doas_fn
+
+        if isinstance(rules, (list, tuple)):
+            for rule in rules:
+                if not self.is_doas_rule_valid(user, rule):
+                    msg = "Invalid Doas rule %r for user '%s', not writing any Doas rules for user!" % (rule, user)
+                    LOG.error(msg)
+                    return
+        elif isinstance(rules, str):
+            if not self.is_doas_rule_valid(user, rules):
+                msg = "Invalid Doas rule %r for user '%s', not writing any Doas rules for user!" % (rules, user)
+                LOG.error(msg)
+                return
+
+        lines = [
+            "",
+            "# cloud-init User rules for %s" % user,
+        ]
+        if isinstance(rules, (list, tuple)):
+            for rule in rules:
+                lines.append("%s" % rule)
+        elif isinstance(rules, str):
+            lines.append("%s" % rules)
+        else:
+            msg = "Can not create Doas rule addition with type %r"
+            raise TypeError(msg % (type_utils.obj_name(rules)))
+        content = "\n".join(lines)
+        content += "\n"  # trailing newline
+
+        if not os.path.exists(doas_file):
+            contents = [
+                util.make_header(),
+                content,
+            ]
+            try:
+                util.write_file(doas_file, "\n".join(contents), mode=0o440)
+            except IOError as e:
+                util.logexc(LOG, "Failed to write Doas file %s", doas_file)
+                raise e
+        else:
+            try:
+                util.append_file(doas_file, content)
+            except IOError as e:
+                util.logexc(LOG, "Failed to append Doas file %s", doas_file)
+                raise e
+
     def ensure_sudo_dir(self, path, sudo_base="/etc/sudoers"):
         # Ensure the dir is included and that
         # it actually exists as a directory
diff -aur a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl
--- a/config/cloud.cfg.tmpl
+++ b/config/cloud.cfg.tmpl
@@ -278,6 +278,9 @@
      lock_passwd: True
      gecos: {{ variant }} Cloud User
 {% endif %}
+{% if variant == "alpine" %}
+     doas: ["permit nopass alpine"]
+{% endif %}
 {% if variant == "suse" %}
      groups: [cdrom, users]
 {% elif variant == "gentoo" %}
@@ -285,7 +288,7 @@
      primary_group: users
      no_user_group: true
 {% elif variant == "alpine" %}
-     groups: [adm, sudo]
+     groups: [adm, sudo, wheel]
 {% elif variant == "arch" %}
      groups: [wheel, users]
 {% elif variant == "openmandriva" %}
