From 91bc3805cf0e29e22cf001f2b888221538943e11 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sun, 12 Jul 2026 18:28:06 +0300 Subject: [PATCH 1/2] feat(baseline)!: unify sudo accounts into one primitive; single operator list Collapse the baseline role's two near-duplicate key-only NOPASSWD sudo-account paths (the admin account in main.yml and named users in sudo_users.yml) into one primitive, and remove the ssh_admin_* config family in favour of a single operator list. - New tasks/sudo_account.yml renders one account (create -> sanitized /etc/sudoers.d drop-in -> password lock -> getent probe -> keys); the admin head and every named operator both route through it, so the sanitize/lock/check-mode logic lives in one place. - tasks/sudo_users.yml validates the whole set (well-formed, keyed, unique sudoers.d filenames, non-blank key members) and loops the primitive; still standalone-includable for molecule (tasks_from). - main.yml auto-detects the runner ($USER + ~/.ssh) as the lockout- critical head of baseline_sudo_users; a same-name explicit entry overrides it. The unconditional lockout guard now requires >=1 non-root operator with a usable (non-blank) key before hardening. BREAKING CHANGE: removes ssh_admin_user, ssh_admin_pubkey, ssh_admin_pubkey_autodetect, ssh_admin_extra_pubkeys and ssh_admin_passwordless_sudo. Configure admins via baseline_sudo_users plus baseline_sudo_autodetect_runner / baseline_sudo_passwordless. Verified: ansible-lint (production profile), molecule default (converge + idempotence + verify), and a runner-head/lockout logic matrix. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- ansible/README.md | 21 +- ansible/galaxy/README.md | 6 +- ansible/inventory/group_vars/all.yml | 40 ++- ansible/molecule/default/converge.yml | 2 +- ansible/roles/baseline/README.md | 67 +++-- ansible/roles/baseline/defaults/main.yml | 52 ++-- ansible/roles/baseline/tasks/main.yml | 248 +++++++----------- ansible/roles/baseline/tasks/sudo_account.yml | 106 ++++++++ ansible/roles/baseline/tasks/sudo_users.yml | 182 ++++++------- 10 files changed, 370 insertions(+), 356 deletions(-) create mode 100644 ansible/roles/baseline/tasks/sudo_account.yml diff --git a/README.md b/README.md index 6d8dd14..51a7ac6 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ cd ansible make deps # vendor pinned Galaxy collections into ./collections cp inventory/hosts.yml.example inventory/hosts.yml $EDITOR inventory/hosts.yml # set hosts for decdn_nodes -$EDITOR inventory/group_vars/all.yml # set ssh_admin_pubkey (REQUIRED — prevents lockout) +$EDITOR inventory/group_vars/all.yml # optional: add admins to baseline_sudo_users (runner is auto-detected) cp inventory/host_vars/decdn-node-1/secret.yml.example inventory/host_vars/decdn-node-1/secret.yml $EDITOR inventory/host_vars/decdn-node-1/secret.yml # set decdn_rpc_url (per-node config is in main.yml) make check # dry run (--check --diff) diff --git a/ansible/README.md b/ansible/README.md index aa99456..f7ade1b 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -62,14 +62,15 @@ $EDITOR inventory/host_vars/decdn-node-1/secret.yml # set decdn_rpc_url Your `hosts.yml` is no longer force-ignored — commit it in your fork if you want, or keep it local. -By default baseline **deploys you as yourself**: an empty `ssh_admin_user` resolves to your -control-machine `$USER`, and an empty `ssh_admin_pubkey` is autodetected from `~/.ssh` -(`id_ed25519` > `ecdsa` > `rsa`). Add additional operators' keys via `ssh_admin_extra_pubkeys`. Set -`ssh_admin_user`/`ssh_admin_pubkey` explicitly to override (e.g. a shared `deploy` account, -or when deploying from CI). baseline **asserts a key resolves** before `ssh_hardening` -disables root + password login, so you can't lock yourself out. After the first deploy, -switch each host's `ansible_user` to that admin account (your local username unless you set -one). +By default baseline **deploys you as yourself**: the runner (your control-machine `$USER` + +its autodetected `~/.ssh` key, `id_ed25519` > `ecdsa` > `rsa`) is prepended as the head of +the one operator list, `baseline_sudo_users`. Add other admins there — list yourself (same +name) to override the auto-detected head with explicit keys. Set +`baseline_sudo_autodetect_runner: false` to skip the runner and provision only the explicit +list (e.g. from CI). baseline **asserts a non-root account with a key resolves** before +`ssh_hardening` disables root + password login, so you can't lock yourself out. After the +first deploy, switch each host's `ansible_user` to that admin account (your local username +unless you listed one). --- @@ -148,8 +149,8 @@ the RPC URL). Highlights: | Var | Default | Notes | |-----|---------|-------| -| `ssh_admin_user` / `ssh_admin_pubkey` | `""` / `""` | Empty = local `$USER` + autodetected `~/.ssh` key; admin created before SSH hardening. | -| `ssh_admin_extra_pubkeys` | `[]` | Extra authorized keys for the admin user (additional operators). | +| `baseline_sudo_users` | `[]` | The one operator list (`{name, keys, passwordless?}`); the auto-detected runner head (`$USER` + `~/.ssh` key) is prepended, all created before SSH hardening. | +| `baseline_sudo_autodetect_runner` / `baseline_sudo_passwordless` | `true` / `true` | Prepend the runner as head (`false` = explicit list only); key-only NOPASSWD + locked-password default. | | `baseline_extra_inbound` | `[]` | public inbound ports; `decdn_nodes` opens udp/4433. | | `baseline_preserve_ipv6_autoconf` | `true` | Keep IPv6 RA/autoconf under hardening; set `false` for static-IPv6 hosts. | | `baseline_rp_filter_loose` | `false` | `true` loosens reverse-path filtering (`rp_filter=2`) for multi-homed nodes. | diff --git a/ansible/galaxy/README.md b/ansible/galaxy/README.md index e3cc879..9aec706 100644 --- a/ansible/galaxy/README.md +++ b/ansible/galaxy/README.md @@ -42,8 +42,10 @@ hardening), then the node: roles: - role: decdn.node.baseline vars: - ssh_admin_user: deploy - ssh_admin_pubkey: "ssh-ed25519 AAAA... you@host" # REQUIRED — lockout guard + baseline_sudo_users: # REQUIRED — lockout guard + - name: deploy + keys: ["ssh-ed25519 AAAA... you@host"] + baseline_sudo_autodetect_runner: false # provision only the explicit admin above baseline_extra_inbound: - { proto: udp, port: 4433, comment: "deCDN QUIC" } - role: decdn.node.decdn_node diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml index 5a06bef..810e38a 100644 --- a/ansible/inventory/group_vars/all.yml +++ b/ansible/inventory/group_vars/all.yml @@ -5,33 +5,27 @@ # the roles are self-contained. Set anything here to override per host/group. # Secrets are generated ON THE HOST by the roles and never live here. -# --- Admin SSH access (deploy as yourself by default) ----------------------- -# The admin sudo user + its key are installed BEFORE ssh_hardening disables root -# + password login. Left empty they resolve from the control machine of whoever -# runs ansible-playbook (NOTE: under `sudo ansible-playbook`, cron, or CI the -# $USER/$HOME — and thus the autodetected key — may not be yours; set both -# explicitly there). Explicit values always win. The baseline role aborts before -# hardening unless a NON-ROOT user + a key resolve, so you can't lock yourself out. -ssh_admin_user: "" # "" -> your local $USER (e.g. "deploy" to share one account; never "root") -ssh_admin_pubkey: "" # "" -> autodetected ~/.ssh key; or "ssh-ed25519 AAAA... you@laptop" -ssh_admin_pubkey_autodetect: true # read ~/.ssh (id_ed25519 > ecdsa > rsa) when pubkey is empty -# Additional authorized keys for the admin user (e.g. teammates): -# ssh_admin_extra_pubkeys: -# - "ssh-ed25519 AAAA... alice@laptop" -# - "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: +# --- Admin SSH access: ONE operator list (deploy as yourself by default) ----- +# Admin/operator accounts + their keys are installed BEFORE ssh_hardening disables root +# + password login. There is no separate admin knob: the runner (this control box's +# $USER + its ~/.ssh key) is auto-detected and prepended as the head, so the person +# running the playbook is provisioned without being committed here. Add other admins +# below; list yourself (same name) to OVERRIDE the auto-detected head with explicit +# key(s). The baseline role aborts before hardening unless the resolved set has a +# NON-ROOT account with a key, so you can't lock yourself out. +# NOTE: under `sudo ansible-playbook`, cron, or CI the $USER/$HOME — and thus the +# autodetected key — may not be yours; set baseline_sudo_autodetect_runner: false and +# list admins explicitly here. # 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 +# - name: bot # optional per-entry passwordless override +# keys: ["ssh-ed25519 AAAA... bot@ci"] +# passwordless: false +baseline_sudo_users: [] +# baseline_sudo_autodetect_runner: true # false = provision ONLY the explicit list above +# baseline_sudo_passwordless: true # default sudo mode: NOPASSWD sudo + locked password # Optional inbound-SSH source allowlist (CIDRs). Empty = accept from any source. ssh_allow_cidrs: [] diff --git a/ansible/molecule/default/converge.yml b/ansible/molecule/default/converge.yml index d6bde58..f14b9c8 100644 --- a/ansible/molecule/default/converge.yml +++ b/ansible/molecule/default/converge.yml @@ -28,7 +28,7 @@ # 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. + # keys (exercises the multi-key authorized_key loop), while molecule-operator stays single. baseline_sudo_users: - name: molecule-operator keys: diff --git a/ansible/roles/baseline/README.md b/ansible/roles/baseline/README.md index f493492..fa346c9 100644 --- a/ansible/roles/baseline/README.md +++ b/ansible/roles/baseline/README.md @@ -9,20 +9,22 @@ In order — the ordering matters: 1. **Base packages** — `curl`, `git`, `jq`, `openssl`, `nftables`, `fail2ban`, `unattended-upgrades`, `chrony`, … (override `baseline_packages`). -2. **Admin sudo user** — creates `ssh_admin_user` and installs its key(s) - **before** SSH is hardened, so you keep a way in. Both resolve from the control - machine when left empty: the user falls back to the local `$USER`, and the key is - autodetected from `~/.ssh` (`id_ed25519` > `id_ecdsa` > `id_rsa`). Extra operator keys - come from `ssh_admin_extra_pubkeys`. Explicit values always win. By default the - account is **key-only**: it gets a NOPASSWD sudoers drop-in and its password is - 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`. +2. **Sudo operators** — one list, `baseline_sudo_users`, installed **before** SSH is + hardened so you keep a way in. There is no separate admin knob: the **runner** (this + control box's `$USER` + its `~/.ssh` key, autodetected `id_ed25519` > `id_ecdsa` > + `id_rsa`) is prepended as the lockout-critical **head**, so the person running + `make deploy` is provisioned without being committed anywhere. Set + `baseline_sudo_autodetect_runner: false` to skip that and provision only the explicit + list (e.g. CI). Add other admins to `baseline_sudo_users` — each a distinct login (own + username, home, key(s)); listing yourself there (same name) **overrides** the + auto-detected head with explicit keys. Every operator is created **key-only**: member + of `sudo`, a NOPASSWD sudoers drop-in, and a locked password, so sudo / `make deploy` + needs no become password and no password can authenticate. `baseline_sudo_passwordless` + (default `true`) is the role-wide default; set it (or a per-entry `passwordless: false`) + `false` for classic password sudo (you must then set a password on the account + yourself). The head and every listed operator are rendered by a single primitive + (`tasks/sudo_account.yml`), so the sudoers.d sanitize / password-lock / check-mode + logic lives in exactly one place. 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. @@ -42,33 +44,30 @@ In order — the ordering matters: ## Lockout guard `ssh_hardening` disables root and password auth. The role runs an **unconditional -assert** before any account creation or hardening that aborts the play unless a -**non-root** admin user *and* at least one key resolve. By default these come from -the control machine (local `$USER` + `~/.ssh` key), so a stock interactive run "just -works". The assert fires — by design, so you can fix it rather than lock yourself -out — when any of these hold: +assert** before any account creation or hardening that aborts the play unless the +resolved operator set (auto-detected runner head + `baseline_sudo_users`) contains at +least one **non-root** account **with a key**. By default the head comes from the +control machine (local `$USER` + `~/.ssh` key), so a stock interactive run "just works". +The assert fires — by design, so you can fix it rather than lock yourself out — when +the whole set has no viable admin, e.g.: -- **No key resolves**: no `~/.ssh/id_ed25519|ecdsa|rsa.pub` and no explicit - `ssh_admin_pubkey`/`ssh_admin_extra_pubkeys` (the most common real-world trigger). -- **The user is unresolvable**: `ssh_admin_user` empty *and* `$USER` unset (cron, CI, - or `sudo` with `env_reset`). -- **The user resolves to `root`**: hardening forbids root login, so this would be a - guaranteed lockout — set `ssh_admin_user` to a non-root account. +- **No runner head resolves** (no `~/.ssh/id_ed25519|ecdsa|rsa.pub`, `$USER` unset/`root` + under cron/CI/`sudo`, or `baseline_sudo_autodetect_runner: false`) **and** + `baseline_sudo_users` is empty — nothing to log in as. +- The only entries resolve to **`root`** (hardening forbids root login) or are + **keyless** — add a non-root, keyed entry to `baseline_sudo_users`. -Because resolution reads the **control node's** `$USER`/`$HOME` of whoever invokes -`ansible-playbook`, a `sudo`/CI run can autodetect a different user/key than you -expect — set both explicitly in that case. +Because the head reads the **control node's** `$USER`/`$HOME` of whoever invokes +`ansible-playbook`, a `sudo`/CI run can autodetect a different user/key than you expect — +set `baseline_sudo_autodetect_runner: false` and list admins explicitly in that case. ## Key variables | Var | Default | Notes | |-----|---------|-------| -| `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) 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. | +| `baseline_sudo_users` | `[]` | The one operator list. Each item `{name, keys: [...], passwordless?}` (pubkeys only), created key-only. The auto-detected runner head is prepended; a same-name entry here overrides it. | +| `baseline_sudo_autodetect_runner` | `true` | Auto-detect the runner (`$USER` + `~/.ssh` `id_ed25519`/`ecdsa`/`rsa`) and prepend it as the head. `false` = provision only the explicit list (e.g. CI). | +| `baseline_sudo_passwordless` | `true` | Role-wide default sudo mode: NOPASSWD drop-in + locked password (key-only). Per-entry `passwordless: false` overrides; `false` = 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. | | `baseline_preserve_ipv6_autoconf` | `true` | Re-enable IPv6 RA/autoconf under `os_hardening` (which disables it, breaking SLAAC addresses). Set `false` for statically-addressed IPv6 hosts to keep full CIS IPv6 hardening. | diff --git a/ansible/roles/baseline/defaults/main.yml b/ansible/roles/baseline/defaults/main.yml index f3c33cf..d3cb7f7 100644 --- a/ansible/roles/baseline/defaults/main.yml +++ b/ansible/roles/baseline/defaults/main.yml @@ -1,33 +1,33 @@ --- # baseline role defaults. Override in inventory/group_vars for a real deployment. -# Admin sudo account, created + keyed BEFORE ssh_hardening disables root login. -# "Deploy as yourself" by default — both knobs resolve from the control machine -# when left empty, and explicit values always win: -# ssh_admin_user "" -> the control-machine local $USER (of whoever runs -# ansible-playbook; under sudo/CI this may not be you) -# ssh_admin_pubkey "" -> autodetected from that same ~/.ssh (id_ed25519 > ecdsa > rsa) -# The lockout-guard assert in tasks/main.yml aborts the run (before any hardening) -# unless a NON-ROOT admin user and >=1 key resolve — so hardening never runs keyless. -ssh_admin_user: "" -ssh_admin_pubkey: "" -# When ssh_admin_pubkey is empty, read the operator's default local public key. -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: ["", ...]}, ...]. +# Sudo operators (admin accounts), created + keyed BEFORE ssh_hardening disables root +# login. ONE list — no separate admin knobs. "Deploy as yourself" by default: the runner +# (the control-machine $USER of whoever runs ansible-playbook, plus its ~/.ssh key) is +# auto-detected and prepended as the lockout-critical head, so the operator running +# `make deploy` is provisioned WITHOUT appearing in any committed file. Add any other +# admins to baseline_sudo_users; list yourself there (same name) to OVERRIDE the +# auto-detected head with explicit key(s)/settings. Each operator is created key-only: +# member of `sudo`, a NOPASSWD sudoers.d drop-in, and a locked password (login by key +# only). Pubkeys only — no passwords. +# Shape: [{name: , keys: ["", ...], passwordless: }, ...] +# (`passwordless` is optional per entry; it defaults to baseline_sudo_passwordless below.) +# The lockout-guard assert in tasks/main.yml aborts the run (before any hardening) unless +# the resolved set has a NON-ROOT account with a key — so hardening never runs keyless. 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 -# deploy` works with no become password, and no password can authenticate at all. -# Set false to keep the classic password-sudo model (you must then set a password on -# the account yourself, e.g. `passwd `, to be able to sudo). -ssh_admin_passwordless_sudo: true +# Auto-detect the runner ($USER + ~/.ssh id_ed25519 > ecdsa > rsa) and prepend it as the +# operator-set head. On (default) = zero-config deploy-as-yourself. Set false to provision +# ONLY the explicit baseline_sudo_users (e.g. CI/cron, where $USER may be root/unset or a +# shared automation user you don't want to grant a permanent sudo account). +# NOTE: under `sudo ansible-playbook`, cron, or CI the $USER/$HOME — and thus the +# autodetected key — may not be yours; turn this off and list admins explicitly there. +baseline_sudo_autodetect_runner: true +# Default sudo mode for operators without a per-entry `passwordless`. true (default) = +# key-only: a NOPASSWD sudoers drop-in AND a locked password, so non-interactive sudo / +# `make deploy` works with no become password and no password can authenticate. false = +# classic password sudo (you must then set a password on the account yourself, e.g. +# `passwd `, to be able to sudo once ssh_hardening disables root login). +baseline_sudo_passwordless: true # Optional inbound-SSH source allowlist (CIDRs). Empty = accept from any source. ssh_allow_cidrs: [] diff --git a/ansible/roles/baseline/tasks/main.yml b/ansible/roles/baseline/tasks/main.yml index 5903699..59993b6 100644 --- a/ansible/roles/baseline/tasks/main.yml +++ b/ansible/roles/baseline/tasks/main.yml @@ -17,181 +17,109 @@ - name: Probe which service units already exist on the target ansible.builtin.service_facts: -# --- Admin account (must exist + have a key BEFORE ssh_hardening locks root) --- -# Resolve the user and key set from the control machine when left empty; explicit -# values always win. Lookups run on the control node — that's the operator's box. -- name: Resolve the admin sudo username (local $USER if unset) +# --- Sudo operators: ONE list, admin head auto-detected (all created + keyed BEFORE +# ssh_hardening locks root) -------------------------------------------------------- +# There is no separate admin config: the operator set is the auto-detected runner head +# (this control box's $USER + its ~/.ssh key — "deploy as yourself", never committed) with +# `baseline_sudo_users` appended. Lookups run on the control node — that's the operator's box. +- name: Autodetect the runner's local SSH public key (control machine) ansible.builtin.set_fact: - ssh_admin_user_effective: >- - {{ ssh_admin_user if ssh_admin_user | length > 0 - else lookup('ansible.builtin.env', 'USER') }} - -- name: Autodetect the operator's local SSH public key (control machine) - ansible.builtin.set_fact: - ssh_admin_detected_pubkey: "{{ lookup('ansible.builtin.file', found) | trim }}" + baseline_runner_pubkey: "{{ lookup('ansible.builtin.file', found) | trim if found | length > 0 else '' }}" vars: + # ~ expands via os.path.expanduser, which falls back to the passwd DB when $HOME is + # unset (cron/CI) — more robust than an env lookup that returns ''. Only probe ~/.ssh + # when autodetection is on; otherwise this stays '' and no head is prepended below. found: >- - {{ query('ansible.builtin.first_found', - {'files': candidates, 'skip': true}) | first | default('') }} - # ~ expands via os.path.expanduser, which falls back to the passwd DB when - # $HOME is unset (cron/CI) — more robust than an env lookup that returns ''. + {{ (query('ansible.builtin.first_found', {'files': candidates, 'skip': true}) + | first | default('')) + if baseline_sudo_autodetect_runner | bool else '' }} candidates: - "~/.ssh/id_ed25519.pub" - "~/.ssh/id_ecdsa.pub" - "~/.ssh/id_rsa.pub" - when: - - ssh_admin_pubkey | length == 0 - - ssh_admin_pubkey_autodetect | bool - - found | length > 0 -- name: Resolve the set of admin authorized keys +# Prepend the runner as the operator-set head ONLY when it is a usable admin: autodetect +# on, a non-empty non-root $USER, a key found, AND the runner isn't already named in +# baseline_sudo_users (an explicit entry with the same name OVERRIDES the auto head, so you +# can pin your own keys/settings without a duplicate sudoers.d drop-in collision). Yields a +# 0-or-1-element list so the assembly and the lockout guard below are pure list logic. +- name: Resolve the auto-detected runner head (0 or 1 operator) ansible.builtin.set_fact: - ssh_admin_keys: >- - {{ (([ssh_admin_pubkey] if ssh_admin_pubkey | length > 0 - else [ssh_admin_detected_pubkey | default('')]) - + ssh_admin_extra_pubkeys) - | map('trim') | reject('equalto', '') | unique | list }} - -# UNCONDITIONAL lockout guard — runs BEFORE the account is created and hardening -# applied. It is the single gate that makes a keyless/root-only harden impossible; -# downstream tasks therefore need no `when`. ssh_hardening disables root login, so a -# resolved admin user of "root" (or an empty one, e.g. $USER unset under cron/CI) -# would lock the host out — both are refused here rather than skipped silently. -- name: Refuse to harden SSH without a non-root admin user and a key (lockout guard) + baseline_runner_head: >- + {{ [{'name': runner_name, 'keys': [baseline_runner_pubkey]}] if prepend else [] }} + vars: + runner_name: "{{ lookup('ansible.builtin.env', 'USER') }}" + prepend: >- + {{ baseline_sudo_autodetect_runner | bool + and runner_name | length > 0 + and runner_name != 'root' + and baseline_runner_pubkey | length > 0 + and runner_name not in (baseline_sudo_users | map(attribute='name', default='') | list) }} + +- name: Assemble the operator set (runner head + committed admins) + ansible.builtin.set_fact: + baseline_operators: "{{ baseline_runner_head + baseline_sudo_users }}" + +# UNCONDITIONAL lockout guard — runs BEFORE any account is created and hardening applied. +# It is the single gate that makes a keyless/root-only harden impossible; downstream tasks +# therefore need no `when`. ssh_hardening disables root login, so the merged operator set +# must contain at least one NON-ROOT account that carries a key — otherwise nothing could +# log back in afterwards. An empty set (autodetect off/unresolved AND no committed admins) +# and a root-only/keyless set are both refused here rather than discovered post-hardening. +- name: Refuse to harden SSH without a non-root, keyed admin account (lockout guard) ansible.builtin.assert: that: - - ssh_admin_user_effective | length > 0 - - ssh_admin_user_effective != 'root' - - ssh_admin_keys | length > 0 + - usable_admin_keys | length > 0 fail_msg: >- - Cannot establish a working admin login before devsec.hardening.ssh_hardening - disables root + password auth. Resolved user='{{ ssh_admin_user_effective }}', - keys={{ ssh_admin_keys | length }}. Causes: ssh_admin_user is empty and the - control-machine $USER is unset (cron/CI/sudo with env_reset) -> set - ssh_admin_user; the resolved user is 'root' (hardening forbids root login) -> - set ssh_admin_user to a non-root account; or no key was found -> set - ssh_admin_pubkey, add ssh_admin_extra_pubkeys, or place a default key - (~/.ssh/id_ed25519.pub, id_ecdsa.pub, or id_rsa.pub) on the control machine. - -# Surface WHAT will be trusted: autodetect installs whatever key sits in the -# control box's ~/.ssh, so echo the resolved user + key count (a silently dropped -# malformed entry, or a wrong control machine, shows up as an unexpected count). -- name: Report the resolved admin user and key count - ansible.builtin.debug: - msg: >- - Admin user '{{ ssh_admin_user_effective }}' will be authorized with - {{ ssh_admin_keys | length }} key(s); primary source = - {{ 'explicit ssh_admin_pubkey' if ssh_admin_pubkey | length > 0 - else ('autodetected ~/.ssh key' - if (ssh_admin_detected_pubkey | default('')) | length > 0 - else 'none (extra keys only)') }}, - plus {{ ssh_admin_extra_pubkeys | length }} extra. - -- name: Create admin sudo user - ansible.builtin.user: - name: "{{ ssh_admin_user_effective }}" - groups: [sudo] - append: true - shell: /bin/bash - create_home: true - -# --- Passwordless, key-only admin (ssh_hardening disables SSH password auth) ----- -# The account logs in by key only, so it needs no password at all: grant NOPASSWD -# sudo (so non-interactive sudo / `make deploy` works with no become password) and -# lock the password (so nothing can authenticate by password). Both are gated on the -# same knob; turning it off removes the grant and falls back to classic sudo-group -# password sudo — which needs a real password, so we must NOT lock it in that case -# (and the operator must set one — see the disabled-path warning below). - -# sudo's #includedir SKIPS any file in sudoers.d whose name contains a '.' or ends -# in '~'. A username like `deploy.bot` would produce a valid-but-ignored drop-in, so -# NOPASSWD would silently not apply — and with the password locked below that is a -# sudo lockout. `visudo -cf` validates content, not the filename, so it can't catch -# this. Sanitize the name to sudo's accepted charset; the rule inside still names the -# real user, so the grant is unaffected. -- name: Resolve the admin sudoers.d drop-in path (sudo ignores '.'/'~' in filenames) - ansible.builtin.set_fact: - ssh_admin_sudoers_file: >- - /etc/sudoers.d/{{ ssh_admin_user_effective | regex_replace('[^A-Za-z0-9_-]', '_') }} - -- name: Grant the admin user passwordless sudo - ansible.builtin.copy: - dest: "{{ ssh_admin_sudoers_file }}" - owner: root - group: root - mode: "0440" - content: "{{ ssh_admin_user_effective }} ALL=(ALL) NOPASSWD:ALL\n" - validate: "visudo -cf %s" - when: ssh_admin_passwordless_sudo | bool - -- name: Lock the admin user's password (key-only login; NOPASSWD sudo) - ansible.builtin.user: - name: "{{ ssh_admin_user_effective }}" - password_lock: true - when: ssh_admin_passwordless_sudo | bool - -# Reverse the NOPASSWD grant if the knob is later turned off. The password lock is -# intentionally NOT auto-reverted: unlocking a never-set password is unsafe/undefined -# — older shadow-utils blank it (passwordless account), newer ones (Debian bookworm / -# current Ubuntu) refuse. Regaining password sudo is a manual `passwd ` step. -# The `length > 0` guard is defence-in-depth: the lockout-guard assert above already -# aborts on an empty user, but if it were ever bypassed the path would collapse to the -# bare `/etc/sudoers.d/` directory and state:absent would recursively delete it, -# breaking sudo host-wide. Never run this destructive task on an empty username. -- name: Remove the passwordless-sudo drop-in when disabled - ansible.builtin.file: - path: "{{ ssh_admin_sudoers_file }}" - state: absent - when: - - not (ssh_admin_passwordless_sudo | bool) - - ssh_admin_user_effective | length > 0 - -# Loud fallback warning: with the knob off there is no NOPASSWD grant, and a fresh -# account (or one locked by a prior passwordless run) has no usable password — so it -# cannot sudo after ssh_hardening unless the operator sets one on the host. Flipping -# this true->false on an ALREADY-hardened host while connected as that admin user also -# breaks the current run once the drop-in is removed (later tasks can no longer -# `sudo -n`) — set a password first and re-run with `--ask-become-pass`. -- name: Warn that classic password sudo needs a password set on the host + No usable admin login exists before devsec.hardening.ssh_hardening disables root + + password auth: the operator set has {{ baseline_operators | length }} entr{{ 'y' + if baseline_operators | length == 1 else 'ies' }}, but none is a non-root account + with a real key. Causes: baseline_sudo_autodetect_runner is off (or $USER is + unset/root, or no ~/.ssh/id_ed25519|ecdsa|rsa.pub was found) AND baseline_sudo_users + is empty or lists only root/keyless entries (a `keys: []`, `keys: [""]`, or blank + key counts as keyless here). Fix: add an admin to baseline_sudo_users as + {name: , keys: ["ssh-..."]}, or run where $USER + a ~/.ssh key + resolve on the control machine. + vars: + # Non-root, truthy-named operators. (name has no dict-method collision, so attribute + # access is safe here — unlike `keys` below.) + nonroot_named: >- + {{ baseline_operators | selectattr('name', 'defined') | selectattr('name', 'truthy') + | rejectattr('name', 'equalto', 'root') | list }} + # Their actual key MATERIAL — the guard requires a usable key, not merely a declared + # field. Collision-safe: map('list') yields each dict's KEY NAMES, so superset(['keys']) + # selects entries that declare a `keys` key (attribute='keys' would hit the dict .keys() + # METHOD); map(attribute='keys') is then safe on that subset. flatten + trim + drop + # empties so a keyless entry, `keys: []`, `keys: [""]`, or a whitespace-only key + # contributes nothing — otherwise a password-locked account with no installable key + # would pass the guard and brick the host once hardening disables root/password login. + usable_admin_keys: >- + {{ nonroot_named | zip(nonroot_named | map('list')) + | selectattr('1', 'superset', ['keys']) | map(attribute='0') + | rejectattr('keys', 'string') | map(attribute='keys') | flatten + | map('trim') | reject('equalto', '') | list }} + +# Surface WHAT will be provisioned: autodetect trusts whatever key sits in the control +# box's ~/.ssh, so echo the resolved operator names and whether the runner head was added +# (a wrong control machine, or an unexpectedly-overridden head, shows up here). +- name: Report the resolved operator set ansible.builtin.debug: msg: >- - ssh_admin_passwordless_sudo is false: admin user '{{ ssh_admin_user_effective }}' - uses classic sudo-group password sudo. If its password is unset or locked (fresh - account, or a prior passwordless run), run `passwd {{ ssh_admin_user_effective }}` - on the host or it will be unable to sudo once SSH hardening disables root login. - Switching from passwordless to password sudo on a live hardened host needs that - password set first, then a re-run with `--ask-become-pass`. - when: not (ssh_admin_passwordless_sudo | bool) - -# authorized_key resolves the user's ~/.ssh to write the key, so the account must -# already exist. Under --check the create task above is a no-op, so on a not-yet- -# provisioned host the user is absent and the module hard-fails ("Either user must -# exist or you must provide full path to key file in check mode"). Probe real -# on-host state (read-only, runs in check mode) and skip the key install only when -# check mode meets a missing user; real runs and check runs on a provisioned host -# (where drift is worth previewing) always proceed. -- name: Probe whether the admin user already exists on the target - ansible.builtin.getent: - database: passwd - key: "{{ ssh_admin_user_effective }}" - fail_key: false - -- name: Install admin authorized keys - ansible.posix.authorized_key: - user: "{{ ssh_admin_user_effective }}" - key: "{{ item }}" - state: present - loop: "{{ ssh_admin_keys }}" - # 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 + Provisioning {{ baseline_operators | length }} sudo operator(s): + {{ baseline_operators | map(attribute='name', default='?') | list | join(', ') }}. + {{ ('Runner head auto-detected from $USER + ~/.ssh: ' + + (baseline_runner_head | map(attribute='name') | first) + '.') + if baseline_runner_head | length > 0 + else ('No auto-detected runner head (autodetect off, $USER/key unresolved,' + + ' or overridden by an explicit entry); admins from baseline_sudo_users.') }} + +# Runs unconditionally — the lockout guard above has proven a usable admin exists. +# sudo_users.yml validates the whole set (well-formed, keyed, unique sudoers.d filenames) +# and routes every operator through the shared sudo_account.yml primitive, provisioning +# them BEFORE ssh_hardening locks root. Kept in its own file so molecule can exercise the +# named-users subset standalone (`tasks_from: sudo_users`). +- name: Provision every sudo operator via one primitive ansible.builtin.include_tasks: sudo_users.yml - when: baseline_sudo_users | length > 0 # --- Firewall: default-deny inbound, SSH only --------------------------------- - name: Install nftables ruleset diff --git a/ansible/roles/baseline/tasks/sudo_account.yml b/ansible/roles/baseline/tasks/sudo_account.yml new file mode 100644 index 0000000..4b62215 --- /dev/null +++ b/ansible/roles/baseline/tasks/sudo_account.yml @@ -0,0 +1,106 @@ +--- +# One key-only NOPASSWD sudo account — the single primitive that the auto-detected admin +# head and every named operator both route through. Takes a `sudo_account` dict +# {name, keys, passwordless} (passwordless is optional; it defaults to the role-wide +# baseline_sudo_passwordless) and renders the account ONCE: create the user, write its +# sanitized sudoers.d drop-in, lock its password, and install its keys — with all the +# sanitize / lock / check-mode reasoning that used to live twice. +# +# `name`/`keys` are read with SUBSCRIPT, never dot-access: `sudo_account.keys` resolves to +# the dict's `.keys()` METHOD (truthy garbage), so it would silently install no real keys. +# `sudo_account['keys']` returns the list. + +- name: Create sudo user + ansible.builtin.user: + name: "{{ sudo_account['name'] }}" + groups: [sudo] + append: true + shell: /bin/bash + create_home: true + +# sudo's #includedir SKIPS any file in sudoers.d whose name contains a '.' or ends in '~'. +# A username like `deploy.bot` would produce a valid-but-ignored drop-in, so NOPASSWD would +# silently not apply — and with the password locked below that is a sudo lockout. `visudo +# -cf` validates content, not the filename, so it can't catch this. Sanitize the name to +# sudo's accepted charset; the rule inside still names the real user, so the grant is +# unaffected. (Uniqueness of these sanitized names across the whole operator set is asserted +# up front in sudo_users.yml, so two names can't collide onto one drop-in here.) +- name: Resolve the sudoers.d drop-in path (sudo ignores '.'/'~' in filenames) + ansible.builtin.set_fact: + sudo_account_sudoers_file: >- + /etc/sudoers.d/{{ sudo_account['name'] | regex_replace('[^A-Za-z0-9_-]', '_') }} + +# Passwordless, key-only account (the default): grant NOPASSWD sudo (so non-interactive +# sudo / `make deploy` works with no become password) and lock the password (so nothing can +# authenticate by password — ssh_hardening disables SSH password auth anyway). Both are +# gated on the same per-account flag; turning it off falls back to classic sudo-group +# password sudo (see the disabled-path warning below). +- name: Grant the user passwordless sudo + ansible.builtin.copy: + dest: "{{ sudo_account_sudoers_file }}" + owner: root + group: root + mode: "0440" + content: "{{ sudo_account['name'] }} ALL=(ALL) NOPASSWD:ALL\n" + validate: "visudo -cf %s" + when: sudo_account['passwordless'] | default(baseline_sudo_passwordless) | bool + +- name: Lock the user's password (key-only login; NOPASSWD sudo) + ansible.builtin.user: + name: "{{ sudo_account['name'] }}" + password_lock: true + when: sudo_account['passwordless'] | default(baseline_sudo_passwordless) | bool + +# Reverse the NOPASSWD grant if passwordless is turned off. The password lock is +# intentionally NOT auto-reverted: unlocking a never-set password is unsafe/undefined — +# older shadow-utils blank it (passwordless account), newer ones (Debian bookworm / current +# Ubuntu) refuse. Regaining password sudo is a manual `passwd ` step. The `length > 0` +# guard is defence-in-depth: the pre-flight assert already rejects an empty name, but if it +# were ever bypassed the path would collapse to the bare `/etc/sudoers.d/` directory and +# state:absent would recursively delete it, breaking sudo host-wide. Never run this +# destructive task on an empty username. +- name: Remove the passwordless-sudo drop-in when disabled + ansible.builtin.file: + path: "{{ sudo_account_sudoers_file }}" + state: absent + when: + - not (sudo_account['passwordless'] | default(baseline_sudo_passwordless) | bool) + - sudo_account['name'] | length > 0 + +# Loud fallback warning: with passwordless off there is no NOPASSWD grant, and a fresh +# account (or one locked by a prior passwordless run) has no usable password — so it cannot +# sudo after ssh_hardening unless a password is set on the host. Flipping this true->false +# on an ALREADY-hardened host while connected as that user also breaks the current run once +# the drop-in is removed (later tasks can no longer `sudo -n`) — set a password first and +# re-run with `--ask-become-pass`. +- name: Warn that classic password sudo needs a password set on the host + ansible.builtin.debug: + msg: >- + passwordless sudo is off for '{{ sudo_account['name'] }}': it uses classic sudo-group + password sudo. If its password is unset or locked (fresh account, or a prior + passwordless run), run `passwd {{ sudo_account['name'] }}` on the host or it will be + unable to sudo once SSH hardening disables root login. Switching from passwordless to + password sudo on a live hardened host needs that password set first, then a re-run + with `--ask-become-pass`. + when: not (sudo_account['passwordless'] | default(baseline_sudo_passwordless) | bool) + +# authorized_key resolves the user's ~/.ssh to write the key, so the account must already +# exist. Under --check the create task above is a no-op, so on a not-yet-provisioned host +# the user is absent and the module hard-fails ("Either user must exist or you must provide +# full path to key file in check mode"). Probe real on-host state (read-only, runs in check +# mode) and skip the key install only when check mode meets a missing user; real runs and +# check runs on a provisioned host (where drift is worth previewing) always proceed. +- name: Probe whether the user already exists on the target + ansible.builtin.getent: + database: passwd + key: "{{ sudo_account['name'] }}" + fail_key: false + +- name: Install the user's authorized keys + ansible.posix.authorized_key: + user: "{{ sudo_account['name'] }}" + key: "{{ item }}" + state: present + loop: "{{ sudo_account['keys'] }}" + # 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(sudo_account['name']) diff --git a/ansible/roles/baseline/tasks/sudo_users.yml b/ansible/roles/baseline/tasks/sudo_users.yml index ecf3bfe..9ba6000 100644 --- a/ansible/roles/baseline/tasks/sudo_users.yml +++ b/ansible/roles/baseline/tasks/sudo_users.yml @@ -1,127 +1,111 @@ --- -# 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 +# Provision a set of key-only NOPASSWD sudo operators through the shared sudo_account.yml +# primitive. Called two ways: +# * from main.yml with the `baseline_operators` fact = the auto-detected admin head plus +# any committed `baseline_sudo_users` — the whole operator set, validated together; +# * standalone via molecule's `tasks_from: sudo_users` (no admin context), where +# `baseline_operators` is unset and it falls back to `baseline_sudo_users` — so the +# named-users block keeps its container-safe standalone converge/idempotence/verify +# coverage (the rest of baseline is real-host-only). +# Each operator becomes its OWN account (own username, home, key(s)), created key-only: +# member of `sudo`, a NOPASSWD sudoers.d drop-in, and a locked password. This runs after +# the admin identity is resolved 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. +# On the production (main.yml) path these accounts are not individually lockout-critical — +# the play connects as the admin head, whose non-root/keyed viability is proven by the +# UNCONDITIONAL lockout assert in main.yml before this file runs. On the molecule +# `tasks_from` path main.yml never runs, but that is a throwaway container with no hardening, +# so lockout is moot there. Per-account check-mode safety lives in the primitive +# (a getent probe gates each key install, so `make check` on a fresh host — where an account +# doesn't exist yet — doesn't hard-fail). -# 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. +# Refuse 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, over the WHOLE set. With the admin head as a real list member (when called +# from main), an admin-vs-named sudoers.d collision is caught by the same uniqueness check +# with no special-casing: +# * An entry with no `keys` (or keys: []): the primitive 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. +# * `keys` as a scalar string (a common `- "ssh-..."` mistake): truthy, so it slips past +# the keyless check, but the primitive's authorized_key loop would then mis-iterate the +# string char-by-char AFTER the account is already changed. Reject it up front. # * 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 +# `deploy_bot`, or a named user colliding with the admin's own drop-in): the primitive's +# copy 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 can't catch +# this. +- name: Assert each sudo operator 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. + # `keys` as a scalar string is truthy, so it slips past the keyless check below, but + # the authorized_key loop would then mis-iterate it char-by-char AFTER accounts are + # changed. Reject up front. - stringy_keys_entries | length == 0 - - keyless_named_users | length == 0 + - keyless_operators | length == 0 + # A non-empty keys list whose MEMBERS are blank/whitespace passes keyless (the list + # is truthy) yet installs no key — leaving a password-locked, sudo-group, NOPASSWD + # account with no way to log in (a silent lockout if it is the sole admin). Reject it. + - blank_key_entries | length == 0 - sudoers_dropin_names | length == (sudoers_dropin_names | unique | length) fail_msg: >- - baseline_sudo_users is misconfigured (validated before any account is created). + The sudo operator set 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 }}. + Entries lacking a non-empty `keys` list: {{ keyless_operators }}. + Entries with a blank/whitespace-only key: {{ blank_key_entries }}. + Sanitized /etc/sudoers.d/ filenames must be unique (admin head included), 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 }}" + # The set to validate: the merged operator list from main.yml, or — on the standalone + # molecule `tasks_from` path where that fact is unset — just the committed named users. + _ops: "{{ baseline_operators | default(baseline_sudo_users) }}" + # `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: "{{ _ops | map(attribute='name', default='') | list }}" + key_names_per_entry: "{{ _ops | map('list') | list }}" with_keys: >- - {{ baseline_sudo_users | zip(key_names_per_entry) + {{ _ops | 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. + # `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') + {{ (_ops | rejectattr('name', 'defined') | list) + + (_ops | 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: >- + # Keyless = entries with no `keys` key at all (absent), plus entries whose `keys` is + # present but empty/null (falsy). + keyless_operators: >- {{ (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. + # Names of entries with a blank/whitespace-only key MEMBER (list truthy, so it escapes + # keyless above). subelements pairs each (entry, key); `match '^\s*$'` flags empty/blank + # keys; then report the entry name. rejectattr('keys','string') keeps subelements off a + # scalar `keys` (already rejected by stringy_keys_entries, but belt-and-suspenders). + blank_key_entries: >- + {{ with_keys | rejectattr('keys', 'string') + | subelements('keys', skip_missing=true) + | selectattr('1', 'match', '^\s*$') + | map(attribute='0') | map(attribute='name', default='') | unique | list }} + # Uniqueness over the whole set (admin head is already a member, so no separate + # admin-drop-in term is needed — this subsumes the old admin-vs-named collision check). 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 []) }} + {{ names | map('regex_replace', '[^A-Za-z0-9_-]', '_') | list }} -- 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 }}" +- name: Provision each sudo operator via the shared account primitive + ansible.builtin.include_tasks: sudo_account.yml + loop: "{{ baseline_operators | default(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 + loop_var: sudo_account + label: "{{ sudo_account['name'] }}" From 1c29c37f497c9158a9be7f97310969ba7a4b9a68 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sun, 12 Jul 2026 18:41:07 +0300 Subject: [PATCH 2/2] fix(baseline): migrate leftover ssh_admin_* doc refs; expand ~ explicitly Address PR #30 review: - hosts.yml.example + .ansible-lint still referenced the removed ssh_admin_* variables (missed by an earlier *.yml-scoped grep). Point them at baseline_sudo_users / baseline_sudo_passwordless. - Expand ~ with the expanduser filter before first_found in the runner autodetect, so the lockout-critical key lookup does not depend on first_found's version-varying tilde handling (verified working on the pinned ansible-core; hardened for robustness). Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/.ansible-lint | 2 +- ansible/inventory/hosts.yml.example | 9 +++++---- ansible/roles/baseline/tasks/main.yml | 7 +++++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/ansible/.ansible-lint b/ansible/.ansible-lint index edd15d7..d60205f 100644 --- a/ansible/.ansible-lint +++ b/ansible/.ansible-lint @@ -3,7 +3,7 @@ # skipped project-wide (removing the skip surfaces ~60 violations): # - decdn_node uses a `decdn_` prefix, not the rule-expected `decdn_node_` # (decdn_rpc_url, decdn_bind_port, decdn_user, …) — the bulk of the skip. -# - baseline surfaces a few global knobs in group_vars (ssh_admin_*, ssh_allow_cidrs). +# - baseline surfaces a global knob in group_vars (ssh_allow_cidrs). # Conventional names read better for a public ops repo than role-prefixed ones; # everything else is held to the 'production' profile. skip_list: diff --git a/ansible/inventory/hosts.yml.example b/ansible/inventory/hosts.yml.example index 25e21b9..e526b03 100644 --- a/ansible/inventory/hosts.yml.example +++ b/ansible/inventory/hosts.yml.example @@ -4,17 +4,18 @@ # it local. (Just never push real host IPs to the upstream template.) # # First run: connect as a user that can sudo (often root) to bootstrap the box. -# After the baseline role creates {{ ssh_admin_user }} and ssh_hardening disables -# root login, switch ansible_user to that admin account. +# After the baseline role creates your admin account (the auto-detected runner, or an +# entry in baseline_sudo_users) and ssh_hardening disables root login, switch +# ansible_user to that admin account. # # Ubuntu sudo-rs workaround: 25.10 (Questing) and 26.04 ship sudo-rs as the default # /usr/bin/sudo. Ansible's sudo become plugin passes a custom `-p` prompt sentinel and # waits for it before sending the become password; sudo-rs does not honor that custom # `-p`, so `--ask-become-pass` / -K hangs with "Timeout waiting for privilege # escalation prompt". This only bites when a become PASSWORD is actually sent: with the -# default `ssh_admin_passwordless_sudo: true` the admin user has NOPASSWD sudo, so +# default `baseline_sudo_passwordless: true` the admin user has NOPASSWD sudo, so # Ansible sends no become password and the hang never occurs. It resurfaces only if you -# set `ssh_admin_passwordless_sudo: false` (classic password sudo). Fix: classic sudo is +# set `baseline_sudo_passwordless: false` (classic password sudo). Fix: classic sudo is # still installed at /usr/bin/sudo.ws and honors the -p prompt Ansible expects — point # become at it with the per-host `ansible_become_exe` line below. Only uncomment on an # affected host: `sudo --version` reports "sudo-rs" and/or /usr/bin/sudo.ws exists. Leave diff --git a/ansible/roles/baseline/tasks/main.yml b/ansible/roles/baseline/tasks/main.yml index 59993b6..47b5863 100644 --- a/ansible/roles/baseline/tasks/main.yml +++ b/ansible/roles/baseline/tasks/main.yml @@ -26,11 +26,14 @@ ansible.builtin.set_fact: baseline_runner_pubkey: "{{ lookup('ansible.builtin.file', found) | trim if found | length > 0 else '' }}" vars: - # ~ expands via os.path.expanduser, which falls back to the passwd DB when $HOME is + # Expand ~ EXPLICITLY with the expanduser filter before first_found, rather than + # relying on the lookup to do it (behaviour that varies by ansible-core version). + # expanduser uses os.path.expanduser, which falls back to the passwd DB when $HOME is # unset (cron/CI) — more robust than an env lookup that returns ''. Only probe ~/.ssh # when autodetection is on; otherwise this stays '' and no head is prepended below. found: >- - {{ (query('ansible.builtin.first_found', {'files': candidates, 'skip': true}) + {{ (query('ansible.builtin.first_found', + {'files': candidates | map('expanduser') | list, 'skip': true}) | first | default('')) if baseline_sudo_autodetect_runner | bool else '' }} candidates: