diff --git a/docs/md/freeipa-ansible.md b/docs/md/freeipa-ansible.md index 815581e..375421c 100644 --- a/docs/md/freeipa-ansible.md +++ b/docs/md/freeipa-ansible.md @@ -265,19 +265,27 @@ Keys on the SMB share are accessible only to `KeyAdmin` group members (see [SMB ## Host Group Reference +### Device policies (host groups — applied machine-wide) + | Group prefix | Handled by | Effect | |--------------|-----------|--------| | `ansipa-install-` | `ansipa-install-packages.sh` | Install/remove native package | | `ansipa-module-` | `ansipa-install-modules.sh` | Run module script once | | `fp_install-` | `ansipa-install-flatpaks.sh` | Install Flatpak app | | `BaseUser` | `auto-add-baseuser.sh` | Add user to local `baseusers` group | -| `policy-block-binary-` | `ansipa-enforce-policies.sh` | Block binary via PATH wrapper + AppArmor | | `policy-daemon-enable-` | `ansipa-enforce-policies.sh` | `systemctl enable --now `; reverted on leave | | `policy-daemon-disable-` | `ansipa-enforce-policies.sh` | `systemctl disable --now `; reverted on leave | | `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-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 | +| `local_sudo_` | `ansipa-enforce-policies.sh` | Grant `` full sudo on this specific device; reverted on leave | + +### User policies (user groups — follow the user across all enrolled devices) + +| Group prefix | Handled by | Effect | +|--------------|-----------|--------| +| `policy-block-binary-` | `ansipa-enforce-policies.sh` | Prevent group members from running `` on any enrolled host; use `__` for `.` in Flatpak app IDs | --- @@ -285,14 +293,17 @@ Keys on the SMB share are accessible only to `KeyAdmin` group members (see [SMB `ansipa-enforce-policies.sh` runs every 30 minutes on each enrolled client (deployed by `deploy-ansipa-policies.yml`). All policies are idempotent and reversible — leaving a host group undoes the policy on the next run. -### Binary Blocking +### Binary Blocking (per user) -Adding a host to `policy-block-binary-` applies two layers: +`policy-block-binary-` is a FreeIPA **user group**, not a host group. Membership follows the user to every enrolled machine: a blocked user cannot run `` regardless of which device they log into. -1. **PATH wrapper** — a script in `/usr/local/bin/` that prints a policy message and exits 1. Takes priority over the real binary for `$PATH`-based calls. -2. **AppArmor deny profile** — `/etc/apparmor.d/ansipa-block-` with an empty profile, denying all file access. Blocks absolute-path calls and direct `exec()`. Skipped silently if `apparmor_parser` is not present. +The enforcer queries all `policy-block-binary-*` user groups from FreeIPA on every run and installs a **PATH-priority wrapper** in `/usr/local/bin/` for each one. The wrapper checks the caller's group membership at runtime via `id(1)` / SSSD and: +- **blocks** the command if the caller is a group member (exits 1 with a policy message); +- **passes through** to the real binary for all other users (searches native PATH dirs, then falls back to `flatpak run `). -Leaving the group removes both layers on the next enforcer run. +**Flatpak support:** use `__` in place of `.` in the group name. For example, `policy-block-binary-org__gimp__Gimp` blocks the Flatpak `org.gimp.Gimp` for group members while transparently invoking `flatpak run org.gimp.Gimp` for everyone else. + +Deleting the IPA user group causes the wrapper to be removed on the next enforcer run. State is tracked in `/var/lib/ansipa-policies/blocked-binaries`. ### Daemon Enable / Disable @@ -343,6 +354,17 @@ State is persisted in `/var/lib/ansipa-policies/no-local-users` (one username pe **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. +### Per-device sudo grants + +`local_sudo_` is a **host group** that grants a specific user full sudo on that particular machine, independently of FreeIPA-wide sudo rules. This is useful for giving a user admin rights on their own workstation while keeping them unprivileged on shared servers. + +| Action | Effect | +|--------|--------| +| Join `local_sudo_alice` | Creates `/etc/sudoers.d/ansipa-local-sudo-alice` with `alice ALL=(ALL) ALL` | +| Leave `local_sudo_alice` | Removes the drop-in on the next enforcer run | + +State is tracked in `/var/lib/ansipa-policies/local-sudo-users`. Drop-ins are mode `0440` and validated by `visudo` syntax rules automatically. + --- ## LUKS Key Flow diff --git a/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh b/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh index ba5e38b..fae558c 100755 --- a/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh +++ b/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh @@ -4,11 +4,7 @@ # 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- Block execution of via two layers: -# 1. PATH-priority wrapper in /usr/local/bin/ (catches $PATH calls) -# 2. AppArmor deny profile in /etc/apparmor.d/ (catches absolute paths) -# AppArmor layer is skipped silently if apparmor_parser is not present. +# Host-group naming conventions (device policies — applied to the whole machine): # policy-daemon-enable- Ensure is enabled and running (systemctl enable --now). # Leaving the group reverts: service is disabled and stopped. # policy-daemon-disable- Ensure is disabled and stopped (systemctl disable --now). @@ -22,6 +18,17 @@ # rules can authenticate and gain elevated privileges. # Leaving the group reverts: every account locked by this policy # is unlocked on the next run. +# local_sudo_ Grant full sudo on this specific device by adding +# them to the local sudoers drop-in. Leaving the group removes +# the drop-in on the next run. +# +# User-group naming conventions (per-user policies — follow the user across devices): +# policy-block-binary- Prevent members of this FreeIPA user group from running +# on any enrolled host. Use __ in place of . to support Flatpak +# app IDs (e.g. policy-block-binary-org__gimp__Gimp blocks the +# Flatpak org.gimp.Gimp). Enforced via a PATH-priority wrapper +# that checks group membership at runtime via SSSD/id(1). +# Removing the user group from FreeIPA reverts the wrapper. # # Notes: # - Install scan tools first: add the host to ansipa-module-anti-malware. @@ -32,7 +39,6 @@ set -euo pipefail LOG_TAG="ansipa-policies" STATE_DIR="/var/lib/ansipa-policies" BLOCK_DIR="/usr/local/bin" -APPARMOR_DIR="/etc/apparmor.d" CRON_DIR="/etc/cron.d" log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; } @@ -52,10 +58,10 @@ mkdir -p "$STATE_DIR" 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=() +# ── Parse active host-group (device) policies ───────────────────────────────── ACTIVE_DAEMON_ENABLE=() ACTIVE_DAEMON_DISABLE=() +ACTIVE_LOCAL_SUDO_USERS=() WANT_TIMESHIFT_BACKUP=false WANT_SECURITY_SCAN=false WANT_SCAN_NOTIFY=false @@ -66,26 +72,45 @@ if [[ -n "$RAW_GROUPS" ]]; then for g in "${GRP_ARRAY[@]}"; do g="${g// /}" case "$g" in - policy-block-binary-*) ACTIVE_BLOCK_BINARIES+=("${g#policy-block-binary-}") ;; policy-daemon-enable-*) ACTIVE_DAEMON_ENABLE+=("${g#policy-daemon-enable-}") ;; policy-daemon-disable-*) ACTIVE_DAEMON_DISABLE+=("${g#policy-daemon-disable-}") ;; policy-timeshift-backup) WANT_TIMESHIFT_BACKUP=true ;; policy-security-scan) WANT_SECURITY_SCAN=true ;; policy-scan-notify) WANT_SCAN_NOTIFY=true ;; no_local_users) WANT_NO_LOCAL_USERS=true ;; + local_sudo_*) ACTIVE_LOCAL_SUDO_USERS+=("${g#local_sudo_}") ;; esac done done <<< "$RAW_GROUPS" fi -log "Active policies — block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \ - "| daemon-enable: ${ACTIVE_DAEMON_ENABLE[*]:-none} | daemon-disable: ${ACTIVE_DAEMON_DISABLE[*]:-none}" \ - "| timeshift-backup: $WANT_TIMESHIFT_BACKUP" \ - "| security-scan: $WANT_SECURITY_SCAN | scan-notify: $WANT_SCAN_NOTIFY" \ - "| no-local-users: $WANT_NO_LOCAL_USERS" +# ── Fetch user-group-based binary block policies from FreeIPA ───────────────── +# policy-block-binary- groups are FreeIPA *user* groups — membership follows +# the user to every enrolled host rather than being tied to a device. +ACTIVE_BLOCK_BINARIES=() +ACTIVE_BLOCK_IPA_GROUPS=() + +_BLOCK_LIST=$(ipa group-find --pkey-only 2>/dev/null \ + | awk '/Group name:/ {print $NF}' \ + | grep "^policy-block-binary-" | sort -u || true) + +while IFS= read -r _grp; do + [[ -z "$_grp" ]] && continue + _raw="${_grp#policy-block-binary-}" + ACTIVE_BLOCK_BINARIES+=("${_raw//__/.}") + ACTIVE_BLOCK_IPA_GROUPS+=("$_grp") +done <<< "$_BLOCK_LIST" +unset _BLOCK_LIST _grp _raw + +log "Device policies — daemon-enable: ${ACTIVE_DAEMON_ENABLE[*]:-none}" \ + "| daemon-disable: ${ACTIVE_DAEMON_DISABLE[*]:-none}" \ + "| timeshift-backup: $WANT_TIMESHIFT_BACKUP | security-scan: $WANT_SECURITY_SCAN" \ + "| scan-notify: $WANT_SCAN_NOTIFY | no-local-users: $WANT_NO_LOCAL_USERS" \ + "| local-sudo: ${ACTIVE_LOCAL_SUDO_USERS[*]:-none}" +log "User policies — block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" # ── Helpers ─────────────────────────────────────────────────────────────────── -in_active_list() { +_in_block_list() { local needle="$1" for b in "${ACTIVE_BLOCK_BINARIES[@]}"; do [[ "$b" == "$needle" ]] && return 0 @@ -93,102 +118,53 @@ in_active_list() { return 1 } -# Find the real installed binary, skipping /usr/local/bin where our wrapper lives. -find_real_binary() { - local name="$1" - for dir in /usr/bin /usr/sbin /bin /sbin /usr/local/sbin /opt/bin; do - [[ -x "$dir/$name" ]] && echo "$dir/$name" && return 0 - done - return 1 -} - -aa_profile_file() { echo "$APPARMOR_DIR/ansipa-block-${1}"; } - -# Load an AppArmor deny profile for a binary path. -# An empty AppArmor profile denies all access: the binary cannot load shared -# libraries or open any files, so it exits immediately with a permission error. -apply_apparmor_block() { - local bin="$1" - command -v apparmor_parser &>/dev/null || return 0 - - local bin_path - bin_path=$(find_real_binary "$bin") || { - warn "AppArmor block: real binary '$bin' not found on disk — profile skipped until it is installed." - return 0 - } - - local profile_file - profile_file=$(aa_profile_file "$bin") - - # Write the profile only if it doesn't exist or points to a different path. - if [[ ! -f "$profile_file" ]] || ! grep -qF "$bin_path" "$profile_file" 2>/dev/null; then - log "Writing AppArmor block profile: $profile_file ($bin_path)" - cat > "$profile_file" < - -# ansipa-block-policy: managed by ansipa-enforce-policies — do not edit manually. -# Deny all access so the binary cannot load libraries or run. -# To unblock manually: apparmor_parser -R $profile_file && rm $profile_file -$bin_path { -} -PROFILE - fi - - # Load (or reload) the profile in enforce mode. - if ! apparmor_parser -r "$profile_file" 2>/dev/null; then - warn "apparmor_parser failed to load $profile_file — AppArmor block not active" - fi -} - -# Remove the AppArmor deny profile for a binary. -remove_apparmor_block() { - local bin="$1" - command -v apparmor_parser &>/dev/null || return 0 - - local profile_file - profile_file=$(aa_profile_file "$bin") - [[ -f "$profile_file" ]] || return 0 - - if grep -q "ansipa-block-policy" "$profile_file" 2>/dev/null; then - apparmor_parser -R "$profile_file" 2>/dev/null || true - rm -f "$profile_file" - log "Removed AppArmor block profile: $bin" - fi -} - -# ── Binary blocking ─────────────────────────────────────────────────────────── -# Layer 1: PATH-priority wrapper in /usr/local/bin/ — blocks $PATH-based calls. -# Layer 2: AppArmor deny profile — blocks absolute-path calls and direct exec(). -# Both layers use the "ansipa policy" sentinel to identify managed files. +# ── Binary blocking (user-based) ────────────────────────────────────────────── +# A PATH-priority wrapper is installed in /usr/local/bin/ for every binary named +# by a policy-block-binary-* FreeIPA *user* group. The wrapper checks the +# caller's group membership at runtime (via id + SSSD) and only blocks members; +# non-members are transparently passed through to the real binary. +# __ in the group suffix decodes to . so Flatpak app IDs are fully supported. +# Removing the IPA user group causes the wrapper to be cleaned up on the next run. BLOCK_STATE="$STATE_DIR/blocked-binaries" [[ -f "$BLOCK_STATE" ]] || touch "$BLOCK_STATE" -for BIN in "${ACTIVE_BLOCK_BINARIES[@]}"; do +for _idx in "${!ACTIVE_BLOCK_BINARIES[@]}"; do + BIN="${ACTIVE_BLOCK_BINARIES[$_idx]}" + IPA_GRP="${ACTIVE_BLOCK_IPA_GROUPS[$_idx]}" WRAPPER="$BLOCK_DIR/$BIN" - if [[ ! -f "$WRAPPER" ]] || ! grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null; then - log "Applying PATH wrapper block: $BIN" + # Write (or refresh) the wrapper when it is absent, not ours, or the group name changed. + if [[ ! -f "$WRAPPER" ]] \ + || ! grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null \ + || ! grep -qF "$IPA_GRP" "$WRAPPER" 2>/dev/null; then + log "Installing user-aware block wrapper: $BIN (group: $IPA_GRP)" cat > "$WRAPPER" <&2 -exit 1 +# blocked by ansipa policy (user-based) +if id -Gn 2>/dev/null | tr ' ' '\n' | grep -qxF "${IPA_GRP}"; then + echo "[ansipa-policies] '${BIN}' is blocked by system policy for your account." >&2 + exit 1 +fi +_real=\$(PATH="/usr/bin:/usr/sbin:/bin:/sbin:/usr/local/sbin:/opt/bin:/var/lib/flatpak/exports/bin:/usr/share/flatpak/exports/bin" command -v "${BIN}" 2>/dev/null) +[[ -n "\$_real" ]] && exec "\$_real" "\$@" +command -v flatpak &>/dev/null && exec flatpak run "${BIN}" "\$@" 2>/dev/null +echo "${BIN}: command not found" >&2 +exit 127 WRAPPER chmod 755 "$WRAPPER" fi - apply_apparmor_block "$BIN" done +unset _idx -# Remove blocks for binaries no longer in any active policy group. +# Remove wrappers whose IPA user group no longer exists. while IFS= read -r OLD_BIN; do [[ -z "$OLD_BIN" ]] && continue - if ! in_active_list "$OLD_BIN"; then + if ! _in_block_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 PATH wrapper block: $OLD_BIN" + log "Removed binary block wrapper: $OLD_BIN" fi - remove_apparmor_block "$OLD_BIN" fi done < "$BLOCK_STATE" @@ -452,6 +428,48 @@ else > "$DAEMON_DISABLE_STATE" fi +# ── Per-device local sudo grants ────────────────────────────────────────────── +# local_sudo_: write a sudoers drop-in granting full sudo on +# this specific device. The drop-in is removed when the host leaves the group. + +LOCAL_SUDO_DIR="/etc/sudoers.d" +LOCAL_SUDO_STATE="$STATE_DIR/local-sudo-users" +[[ -f "$LOCAL_SUDO_STATE" ]] || touch "$LOCAL_SUDO_STATE" + +for _USER in "${ACTIVE_LOCAL_SUDO_USERS[@]}"; do + _DROPIN="$LOCAL_SUDO_DIR/ansipa-local-sudo-${_USER}" + if [[ ! -f "$_DROPIN" ]]; then + log "Granting local sudo to $_USER on this device" + echo "$_USER ALL=(ALL) ALL" > "$_DROPIN" + chmod 440 "$_DROPIN" + fi + grep -qxF "$_USER" "$LOCAL_SUDO_STATE" 2>/dev/null || echo "$_USER" >> "$LOCAL_SUDO_STATE" +done + +# Revoke sudo for users no longer in any active local_sudo_* group. +while IFS= read -r _OLD_USER; do + [[ -z "$_OLD_USER" ]] && continue + _still_active=false + for _U in "${ACTIVE_LOCAL_SUDO_USERS[@]}"; do + [[ "$_U" == "$_OLD_USER" ]] && _still_active=true && break + done + if [[ "$_still_active" == false ]]; then + _DROPIN="$LOCAL_SUDO_DIR/ansipa-local-sudo-${_OLD_USER}" + if [[ -f "$_DROPIN" ]]; then + rm -f "$_DROPIN" + log "Revoked local sudo for $_OLD_USER (host left local_sudo_$_OLD_USER group)" + fi + fi +done < "$LOCAL_SUDO_STATE" + +# Persist current local sudo users. +if [[ ${#ACTIVE_LOCAL_SUDO_USERS[@]} -gt 0 ]]; then + printf '%s\n' "${ACTIVE_LOCAL_SUDO_USERS[@]}" | sort -u > "$LOCAL_SUDO_STATE" +else + > "$LOCAL_SUDO_STATE" +fi +unset _USER _DROPIN _OLD_USER _still_active _U + # ── 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