Last active
September 7, 2025 18:42
-
-
Save robertsinfosec/cde20dd093955f0b38ea07baff3a7f16 to your computer and use it in GitHub Desktop.
When you want to carefully and safely move the `UID`/`GID` of a user in Linux. This needs to be run as a `root`, directly. You can't log in as `sysadmin`, then do `sudo -s` and run this against `sysadmin`, it will give an error.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # move-user-uid.sh — Safely move a user to a new UID/GID and fix file ownerships. | |
| # Usage: ./move-user-uid.sh <username> <new-uid> <new-gid> [--force] | |
| # Example: ./move-user-uid.sh operations 2000 2000 --force | |
| set -euo pipefail | |
| # ── Colors ───────────────────────────────────────────────────────────────────── | |
| Black='\033[0;30m' | |
| DarkGray='\033[1;30m' | |
| Red='\033[0;31m' | |
| LightRed='\033[1;31m' | |
| Green='\033[0;32m' | |
| LightGreen='\033[1;32m' | |
| Brown='\033[0;33m' | |
| Yellow='\033[1;33m' | |
| Blue='\033[0;34m' | |
| LightBlue='\033[1;34m' | |
| Purple='\033[0;35m' | |
| LightPurple='\033[1;35m' | |
| Cyan='\033[0;36m' | |
| LightCyan='\033[1;36m' | |
| LightGray='\033[0;37m' | |
| White='\033[1;37m' | |
| NC='\033[0m' # No Color | |
| # ── Status printer ───────────────────────────────────────────────────────────── | |
| function setStatus(){ | |
| description=$1 | |
| severity=${2:-"*"} | |
| case "$severity" in | |
| s) echo -e "[${LightGreen}+${NC}] ${LightGreen}${description}${NC}" ;; | |
| f) echo -e "[${Red}-${NC}] ${LightRed}${description}${NC}" ;; | |
| q) echo -e "[${LightPurple}?${NC}] ${LightPurple}${description}${NC}" ;; | |
| *) echo -e "[${LightCyan}*${NC}] ${LightCyan}${description}${NC}" ;; | |
| esac | |
| } | |
| # ── Help / Args ──────────────────────────────────────────────────────────────── | |
| if [[ $# -lt 3 ]] || [[ "${1:-}" == "--help" ]] || [[ "${1:-}" == "-h" ]]; then | |
| cat <<'EOF' | |
| Usage: move-user-uid.sh <username> <new-uid> <new-gid> [--force] | |
| Safely change an existing user's UID/GID and recursively fix file ownership | |
| across local filesystems. Refuses to proceed if the user has an active session, | |
| unless --force is specified. | |
| Examples: | |
| sudo ./move-user-uid.sh operations 2000 2000 | |
| sudo ./move-user-uid.sh operations 2000 2000 --force | |
| EOF | |
| exit 0 | |
| fi | |
| USER_NAME="$1" | |
| NEW_UID="$2" | |
| NEW_GID="$3" | |
| FORCE=0; [[ "${4:-}" == "--force" ]] && FORCE=1 | |
| # ── Pre-flight validation ───────────────────────────────────────────────────── | |
| if [[ $EUID -ne 0 ]]; then | |
| setStatus "Run as root." f; exit 1 | |
| fi | |
| if ! id "$USER_NAME" >/dev/null 2>&1; then | |
| setStatus "User '$USER_NAME' not found." f; exit 1 | |
| fi | |
| # numeric + sensible ranges (allow >= 1000 by default) | |
| if ! [[ "$NEW_UID" =~ ^[0-9]+$ ]] || ! [[ "$NEW_GID" =~ ^[0-9]+$ ]]; then | |
| setStatus "UID/GID must be numeric. Got UID='$NEW_UID' GID='$NEW_GID'." f; exit 1 | |
| fi | |
| if [[ "$NEW_UID" -eq 0 || "$NEW_GID" -eq 0 ]]; then | |
| setStatus "Refusing to set UID/GID 0." f; exit 1 | |
| fi | |
| if [[ "$NEW_UID" -lt 1000 || "$NEW_GID" -lt 1000 ]]; then | |
| setStatus "Warning: UID/GID < 1000 is typically reserved. Continue only if intentional." q | |
| fi | |
| OLD_UID="$(id -u "$USER_NAME")" | |
| OLD_GID="$(id -g "$USER_NAME")" | |
| PRIMARY_GROUP="$(id -gn "$USER_NAME")" | |
| setStatus "Current state: user='$USER_NAME' uid=$OLD_UID gid=$OLD_GID (group '$PRIMARY_GROUP')." s | |
| setStatus "Target state: user='$USER_NAME' uid=$NEW_UID gid=$NEW_GID." s | |
| if [[ "$OLD_UID" -eq "$NEW_UID" && "$OLD_GID" -eq "$NEW_GID" ]]; then | |
| setStatus "No change needed; already at requested UID/GID." s | |
| exit 0 | |
| fi | |
| # collision checks | |
| if getent passwd "$NEW_UID" >/dev/null && [[ "$OLD_UID" -ne "$NEW_UID" ]]; then | |
| owner="$(getent passwd "$NEW_UID" | cut -d: -f1)" | |
| setStatus "Refusing: UID $NEW_UID already used by user '$owner'." f; exit 1 | |
| fi | |
| if getent group "$NEW_GID" >/dev/null && [[ "$OLD_GID" -ne "$NEW_GID" && "$PRIMARY_GROUP" != "$(getent group "$NEW_GID" | cut -d: -f1)" ]]; then | |
| gname="$(getent group "$NEW_GID" | cut -d: -f1)" | |
| setStatus "Refusing: GID $NEW_GID already used by group '$gname'." f; exit 1 | |
| fi | |
| # active session detection | |
| ACTIVE_USERS="$( (who 2>/dev/null || true) | awk '{print $1}' | sort -u )" | |
| if command -v loginctl >/dev/null 2>&1; then | |
| ACTIVE_USERS="$ACTIVE_USERS"$'\n'"$(loginctl list-sessions --no-legend 2>/dev/null | awk '{print $3}' | sort -u)" | |
| fi | |
| if echo "$ACTIVE_USERS" | grep -qx "$USER_NAME"; then | |
| if [[ $FORCE -ne 1 ]]; then | |
| setStatus "Refusing: '$USER_NAME' has an active session. Re-run with --force or log them out first." f | |
| exit 1 | |
| else | |
| setStatus "Proceeding despite active session for '$USER_NAME' (because --force)." q | |
| fi | |
| fi | |
| # ── Plan summary ────────────────────────────────────────────────────────────── | |
| cat <<EOF | |
| $(echo -e "${LightCyan}Plan:${NC}") | |
| • Change primary group '$PRIMARY_GROUP' to GID $NEW_GID (create or modify as needed) | |
| • Change user '$USER_NAME' to UID $NEW_UID and primary GID $NEW_GID | |
| • Recursively reassign ownership from ${OLD_UID}:${OLD_GID} to ${NEW_UID}:${NEW_GID} on local filesystems | |
| • Fix common special paths (mailbox, crontab) | |
| EOF | |
| # ── Quiesce sessions (if any) ──────────────────────────────────────────────── | |
| setStatus "Terminating processes for '$USER_NAME' (uid=$OLD_UID) to avoid file-busy errors..." "*" | |
| loginctl terminate-user "$USER_NAME" >/dev/null 2>&1 || true | |
| pkill -u "$OLD_UID" >/dev/null 2>&1 || true | |
| setStatus "Process termination attempted (ignore if none were running)." s | |
| # ── Ensure /etc/group target exists/updated ─────────────────────────────────── | |
| if ! getent group "$PRIMARY_GROUP" >/dev/null; then | |
| setStatus "Primary group '$PRIMARY_GROUP' not found; creating with GID $NEW_GID..." "*" | |
| groupadd -g "$NEW_GID" "$PRIMARY_GROUP" | |
| setStatus "Group created." s | |
| else | |
| setStatus "Setting group '$PRIMARY_GROUP' to GID $NEW_GID..." "*" | |
| groupmod -g "$NEW_GID" "$PRIMARY_GROUP" | |
| setStatus "Group GID updated." s | |
| fi | |
| # ── Change user UID/GID ────────────────────────────────────────────────────── | |
| setStatus "Changing '$USER_NAME' to UID $NEW_UID and GID $NEW_GID..." "*" | |
| usermod -u "$NEW_UID" -g "$NEW_GID" -o "$USER_NAME" | |
| setStatus "User UID/GID updated." s | |
| # ── Ownership relabel across local filesystems ─────────────────────────────── | |
| # Gather local mount points (single pass, unique) | |
| mapfile -t MOUNTS < <(awk '$3 ~ /^(ext[234]|xfs|btrfs|zfs)$/ {print $2}' /proc/mounts | sort -u) | |
| setStatus "Relabeling files from ${OLD_UID}:${OLD_GID} to ${NEW_UID}:${NEW_GID} on local filesystems..." "*" | |
| for mp in "${MOUNTS[@]}"; do | |
| # Only proceed if mountpoint exists and is a directory | |
| [[ -d "$mp" ]] || continue | |
| # Reassign ownership; -h handles symlinks, -xdev stays within that filesystem | |
| find "$mp" -xdev -uid "$OLD_UID" -exec chown -h "$NEW_UID":"$NEW_GID" {} + 2>/dev/null || true | |
| find "$mp" -xdev -gid "$OLD_GID" -exec chgrp -h "$NEW_GID" {} + 2>/dev/null || true | |
| done | |
| setStatus "Relabel complete (best-effort; skipped non-local/virtual filesystems)." s | |
| # ── Common special cases ───────────────────────────────────────────────────── | |
| [[ -e "/var/mail/$USER_NAME" ]] && chown "$NEW_UID:$NEW_GID" "/var/mail/$USER_NAME" || true | |
| [[ -e "/var/spool/cron/crontabs/$USER_NAME" ]] && chown "$NEW_UID:$NEW_GID" "/var/spool/cron/crontabs/$USER_NAME" || true | |
| # ── Report final state ─────────────────────────────────────────────────────── | |
| NEW_STATE_UID="$(id -u "$USER_NAME")" | |
| NEW_STATE_GID="$(id -g "$USER_NAME")" | |
| if [[ "$NEW_STATE_UID" -eq "$NEW_UID" && "$NEW_STATE_GID" -eq "$NEW_GID" ]]; then | |
| setStatus "Success: '$USER_NAME' is now uid=$NEW_STATE_UID gid=$NEW_STATE_GID." s | |
| setStatus "Log out and back in to refresh any cached credentials/sessions." q | |
| exit 0 | |
| else | |
| setStatus "Error: post-check shows uid=$NEW_STATE_UID gid=$NEW_STATE_GID (expected $NEW_UID:$NEW_GID)." f | |
| exit 1 | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment