feat(ansipa): add no_local_users device policy to lock all local account passwords

Adds a new host group policy `no_local_users` that locks the passwords of root
and all local users (UID >= 1000) via `passwd -l`, ensuring only FreeIPA domain
accounts with centrally-managed sudo rules can authenticate and gain elevated
privileges. Leaving the group reverts by unlocking every account tracked in the
state file. Updates docs with group reference entry and Local User Lockdown section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-05-20 16:18:48 +02:00
parent 3ef916290c
commit 6ad8d0d488
2 changed files with 78 additions and 1 deletions

View File

@ -277,6 +277,7 @@ Keys on the SMB share are accessible only to `KeyAdmin` group members (see [SMB
| `policy-timeshift-backup` | `ansipa-enforce-policies.sh` | Daily Timeshift snapshot at 03:00 | | `policy-timeshift-backup` | `ansipa-enforce-policies.sh` | Daily Timeshift snapshot at 03:00 |
| `policy-security-scan` | `ansipa-enforce-policies.sh` | Daily ClamAV + rkhunter + chkrootkit scan + SMB upload | | `policy-security-scan` | `ansipa-enforce-policies.sh` | Daily ClamAV + rkhunter + chkrootkit scan + SMB upload |
| `policy-scan-notify` | `ansipa-enforce-policies.sh` | Fetch server alerts, notify user every 10 min until acknowledged | | `policy-scan-notify` | `ansipa-enforce-policies.sh` | Fetch server alerts, notify user every 10 min until acknowledged |
| `no_local_users` | `ansipa-enforce-policies.sh` | Lock passwords for root and all local users (UID ≥ 1000); reverted on leave |
--- ---
@ -329,6 +330,19 @@ Client (policy-scan-notify, every 10 min via systemd timer)
- `samba-client` is installed automatically by `deploy-ansipa-policies.yml`. - `samba-client` is installed automatically by `deploy-ansipa-policies.yml`.
- SMB credentials are written to `/etc/ansipa-smb.creds` (root-only, `0600`). - SMB credentials are written to `/etc/ansipa-smb.creds` (root-only, `0600`).
### Local User Lockdown
Adding a host to the `no_local_users` group locks the password of every local account — root (UID 0) and all regular users (UID ≥ 1000) — using `passwd -l`. Accounts whose passwords are already locked (`!` or `*` prefix in shadow) are left untouched and are not tracked.
State is persisted in `/var/lib/ansipa-policies/no-local-users` (one username per line). Only accounts that were actively unlocked at apply time are written to this file, so the revert step only unlocks what was changed.
| Action | Effect |
|--------|--------|
| Join `no_local_users` | `passwd -l` on root + all UID ≥ 1000 local accounts |
| Leave `no_local_users` | `passwd -u` on every account listed in the state file |
**Interaction with FreeIPA sudo:** Domain accounts in the `sudoers` or `sudo-nopasswd` FreeIPA groups retain full sudo access via SSSD — local password lockdown does not affect them. Ensure at least one domain admin has sudo before adding a host to this group.
--- ---
## LUKS Key Flow ## LUKS Key Flow

View File

@ -17,6 +17,11 @@
# If a unit appears in both enable and disable groups it is skipped. # If a unit appears in both enable and disable groups it is skipped.
# policy-timeshift-backup Enforce a daily Timeshift snapshot (requires timeshift installed) # policy-timeshift-backup Enforce a daily Timeshift snapshot (requires timeshift installed)
# policy-security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans # policy-security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans
# no_local_users Lock passwords for root and all local users (UID >= 1000) so
# that only FreeIPA domain accounts with centrally-managed sudo
# rules can authenticate and gain elevated privileges.
# Leaving the group reverts: every account locked by this policy
# is unlocked on the next run.
# #
# Notes: # Notes:
# - Install scan tools first: add the host to ansipa-module-anti-malware. # - Install scan tools first: add the host to ansipa-module-anti-malware.
@ -54,6 +59,7 @@ ACTIVE_DAEMON_DISABLE=()
WANT_TIMESHIFT_BACKUP=false WANT_TIMESHIFT_BACKUP=false
WANT_SECURITY_SCAN=false WANT_SECURITY_SCAN=false
WANT_SCAN_NOTIFY=false WANT_SCAN_NOTIFY=false
WANT_NO_LOCAL_USERS=false
if [[ -n "$RAW_GROUPS" ]]; then if [[ -n "$RAW_GROUPS" ]]; then
while IFS=',' read -ra GRP_ARRAY; do while IFS=',' read -ra GRP_ARRAY; do
@ -66,6 +72,7 @@ if [[ -n "$RAW_GROUPS" ]]; then
policy-timeshift-backup) WANT_TIMESHIFT_BACKUP=true ;; policy-timeshift-backup) WANT_TIMESHIFT_BACKUP=true ;;
policy-security-scan) WANT_SECURITY_SCAN=true ;; policy-security-scan) WANT_SECURITY_SCAN=true ;;
policy-scan-notify) WANT_SCAN_NOTIFY=true ;; policy-scan-notify) WANT_SCAN_NOTIFY=true ;;
no_local_users) WANT_NO_LOCAL_USERS=true ;;
esac esac
done done
done <<< "$RAW_GROUPS" done <<< "$RAW_GROUPS"
@ -74,7 +81,8 @@ fi
log "Active policies — block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \ log "Active policies — block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \
"| daemon-enable: ${ACTIVE_DAEMON_ENABLE[*]:-none} | daemon-disable: ${ACTIVE_DAEMON_DISABLE[*]:-none}" \ "| daemon-enable: ${ACTIVE_DAEMON_ENABLE[*]:-none} | daemon-disable: ${ACTIVE_DAEMON_DISABLE[*]:-none}" \
"| timeshift-backup: $WANT_TIMESHIFT_BACKUP" \ "| timeshift-backup: $WANT_TIMESHIFT_BACKUP" \
"| security-scan: $WANT_SECURITY_SCAN | scan-notify: $WANT_SCAN_NOTIFY" "| security-scan: $WANT_SECURITY_SCAN | scan-notify: $WANT_SCAN_NOTIFY" \
"| no-local-users: $WANT_NO_LOCAL_USERS"
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
in_active_list() { in_active_list() {
@ -444,4 +452,59 @@ else
> "$DAEMON_DISABLE_STATE" > "$DAEMON_DISABLE_STATE"
fi fi
# ── No-local-users policy ──────────────────────────────────────────────────────
# no_local_users: lock the passwords of root and all local users (UID >= 1000)
# so that only FreeIPA domain accounts with centrally-managed sudo rules can
# authenticate and gain elevated privileges.
# Leaving the group reverts: every account locked by this policy is unlocked.
NO_LOCAL_USERS_STATE="$STATE_DIR/no-local-users"
_apply_no_local_users() {
log "Applying no_local_users policy — locking local account passwords"
[[ -f "$NO_LOCAL_USERS_STATE" ]] || touch "$NO_LOCAL_USERS_STATE"
while IFS=: read -r uname _ uid _; do
[[ "$uid" =~ ^[0-9]+$ ]] || continue
{ [[ "$uid" == "0" ]] || [[ "$uid" -ge 1000 ]]; } || continue
# Skip accounts already tracked (locked on a previous run)
grep -qxF "$uname" "$NO_LOCAL_USERS_STATE" 2>/dev/null && continue
# Lock only accounts that currently have a real (unlocked) password hash
local hash
hash=$(getent shadow "$uname" 2>/dev/null | cut -d: -f2 || true)
[[ -z "$hash" || "$hash" == '!'* || "$hash" == '*'* ]] && continue
if passwd -l "$uname" &>/dev/null; then
echo "$uname" >> "$NO_LOCAL_USERS_STATE"
log "Locked local account: $uname"
else
warn "Failed to lock local account: $uname"
fi
done < /etc/passwd
}
_revert_no_local_users() {
[[ -f "$NO_LOCAL_USERS_STATE" ]] || return 0
log "Reverting no_local_users policy — unlocking previously locked accounts"
while IFS= read -r uname; do
[[ -z "$uname" ]] && continue
if passwd -u "$uname" &>/dev/null; then
log "Unlocked local account: $uname"
else
warn "Failed to unlock local account: $uname (may have been removed)"
fi
done < "$NO_LOCAL_USERS_STATE"
> "$NO_LOCAL_USERS_STATE"
}
if [[ "$WANT_NO_LOCAL_USERS" == true ]]; then
_apply_no_local_users
else
if [[ -f "$NO_LOCAL_USERS_STATE" ]] && [[ -s "$NO_LOCAL_USERS_STATE" ]]; then
_revert_no_local_users
fi
fi
log "Policy enforcement complete." log "Policy enforcement complete."