feat(freeipa): add policy enforcement for binary blocking, backups, scans, and sudo

Introduces a FreeIPA host-group-driven policy system alongside a sudo
rules management playbook:

- ansipa-enforce-policies.sh: client-side enforcer (systemd timer, 30 min)
  - policy-block-binary-<name>: PATH-priority wrapper blocks the binary
  - policy-timeshift-backup: daily Timeshift snapshot cron (03:00)
  - policy-security-scan: daily ClamAV/rkhunter/chkrootkit cron (02:00)
  Policies are reversible — leaving a group removes enforcement on next run.

- deploy-ansipa-policies.yml: deploys enforcer + systemd service/timer to clients

- manage-sudo-rules.yml: creates FreeIPA sudo rules (allow_sudoers,
  allow_sudo_nopasswd) that SSSD clients already pick up via --sudo enrollment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-05-20 11:34:09 +02:00
parent da0a9e7a32
commit 45fd7e5d36
3 changed files with 357 additions and 0 deletions

View File

@ -0,0 +1,185 @@
#!/usr/bin/env bash
# ansipa-enforce-policies.sh — enforce FreeIPA host-group-driven policies on this client.
#
# Policies are idempotent and reversible: joining a group applies the policy;
# leaving the group removes it on the next run (every 30 min via systemd timer).
#
# Host-group naming conventions:
# policy-block-binary-<name> Block execution of <name> via a wrapper in /usr/local/bin/
# policy-timeshift-backup Enforce a daily Timeshift snapshot (requires timeshift installed)
# policy-security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans
#
# Notes:
# - Binary blocking uses a PATH-priority wrapper in /usr/local/bin/; callers using
# the full absolute path bypass it. For hard enforcement add AppArmor/SELinux rules.
# - Install scan tools first: add the host to ansipa-module-anti-malware.
# - Configure Timeshift (type + target device) before enabling policy-timeshift-backup.
set -euo pipefail
LOG_TAG="ansipa-policies"
STATE_DIR="/var/lib/ansipa-policies"
BLOCK_DIR="/usr/local/bin"
CRON_DIR="/etc/cron.d"
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; }
warn() { echo "[$LOG_TAG][WARN] $*" >&2; logger -t "$LOG_TAG" "WARN: $*" 2>/dev/null || true; }
HOST_FQDN=$(hostname -f 2>/dev/null || hostname)
if ! command -v ipa &>/dev/null; then
warn "ipa command not found — host not enrolled in FreeIPA. Exiting."
exit 0
fi
kinit -k "host/$HOST_FQDN" &>/dev/null || true
mkdir -p "$STATE_DIR"
# ── Fetch host group membership ───────────────────────────────────────────────
RAW_GROUPS=$(ipa host-show "$HOST_FQDN" --all 2>/dev/null \
| grep -i "Member of host-groups:" | sed 's/.*: //' || true)
# ── Parse active policy groups ────────────────────────────────────────────────
ACTIVE_BLOCK_BINARIES=()
WANT_TIMESHIFT_BACKUP=false
WANT_SECURITY_SCAN=false
if [[ -n "$RAW_GROUPS" ]]; then
while IFS=',' read -ra GRP_ARRAY; do
for g in "${GRP_ARRAY[@]}"; do
g="${g// /}"
case "$g" in
policy-block-binary-*) ACTIVE_BLOCK_BINARIES+=("${g#policy-block-binary-}") ;;
policy-timeshift-backup) WANT_TIMESHIFT_BACKUP=true ;;
policy-security-scan) WANT_SECURITY_SCAN=true ;;
esac
done
done <<< "$RAW_GROUPS"
fi
log "Active policies — block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \
"| timeshift-backup: $WANT_TIMESHIFT_BACKUP | security-scan: $WANT_SECURITY_SCAN"
# ── Helper ────────────────────────────────────────────────────────────────────
in_active_list() {
local needle="$1"
for b in "${ACTIVE_BLOCK_BINARIES[@]}"; do
[[ "$b" == "$needle" ]] && return 0
done
return 1
}
# ── Binary blocking ───────────────────────────────────────────────────────────
# Wrapper scripts are placed in /usr/local/bin/ (higher PATH priority than /usr/bin/).
# The "blocked by ansipa policy" sentinel line lets us identify managed wrappers.
BLOCK_STATE="$STATE_DIR/blocked-binaries"
[[ -f "$BLOCK_STATE" ]] || touch "$BLOCK_STATE"
for BIN in "${ACTIVE_BLOCK_BINARIES[@]}"; do
WRAPPER="$BLOCK_DIR/$BIN"
if [[ ! -f "$WRAPPER" ]] || ! grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null; then
log "Applying block: $BIN"
cat > "$WRAPPER" <<WRAPPER
#!/bin/bash
# blocked by ansipa policy
echo "[$LOG_TAG] '$BIN' is blocked by system policy on this host." >&2
exit 1
WRAPPER
chmod 755 "$WRAPPER"
fi
done
# Remove wrappers for binaries no longer in any active policy group.
while IFS= read -r OLD_BIN; do
[[ -z "$OLD_BIN" ]] && continue
if ! in_active_list "$OLD_BIN"; then
WRAPPER="$BLOCK_DIR/$OLD_BIN"
if [[ -f "$WRAPPER" ]] && grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null; then
rm -f "$WRAPPER"
log "Removed block: $OLD_BIN"
fi
fi
done < "$BLOCK_STATE"
# Persist current blocked list.
if [[ ${#ACTIVE_BLOCK_BINARIES[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_BLOCK_BINARIES[@]}" | sort -u > "$BLOCK_STATE"
else
> "$BLOCK_STATE"
fi
# ── Timeshift daily backup ─────────────────────────────────────────────────────
TIMESHIFT_CRON="$CRON_DIR/ansipa-timeshift-backup"
if [[ "$WANT_TIMESHIFT_BACKUP" == true ]]; then
if [[ ! -f "$TIMESHIFT_CRON" ]]; then
if ! command -v timeshift &>/dev/null; then
warn "timeshift not found — add host to ansipa-module-timeshift first. Cron will be installed anyway."
fi
log "Enabling daily Timeshift backups"
cat > "$TIMESHIFT_CRON" <<'CRON'
# ansipa-policy-timeshift-backup: managed by ansipa-enforce-policies — do not edit manually.
# Timeshift must be configured on this host (type + target device) before snapshots work.
0 3 * * * root /usr/bin/timeshift --create --comments "ansipa-daily" --tags D 2>&1 | logger -t timeshift-backup
CRON
chmod 644 "$TIMESHIFT_CRON"
fi
else
if [[ -f "$TIMESHIFT_CRON" ]]; then
rm -f "$TIMESHIFT_CRON"
log "Removed Timeshift backup cron (host left policy-timeshift-backup group)"
fi
fi
# ── Security scan ─────────────────────────────────────────────────────────────
SCAN_CRON="$CRON_DIR/ansipa-security-scan"
SCAN_SCRIPT="/usr/local/bin/ansipa-security-scan.sh"
if [[ "$WANT_SECURITY_SCAN" == true ]]; then
# (Re-)write the scan script so it stays current with this version of the enforcer.
cat > "$SCAN_SCRIPT" <<'SCAN'
#!/bin/bash
# ansipa-security-scan — daily ClamAV / rkhunter / chkrootkit run.
# Managed by ansipa-enforce-policies — do not edit manually.
LOG=/var/log/ansipa-security-scan.log
{
echo "=== ansipa-security-scan: $(date) ==="
if command -v freshclam &>/dev/null; then
freshclam --quiet 2>/dev/null || true
fi
if command -v clamscan &>/dev/null; then
clamscan -r --infected --quiet /home /etc /tmp /var/tmp 2>/dev/null || true
fi
if command -v rkhunter &>/dev/null; then
rkhunter --update --quiet 2>/dev/null || true
rkhunter --check --skip-keypress --quiet 2>/dev/null || true
fi
if command -v chkrootkit &>/dev/null; then
chkrootkit 2>/dev/null || true
fi
echo "=== scan complete ==="
} >> "$LOG" 2>&1
SCAN
chmod 755 "$SCAN_SCRIPT"
if [[ ! -f "$SCAN_CRON" ]]; then
log "Enabling daily security scans (ClamAV / rkhunter / chkrootkit)"
cat > "$SCAN_CRON" <<'CRON'
# ansipa-policy-security-scan: managed by ansipa-enforce-policies — do not edit manually.
# Install scan tools by adding the host to the ansipa-module-anti-malware group.
0 2 * * * root /usr/local/bin/ansipa-security-scan.sh
CRON
chmod 644 "$SCAN_CRON"
fi
else
if [[ -f "$SCAN_CRON" ]]; then
rm -f "$SCAN_CRON"
rm -f "$SCAN_SCRIPT"
log "Removed security scan policy (host left policy-security-scan group)"
fi
fi
log "Policy enforcement complete."

View File

@ -0,0 +1,76 @@
---
# deploy-ansipa-policies.yml — deploy the policy enforcement daemon to enrolled clients.
#
# Installs ansipa-enforce-policies.sh and a systemd timer that runs it every 30 minutes.
# Policies are declared by adding hosts to the following FreeIPA host groups:
#
# policy-block-binary-<name> Block execution of <name> via a PATH-priority wrapper
# policy-timeshift-backup Enforce daily Timeshift snapshots (03:00)
# policy-security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans (02:00)
#
# Prerequisites:
# - Host enrolled in FreeIPA (sssd + ipa CLI available)
# - For security-scan: also add host to ansipa-module-anti-malware group
# - For timeshift-backup: also add host to ansipa-module-timeshift group and
# configure Timeshift (type + target device) on the host
#
# Usage:
# ansible-playbook -i inventory deploy-ansipa-policies.yml
- name: Deploy FreeIPA policy enforcer
hosts: all
become: yes
tasks:
- name: Deploy policy enforcer script
copy:
src: ansipa-enforce-policies.sh
dest: /usr/local/bin/ansipa-enforce-policies.sh
mode: '0755'
- name: Create policy state directory
file:
path: /var/lib/ansipa-policies
state: directory
mode: '0700'
- name: Install policy enforcer systemd service
copy:
dest: /etc/systemd/system/ansipa-enforce-policies.service
mode: '0644'
content: |
[Unit]
Description=Enforce FreeIPA host-group policies (binary blocks, backups, scans)
After=network-online.target sssd.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ansipa-enforce-policies.sh
StandardOutput=journal
StandardError=journal
- name: Install policy enforcer systemd timer
copy:
dest: /etc/systemd/system/ansipa-enforce-policies.timer
mode: '0644'
content: |
[Unit]
Description=Periodic FreeIPA policy enforcement
[Timer]
OnBootSec=5min
OnUnitActiveSec=30min
[Install]
WantedBy=timers.target
- name: Reload systemd
command: systemctl daemon-reload
- name: Enable and start policy enforcer timer
systemd:
name: ansipa-enforce-policies.timer
enabled: yes
state: started

View File

@ -0,0 +1,96 @@
---
# manage-sudo-rules.yml — create and maintain FreeIPA sudo rules.
#
# This playbook provisions the sudo rules that enrolled clients pick up via SSSD
# (configured by the --sudo flag in freeipa-enroll.sh). Run it once when setting
# up the domain, and again whenever you add or change a rule.
#
# Default rules created:
# allow_sudoers Members of the 'sudoers' IPA group get full sudo (password required)
# allow_sudo_nopasswd Members of 'sudo-nopasswd' get full sudo (NOPASSWD)
#
# To grant a user sudo access:
# ipa group-add-member sudoers --users=<username>
# To grant passwordless sudo:
# ipa group-add-member sudo-nopasswd --users=<username>
#
# Prerequisites:
# - Active admin Kerberos ticket on the target host: kinit admin
# - ipa CLI available (run on the IPA server or any enrolled admin workstation)
#
# Usage:
# kinit admin
# ansible-playbook -i ipa-server.example.com, manage-sudo-rules.yml
# # or, if 'ipa_server' is defined in your inventory:
# ansible-playbook -i inventory manage-sudo-rules.yml
- name: Manage FreeIPA sudo rules
hosts: "{{ ipa_admin_host | default('ipa_server') }}"
become: no
vars:
sudo_rules:
- rule_name: allow_sudoers
group: sudoers
description: "Full sudo access for members of the sudoers group (password required)"
nopasswd: false
- rule_name: allow_sudo_nopasswd
group: sudo-nopasswd
description: "Full sudo access for members of sudo-nopasswd group (no password)"
nopasswd: true
tasks:
- name: Verify ipa command is available and authenticated
command: ipa ping
changed_when: false
register: ipa_ping
failed_when: ipa_ping.rc != 0
- name: Ensure IPA user groups exist for each sudo rule
shell: >
ipa group-show "{{ item.group }}" >/dev/null 2>&1 ||
ipa group-add "{{ item.group }}"
--desc="{{ item.description }}"
register: group_result
changed_when: "'Added group' in group_result.stdout"
loop: "{{ sudo_rules }}"
- name: Ensure sudo rules exist
shell: >
ipa sudorule-show "{{ item.rule_name }}" >/dev/null 2>&1 ||
ipa sudorule-add "{{ item.rule_name }}"
--desc="{{ item.description }}"
--cmdcat=all
--hostcat=all
register: rule_result
changed_when: "'Added Sudo Rule' in rule_result.stdout"
loop: "{{ sudo_rules }}"
- name: Assign groups to their sudo rules
shell: >
ipa sudorule-show "{{ item.rule_name }}" --all 2>/dev/null |
grep -q "{{ item.group }}" ||
ipa sudorule-add-user "{{ item.rule_name }}" --groups="{{ item.group }}"
register: assign_result
changed_when: "'Number of members added' in assign_result.stdout"
loop: "{{ sudo_rules }}"
- name: Set NOPASSWD (sudooption !authenticate) on passwordless rules
shell: >
ipa sudorule-show "{{ item.rule_name }}" --all 2>/dev/null |
grep -q "!authenticate" ||
ipa sudorule-add-option "{{ item.rule_name }}" --sudooption "!authenticate"
register: nopasswd_result
changed_when: "'Added option' in nopasswd_result.stdout"
loop: "{{ sudo_rules | selectattr('nopasswd', 'equalto', true) | list }}"
- name: Show configured sudo rules
command: ipa sudorule-find --all
changed_when: false
register: sudo_summary
- name: Display sudo rules summary
debug:
msg: "{{ sudo_summary.stdout_lines }}"