diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml index 629449e..5a06bef 100644 --- a/ansible/inventory/group_vars/all.yml +++ b/ansible/inventory/group_vars/all.yml @@ -21,6 +21,14 @@ ssh_admin_pubkey_autodetect: true # read ~/.ssh (id_ed25519 > ecdsa > rsa) when # - "ssh-ed25519 AAAA... bob@laptop" ssh_admin_extra_pubkeys: [] +# Additional NAMED sudo users — DISTINCT accounts (own username, home, key), not extra +# keys on the shared admin above. Each is created key-only like the admin (sudo group, +# NOPASSWD drop-in, locked password). Uncomment and add real users to enable: +# baseline_sudo_users: +# - name: alice +# keys: +# - "ssh-ed25519 AAAA... alice@laptop" + # Key-only admin account: NOPASSWD sudo + locked password (default true). Set false to # keep classic password sudo — you must then set a password on the account yourself. ssh_admin_passwordless_sudo: true diff --git a/ansible/molecule/default/converge.yml b/ansible/molecule/default/converge.yml index 4d0cdf3..d6bde58 100644 --- a/ansible/molecule/default/converge.yml +++ b/ansible/molecule/default/converge.yml @@ -24,5 +24,38 @@ decdn_slash_judge_address: "0x3333333333333333333333333333333333333333" decdn_cache_origin_kind: "http" decdn_cache_origin_url: "https://origin.example.invalid/" + # Exercise baseline's named-sudo-users feature (see pre_tasks below). Throwaway + # keys, generated for this test only — public keys, not credentials. Two users + # on purpose: `alice.smith` has a '.' (so the sudoers.d filename-sanitize maps it + # to /etc/sudoers.d/alice_smith — the one branch whose failure is silent) and two + # keys (exercises the subelements fan-out), while molecule-operator stays single. + baseline_sudo_users: + - name: molecule-operator + keys: + - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA/WM19rWc9jhe0qncc4EuoLzkpR28E+oq77lV2HNvaV molecule@test" + - name: alice.smith + keys: + - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAiMn5YPa5+zTGH+dtmHTWBxw5H2/Z587gzTM44n0T9A alice-key1@test" + - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID0mUUEyMOboViIle8vOOa3aGVXiH5NC2HnxLp+A5Gj+ alice-key2@test" + # baseline as a whole is real-host-only (firewall + DevSec hardening — see + # molecule.yml), but its named-sudo-users block is container-safe, so run just + # that task file here to give the feature real converge/idempotence/verify coverage. + pre_tasks: + # Pre-create alice.smith with a DISABLED-password sentinel ('*', not a real + # credential) so the role's `password_lock` has an unlocked field to convert + # (usermod -L prepends '!' -> '!*'). Without this the account would already be + # '!' from useradd and the lock assertion in verify.yml would pass vacuously. + # update_password: on_create keeps the field stable across the idempotence re-run. + - name: Seed alice.smith with a disabled password so the lock is verifiable + ansible.builtin.user: + name: alice.smith + password: "*" + update_password: on_create + shell: /bin/bash + create_home: true + - name: Exercise baseline's named-sudo-users block (container-safe subset) + ansible.builtin.include_role: + name: baseline + tasks_from: sudo_users roles: - role: decdn_node diff --git a/ansible/molecule/default/verify.yml b/ansible/molecule/default/verify.yml index f6d6022..34737ad 100644 --- a/ansible/molecule/default/verify.yml +++ b/ansible/molecule/default/verify.yml @@ -151,3 +151,81 @@ 'decdn-node.service' in ansible_facts.services and ansible_facts.services['decdn-node.service'].state == 'running' fail_msg: "decdn-node is not running — check: journalctl -u decdn-node -e" + + # --- baseline_sudo_users: the two named sudo users created by converge --------- + # molecule-operator (simple name, one key) and alice.smith (a '.' in the name, so + # the sudoers.d filename-sanitize must map it to /etc/sudoers.d/alice_smith; two + # keys, and seeded with a '*' password so the lock is verifiable — see below). + - name: Read the whole passwd, shadow, and group databases + ansible.builtin.getent: + database: "{{ item }}" + loop: + - passwd + - shadow + - group + + - name: Assert both named users exist, are in the sudo group, and are password-locked + ansible.builtin.assert: + that: + # Each clause self-guards (`is defined and …`) so a missing key routes to + # the fail_msg instead of raising AnsibleUndefinedVariable on the deref. + - getent_passwd['molecule-operator'] is defined + - getent_passwd['alice.smith'] is defined + # Exact membership (split on ',') — not a substring match against the raw + # comma-joined member field. + - >- + getent_group['sudo'] is defined + and 'molecule-operator' in getent_group['sudo'][2].split(',') + - >- + getent_group['sudo'] is defined + and 'alice.smith' in getent_group['sudo'][2].split(',') + # password_lock runs `usermod -L`, prepending '!'. molecule-operator was + # never given a password, so its field is a bare '!'. alice.smith was seeded + # with '*', so a correctly-run lock yields exactly '!*' — a bare '!' there + # would mean the seed or the lock silently didn't happen (a vacuous pass). + - >- + getent_shadow['molecule-operator'] is defined + and getent_shadow['molecule-operator'][0].startswith('!') + - >- + getent_shadow['alice.smith'] is defined + and getent_shadow['alice.smith'][0] == '!*' + fail_msg: "a named sudo user is missing, not in sudo, or not password-locked" + + - name: Stat both named-user sudoers.d drop-ins (alice.smith at its SANITIZED path) + ansible.builtin.stat: + path: "{{ item }}" + register: sudoers_stats + loop: + - /etc/sudoers.d/molecule-operator + - /etc/sudoers.d/alice_smith + + - name: Read both drop-ins and both authorized_keys files + ansible.builtin.slurp: + src: "{{ item }}" + register: sudo_user_files + loop: + - /etc/sudoers.d/molecule-operator + - /etc/sudoers.d/alice_smith + - /home/molecule-operator/.ssh/authorized_keys + - /home/alice.smith/.ssh/authorized_keys + + - name: Assert both drop-ins are 0440 NOPASSWD and every key was installed + ansible.builtin.assert: + that: + - sudoers_stats.results[0].stat.exists and sudoers_stats.results[0].stat.mode == '0440' + - sudoers_stats.results[1].stat.exists and sudoers_stats.results[1].stat.mode == '0440' + - "'molecule-operator ALL=(ALL) NOPASSWD:ALL' in op_dropin" + # Sanitized filename, but the rule inside still names the real user. + - "'alice.smith ALL=(ALL) NOPASSWD:ALL' in alice_dropin" + - op_key in op_keys + - alice_key1 in alice_keys + - alice_key2 in alice_keys + fail_msg: "a sudoers drop-in has the wrong mode/content, or an authorized key is missing" + vars: + op_dropin: "{{ sudo_user_files.results[0].content | b64decode }}" + alice_dropin: "{{ sudo_user_files.results[1].content | b64decode }}" + op_keys: "{{ sudo_user_files.results[2].content | b64decode }}" + alice_keys: "{{ sudo_user_files.results[3].content | b64decode }}" + op_key: "AAAAC3NzaC1lZDI1NTE5AAAAIA/WM19rWc9jhe0qncc4EuoLzkpR28E+oq77lV2HNvaV" + alice_key1: "AAAAC3NzaC1lZDI1NTE5AAAAIAiMn5YPa5+zTGH+dtmHTWBxw5H2/Z587gzTM44n0T9A" + alice_key2: "AAAAC3NzaC1lZDI1NTE5AAAAID0mUUEyMOboViIle8vOOa3aGVXiH5NC2HnxLp+A5Gj+" diff --git a/ansible/roles/baseline/README.md b/ansible/roles/baseline/README.md index e8661ec..394f020 100644 --- a/ansible/roles/baseline/README.md +++ b/ansible/roles/baseline/README.md @@ -18,6 +18,11 @@ In order — the ordering matters: locked (`ssh_admin_passwordless_sudo`), so sudo / `make deploy` needs no become password and no password can authenticate. Set the knob `false` for classic password sudo (you must then set a password on the account yourself). + Additional **named** operator accounts come from `baseline_sudo_users` — each a + distinct login (own username, home, and key), created key-only exactly like the + admin (member of `sudo`, NOPASSWD drop-in, locked password). Use this to give + teammates their own accounts rather than sharing keys on the admin via + `ssh_admin_extra_pubkeys`. 3. **Firewall** — nftables **default-deny inbound**; SSH is the only universally-open port. Extra public listeners are declared explicitly via `baseline_extra_inbound`. 4. **Auto-patching** — `unattended-upgrades` for security updates. @@ -54,7 +59,8 @@ expect — set both explicitly in that case. | `ssh_admin_user` | `""` | Admin sudo account; created before SSH hardening. Empty = the control machine's local `$USER`. | | `ssh_admin_pubkey` | `""` | Admin key. Empty = autodetected from `~/.ssh` (`id_ed25519`/`ecdsa`/`rsa`). Set to override. | | `ssh_admin_pubkey_autodetect` | `true` | When `ssh_admin_pubkey` is empty, read the operator's default local public key. | -| `ssh_admin_extra_pubkeys` | `[]` | Additional authorized keys (full pubkey strings) — e.g. other operators. | +| `ssh_admin_extra_pubkeys` | `[]` | Additional authorized keys (full pubkey strings) on the **shared** admin account — e.g. other operators. | +| `baseline_sudo_users` | `[]` | **Distinct** named sudo accounts, created key-only like the admin. Each item `{name, keys: [...]}` (pubkeys only). | | `ssh_admin_passwordless_sudo` | `true` | Give the admin user NOPASSWD sudo and lock its password (key-only). Set `false` for classic password sudo. | | `ssh_allow_cidrs` | `[]` | Optional inbound-SSH source allowlist (CIDRs). Empty = any source. | | `baseline_extra_inbound` | `[]` | Extra public inbound ports. Each item `{proto, port, comment}`. Loopback services need nothing here; the deCDN node opens udp/4433. | diff --git a/ansible/roles/baseline/defaults/main.yml b/ansible/roles/baseline/defaults/main.yml index 944096a..072f6a3 100644 --- a/ansible/roles/baseline/defaults/main.yml +++ b/ansible/roles/baseline/defaults/main.yml @@ -15,6 +15,12 @@ ssh_admin_pubkey: "" ssh_admin_pubkey_autodetect: true # Additional authorized keys for the admin user (full pubkey strings, e.g. teammates). ssh_admin_extra_pubkeys: [] +# Additional named sudo users — DISTINCT accounts, not extra keys on the shared admin +# (that is ssh_admin_extra_pubkeys above). Each entry gets its OWN username, home, and +# key(s), created key-only exactly like the admin: member of `sudo`, a NOPASSWD +# sudoers.d drop-in, and a locked password (login by key only). Pubkeys only — no +# passwords. Shape: [{name: , keys: ["", ...]}, ...]. +baseline_sudo_users: [] # Passwordless, key-only admin account. ssh_hardening disables SSH password auth, so # the admin user logs in by key only; with this on (default) the account gets a # NOPASSWD sudoers drop-in AND its password is locked — non-interactive sudo / `make diff --git a/ansible/roles/baseline/tasks/main.yml b/ansible/roles/baseline/tasks/main.yml index f7d9aa2..acc05c1 100644 --- a/ansible/roles/baseline/tasks/main.yml +++ b/ansible/roles/baseline/tasks/main.yml @@ -185,6 +185,14 @@ # fail_key:false stores a missing user as {user: None}, so test the value, not `in`. when: not ansible_check_mode or (getent_passwd | default({})).get(ssh_admin_user_effective) +# --- Additional named sudo users (own account+home+key, key-only NOPASSWD) ----- +# Separate from the admin above: these are DISTINCT teammate accounts, not extra +# keys on the shared admin account. Kept in their own file so molecule can exercise +# this container-safe block on its own (the rest of baseline is real-host-only). +- name: Create the additional named sudo users + ansible.builtin.include_tasks: sudo_users.yml + when: baseline_sudo_users | length > 0 + # --- Firewall: default-deny inbound, SSH only --------------------------------- - name: Install nftables ruleset ansible.builtin.template: diff --git a/ansible/roles/baseline/tasks/sudo_users.yml b/ansible/roles/baseline/tasks/sudo_users.yml new file mode 100644 index 0000000..ecf3bfe --- /dev/null +++ b/ansible/roles/baseline/tasks/sudo_users.yml @@ -0,0 +1,127 @@ +--- +# Additional named sudo users — each its OWN account (own username, home, key), +# created key-only EXACTLY like the admin: member of `sudo`, a NOPASSWD sudoers.d +# drop-in, and a locked password (login by key only). Distinct from +# ssh_admin_extra_pubkeys, which only adds keys to the SHARED admin account. Runs +# after the admin block and before the firewall / DevSec hardening, so a listed +# operator can log in immediately once hardening applies. +# +# These accounts are NOT lockout-critical — the play always connects as the admin +# user — so, unlike the admin path, this block needs no getent-probe or lockout +# assert: a plain `when: not ansible_check_mode` guard on the key install is enough +# to keep `make check` on a fresh host (where the account doesn't exist yet, so +# authorized_key would hard-fail) from blowing up. + +# Refuse two operator-input shapes that would otherwise misconfigure an account +# SILENTLY (the role's ethos: fail loud, never a silent sudo lockout) — checked here, +# before any account is touched: +# * An entry with no `keys` (or keys: []): the tasks below would still create the +# account, join it to `sudo`, write its NOPASSWD drop-in, and lock its password — +# a dormant account with passwordless root and no way to log in (ssh_hardening +# disables password auth). A `key:`/`keys:` typo hits exactly this. +# * Two names that sanitize to the SAME sudoers.d filename (e.g. `deploy.bot` and +# `deploy_bot`, or a name colliding with the admin's own drop-in — this block runs +# AFTER the admin block): the copy loop would silently overwrite, leaving a user +# with `sudo` + a locked password but NO NOPASSWD grant. `visudo -cf` validates +# content, not a clobbered file, so it cannot catch this. +- name: Assert each named sudo user is well-formed and has a unique sudoers.d filename + ansible.builtin.assert: + that: + - unnamed_entries | length == 0 + # `keys` as a scalar string (a common `- "ssh-..."` mistake) is truthy, so it + # slips past the keyless check below, but subelements() then mis-iterates it AFTER + # accounts are already changed. Reject it up front to keep this a pre-flight gate. + - stringy_keys_entries | length == 0 + - keyless_named_users | length == 0 + - sudoers_dropin_names | length == (sudoers_dropin_names | unique | length) + fail_msg: >- + baseline_sudo_users is misconfigured (validated before any account is created). + Entries with a missing/empty `name`: {{ unnamed_entries | length }}. + Entries whose `keys` is a string, not a list: {{ stringy_keys_entries }}. + Entries lacking a non-empty `keys` list: {{ keyless_named_users }}. + Sanitized /etc/sudoers.d/ filenames must be unique (including vs the admin + drop-in), else one grant silently overwrites another -> silent sudo lockout; + got {{ sudoers_dropin_names }}. + vars: + # `keys` must NOT be read with map(attribute='keys'): it collides with the dict + # .keys() METHOD, so an entry that omits `keys` (a `key:` typo) yields the bound + # method (truthy) and silently escapes the keyless check. `map('list')` yields each + # entry's real key NAMES instead, so `superset(['keys'])` tells us which entries + # actually carry a `keys` key; attribute access is then safe only on those. + names: "{{ baseline_sudo_users | map(attribute='name', default='') | list }}" + key_names_per_entry: "{{ baseline_sudo_users | map('list') | list }}" + with_keys: >- + {{ baseline_sudo_users | zip(key_names_per_entry) + | selectattr('1', 'superset', ['keys']) | map(attribute='0') | list }} + # `default=''` on the name deref so a missing `name` reports cleanly here rather + # than raising an undefined-attribute error while building fail_msg. + unnamed_entries: >- + {{ (baseline_sudo_users | rejectattr('name', 'defined') | list) + + (baseline_sudo_users | selectattr('name', 'defined') + | rejectattr('name', 'truthy') | list) }} + stringy_keys_entries: >- + {{ with_keys | selectattr('keys', 'string') + | map(attribute='name', default='') | list }} + # Keyless = entries with no `keys` key at all (absent), plus entries whose `keys` + # is present but empty/null (falsy). + keyless_named_users: >- + {{ (names | zip(key_names_per_entry) + | rejectattr('1', 'superset', ['keys']) | map(attribute='0') | list) + + (with_keys | selectattr('keys', 'falsy') + | map(attribute='name', default='') | list) }} + # ssh_admin_user_effective is resolved by the admin block in main.yml; on the + # molecule `tasks_from: sudo_users` path that block never runs, so default it out. + sudoers_dropin_names: >- + {{ (names | map('regex_replace', '[^A-Za-z0-9_-]', '_') | list) + + ([ssh_admin_user_effective | regex_replace('[^A-Za-z0-9_-]', '_')] + if ssh_admin_user_effective is defined else []) }} + +- name: Create the additional named sudo users + ansible.builtin.user: + name: "{{ item.name }}" + groups: [sudo] + append: true + shell: /bin/bash + create_home: true + loop: "{{ baseline_sudo_users }}" + loop_control: + label: "{{ item.name }}" + +# Same sudoers.d filename hazard as the admin drop-in: sudo's #includedir SKIPS any +# file whose name contains a '.' or ends in '~' — which, with the password locked +# below, is a silent sudo lockout. Sanitize the username into the filename (the rule +# inside still names the real user); `visudo -cf` validates content, not the name. +- name: Grant each named sudo user passwordless sudo + ansible.builtin.copy: + dest: "/etc/sudoers.d/{{ item.name | regex_replace('[^A-Za-z0-9_-]', '_') }}" + owner: root + group: root + mode: "0440" + content: "{{ item.name }} ALL=(ALL) NOPASSWD:ALL\n" + validate: "visudo -cf %s" + loop: "{{ baseline_sudo_users }}" + loop_control: + label: "{{ item.name }}" + +- name: Lock each named sudo user's password (key-only login; NOPASSWD sudo) + ansible.builtin.user: + name: "{{ item.name }}" + password_lock: true + loop: "{{ baseline_sudo_users }}" + loop_control: + label: "{{ item.name }}" + +# subelements flattens each (user, key) pair. The assert above already rejects a +# keyless entry, so skip_missing=true here is just defence-in-depth (it would emit +# no pairs rather than error). authorized_key writes into the account's ~/.ssh, so +# the user must exist first — under --check the create above is a no-op, so skip the +# install in check mode (see the not-lockout-critical note in the block header above). +- name: Install each named sudo user's authorized keys + ansible.posix.authorized_key: + user: "{{ item.0.name }}" + key: "{{ item.1 }}" + state: present + loop: "{{ baseline_sudo_users | subelements('keys', skip_missing=true) }}" + loop_control: + label: "{{ item.0.name }}" + when: not ansible_check_mode