Skip to content

Team Collaboration

Shared directories can be created on the server, for example /home/shared/<project_name>. Only authorized users can read and write these paths. Unauthorized users cannot browse or download files within them, so you can use them with confidence.

Admin: Configuring Directory Permissions

This section is for administrator reference. Permission management is based on POSIX ACLs, using a JSON configuration file as the single source of truth, with scripts to apply permissions in bulk.

Configuration File

permissions.json declares each user's read-only (ro) or read-write (rw) access to specific paths:

{
  "user_a": {
    "rw": ["/home/shared/project_x"],
    "ro": ["/home/data/dataset_a"]
  },
  "user_b": {
    "rw": ["/home/shared/project_x"],
    "ro": []
  }
}

apply.sh — Apply Permissions

Syncs ACL rules based on permissions.json. Supports incremental updates (diff only) and full rebuilds.

# Usage
sudo bash apply.sh                # Incremental update
sudo bash apply.sh --full         # Full rebuild
sudo bash apply.sh --dry-run      # Preview changes without applying
apply.sh source
#!/usr/bin/env bash
# apply.sh — Sync POSIX ACLs to match permissions.json (single source of truth)
#
# Supports incremental updates via permissions.lock:
#   - If permissions.json hasn't changed (SHA-256 match), skip entirely
#   - If changed, only apply differences (added/removed/modified paths)
#   - Use --full to force a complete resync (ignores lock file)
#   - Use --dry-run to preview changes without applying
#
# Usage: sudo bash apply.sh [--dry-run] [--full]

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG="$SCRIPT_DIR/permissions.json"
LOCK="$SCRIPT_DIR/permissions.lock"
TMP_LOCK="${LOCK}.tmp"

trap 'rm -f "$TMP_LOCK"' EXIT

DRY_RUN=false
FULL_RUN=false

# Base directories where everyone can list first-level contents (but not access them).
# Other base dirs remain fully locked (owner-only).
BROWSABLE_BASE_DIRS=("/home/shared")

for arg in "$@"; do
    case "$arg" in
        --dry-run) DRY_RUN=true ;;
        --full)    FULL_RUN=true ;;
        *)         echo "Unknown argument: $arg" >&2; exit 1 ;;
    esac
done

if [[ "$DRY_RUN" == true ]]; then echo "[dry-run] No changes will be made."; fi
if [[ "$FULL_RUN" == true ]]; then echo "[full] Forced complete resync."; fi

if [[ ! -f "$CONFIG" ]]; then
    echo "Error: $CONFIG not found." >&2; exit 1
fi

for cmd in jq setfacl getfacl sha256sum; do
    if ! command -v "$cmd" &>/dev/null; then
        echo "Error: $cmd is required." >&2; exit 1
    fi
done

CURRENT_HASH=$(sha256sum "$CONFIG" | awk '{print $1}')

# --- Early exit if nothing changed ---
if [[ "$FULL_RUN" == false && -f "$LOCK" ]]; then
    if LOCK_HASH=$(jq -r '.permissions_hash // ""' "$LOCK" 2>/dev/null); then
        if [[ "$LOCK_HASH" == "$CURRENT_HASH" ]]; then
            echo "No changes detected (permissions.json hash unchanged). Exiting."
            exit 0
        fi
    else
        echo "Warning: lock file corrupt, falling back to full sync." >&2
        FULL_RUN=true
    fi
fi

# --- Determine mode ---
MODE="full"
if [[ "$FULL_RUN" == false && -f "$LOCK" ]]; then
    MODE="incremental"
    echo "Changes detected — running incremental update..."
elif [[ "$FULL_RUN" == true ]]; then
    echo "Full resync — applying all permissions from scratch..."
else
    echo "No lock file found — running initial full sync..."
fi
echo ""

# --- Extract base directories from config ---
# e.g. /home/shared/dataset_a → /home/shared
#       /home/projects/2026_xxx  → /home/projects
BASE_DIRS=$(jq -r '
    [.[].rw // [], .[].ro // []] | flatten | .[] |
    split("/") | .[0:3] | join("/")
' "$CONFIG" | sort -u)

# =========================================================================== #
# Helper functions
# =========================================================================== #

revoke_path() {
    local user="$1" path="$2"
    [[ -e "$path" ]] || return 0
    if getfacl -p "$path" 2>/dev/null | grep -q "^user:${user}:"; then
        echo "  revoke  $user  $path"
        if [[ "$DRY_RUN" == false ]]; then
            setfacl -R -x "u:${user}" "$path" 2>/dev/null || true
            setfacl -R -d -x "u:${user}" "$path" 2>/dev/null || true
        fi
    fi
}

apply_rw() {
    local user="$1" path="$2"
    if [[ ! -e "$path" ]]; then
        echo "  Warning: $path does not exist, skipping." >&2; return
    fi
    echo "  apply rw  $user  $path"
    if [[ "$DRY_RUN" == false ]]; then
        setfacl -R -m "u:${user}:rwX" "$path"
        setfacl -R -d -m "u:${user}:rwX" "$path"
    fi
}

apply_ro() {
    local user="$1" path="$2"
    if [[ ! -e "$path" ]]; then
        echo "  Warning: $path does not exist, skipping." >&2; return
    fi
    echo "  apply ro  $user  $path"
    if [[ "$DRY_RUN" == false ]]; then
        setfacl -R -m "u:${user}:rX" "$path"
        setfacl -R -d -m "u:${user}:rX" "$path"
    fi
}

is_browsable_dir() {
    local dir="$1"
    for bd in "${BROWSABLE_BASE_DIRS[@]}"; do
        [[ "$dir" == "$bd" ]] && return 0
    done
    return 1
}

set_traverse() {
    local user="$1" path="$2"
    local parent="$path"
    while true; do
        parent="$(dirname "$parent")"
        [[ "$parent" == "/" || "$parent" == "/home" ]] && break
        # Skip browsable base dirs — they have o+rx, no per-user ACL needed.
        # Adding user:--x here would OVERRIDE the other::r-x for that user.
        if is_browsable_dir "$parent"; then
            continue
        fi
        echo "  traverse  $user  $parent"
        if [[ "$DRY_RUN" == false ]]; then
            setfacl -m "u:${user}:--x" "$parent"
        fi
    done
}

extract_triples() {
    local file="$1" is_lock="${2:-false}"
    jq -r --arg lock "$is_lock" '
        if $lock == "true" then .users else . end |
        to_entries[] |
        .key as $u |
        ((.value.rw // [])[] | "\($u)\t\(.)\trw"),
        ((.value.ro // [])[] | "\($u)\t\(.)\tro")
    ' "$file" 2>/dev/null | grep -v '^$' | sort -u
}

# =========================================================================== #
# Phase 0: Lock down all base directories — owner-only, no group/other
# =========================================================================== #

echo "=== Lock down base directories ==="

for basedir in $BASE_DIRS; do
    [[ -d "$basedir" ]] || continue

    if is_browsable_dir "$basedir"; then
        # Browsable: everyone can list first-level dirs and enter the base dir.
        # Subdirectories remain locked — only ACL-granted users can access them.
        if [[ "$DRY_RUN" == false ]]; then
            chmod o+rx "$basedir"
            setfacl -m "g::r-x" "$basedir" 2>/dev/null || true
            # Remove any stale named user ACL entries from browsable base dir.
            while IFS= read -r acl_user; do
                [[ -n "$acl_user" ]] || continue
                setfacl -x "u:${acl_user}" "$basedir" 2>/dev/null || true
            done < <(getfacl -p "$basedir" 2>/dev/null | grep "^user:" | cut -d: -f2 | grep -v "^$")
            # Lock down files directly in the browsable base dir (prevent download)
            find "$basedir" -maxdepth 1 -type f -exec chmod o-rwx {} \;
        fi
        echo "  $basedir → browsable (o+rx + g::r-x on base, named users cleaned, files+subdirs locked)"
    else
        # Locked: remove group + other access entirely
        if [[ "$DRY_RUN" == false ]]; then
            chmod o-rx "$basedir"
            setfacl -m "g::---" "$basedir" 2>/dev/null || true
        fi
        echo "  $basedir → owner-only (removed group + other)"
    fi

    if [[ "$MODE" == "incremental" ]]; then
        LOCKED_JSON=$(jq -r '.locked_dirs // [] | .[]' "$LOCK" 2>/dev/null || true)
        declare -A LOCKED_MAP=()
        while IFS= read -r d; do
            [[ -n "$d" ]] && LOCKED_MAP["$d"]=1
        done <<< "$LOCKED_JSON"

        for sub in "$basedir"/*/; do
            sub="${sub%/}"
            [[ -d "$sub" ]] || continue
            if [[ -z "${LOCKED_MAP[$sub]+_}" ]]; then
                echo "  lock down  $sub  (new)"
                if [[ "$DRY_RUN" == false ]]; then
                    chmod -R o-rx "$sub"
                fi
            fi
        done
        unset LOCKED_MAP
    else
        for sub in "$basedir"/*/; do
            sub="${sub%/}"
            [[ -d "$sub" ]] || continue
            if [[ "$DRY_RUN" == false ]]; then
                chmod -R o-rx "$sub"
            fi
        done
    fi

done
echo ""

# =========================================================================== #
# Phase 1-2: ACL changes
# =========================================================================== #

if [[ "$MODE" == "full" ]]; then
    # ---- Full mode: revoke stale + apply all ----

    scan_paths() {
        for basedir in $BASE_DIRS; do
            [[ -d "$basedir" ]] || continue
            echo "$basedir"
            for sub in "$basedir"/*/; do
                sub="${sub%/}"
                [[ -d "$sub" ]] && echo "$sub"
            done
        done
    }

    SCAN_LIST=$(scan_paths)
    USERS=$(jq -r 'keys[]' "$CONFIG")

    for user in $USERS; do
        if ! id "$user" &>/dev/null; then
            echo "Warning: user '$user' does not exist, skipping." >&2
            continue
        fi
        home_dir=$(eval echo "~$user")
        echo "=== $user ==="

        declare -A GRANTED=()
        for p in $(jq -r --arg u "$user" '[(.[$u].rw // []), (.[$u].ro // [])] | flatten | .[]' "$CONFIG"); do
            GRANTED["$p"]=1
        done

        echo "  [revoke] checking stale ACL entries..."
        while IFS= read -r path; do
            [[ "$path" == "$home_dir" ]] && continue
            [[ -n "${GRANTED[$path]+_}" ]] && continue
            if getfacl -p "$path" 2>/dev/null | grep -q "^user:${user}:"; then
                echo "  revoke  $path"
                if [[ "$DRY_RUN" == false ]]; then
                    setfacl -R -x "u:${user}" "$path" 2>/dev/null || true
                    setfacl -R -d -x "u:${user}" "$path" 2>/dev/null || true
                fi
            fi
        done <<< "$SCAN_LIST"

        for path in $(jq -r --arg u "$user" '.[$u].rw // [] | .[]' "$CONFIG"); do
            apply_rw "$user" "$path"
        done

        for path in $(jq -r --arg u "$user" '.[$u].ro // [] | .[]' "$CONFIG"); do
            apply_ro "$user" "$path"
        done

        for path in $(jq -r --arg u "$user" '[(.[$u].rw // []), (.[$u].ro // [])] | flatten | .[]' "$CONFIG"); do
            set_traverse "$user" "$path"
        done

        unset GRANTED
        echo ""
    done

else
    # ---- Incremental mode: diff-based update ----

    OLD_TRIPLES=$(extract_triples "$LOCK" "true")
    NEW_TRIPLES=$(extract_triples "$CONFIG" "false")

    REMOVED=$(comm -23 <(printf '%s\n' "$OLD_TRIPLES" | grep .) <(printf '%s\n' "$NEW_TRIPLES" | grep .) 2>/dev/null | grep . || true)
    ADDED=$(comm -13 <(printf '%s\n' "$OLD_TRIPLES" | grep .) <(printf '%s\n' "$NEW_TRIPLES" | grep .) 2>/dev/null | grep . || true)

    if [[ -z "$REMOVED" && -z "$ADDED" ]]; then
        echo "No ACL changes detected (same paths, same types)."
    fi

    # --- Revoke removed paths ---
    if [[ -n "$REMOVED" ]]; then
        echo "=== Revocations ==="
        while IFS=$'\t' read -r user path type; do
            if ! id "$user" &>/dev/null; then
                echo "  Warning: user '$user' no longer exists." >&2
            fi
            revoke_path "$user" "$path"
        done <<< "$REMOVED"

        # Revoke traverse for fully-removed users
        declare -A TRAVERSE_REVOKED=()
        echo ""
        echo "=== Traverse cleanup ==="
        while IFS=$'\t' read -r user path type; do
            if jq -e --arg u "$user" 'has($u)' "$CONFIG" &>/dev/null; then
                continue
            fi
            [[ -n "${TRAVERSE_REVOKED[$user]+_}" ]] && continue
            TRAVERSE_REVOKED["$user"]=1
            echo "  remove traverse for removed user: $user"
            if [[ "$DRY_RUN" == false ]]; then
                for p in $(jq -r --arg u "$user" '[(.users[$u].rw // []), (.users[$u].ro // [])] | flatten | .[]' "$LOCK"); do
                    parent="$p"
                    while true; do
                        parent="$(dirname "$parent")"
                        [[ "$parent" == "/" || "$parent" == "/home" ]] && break
                        setfacl -x "u:${user}" "$parent" 2>/dev/null || true
                    done
                done
            fi
        done <<< "$REMOVED"
        unset TRAVERSE_REVOKED
        echo ""
    fi

    # --- Apply new/changed paths ---
    if [[ -n "$ADDED" ]]; then
        echo "=== New permissions ==="
        while IFS=$'\t' read -r user path type; do
            if ! id "$user" &>/dev/null; then
                echo "  Warning: user '$user' does not exist, skipping." >&2
                continue
            fi
            case "$type" in
                rw) apply_rw "$user" "$path" ;;
                ro) apply_ro "$user" "$path" ;;
            esac
        done <<< "$ADDED"
        echo ""
    fi

    # --- Ensure traverse for all current users ---
    echo "=== Traverse check ==="
    USERS=$(jq -r 'keys[]' "$CONFIG")
    for user in $USERS; do
        if ! id "$user" &>/dev/null; then continue; fi
        for path in $(jq -r --arg u "$user" '[(.[$u].rw // []), (.[$u].ro // [])] | flatten | .[]' "$CONFIG"); do
            set_traverse "$user" "$path"
        done
    done
    echo ""
fi

# =========================================================================== #
# Update lock file
# =========================================================================== #

if [[ "$DRY_RUN" == false ]]; then
    ALL_LOCKED=""
    for basedir in $BASE_DIRS; do
        while IFS= read -r d; do
            [[ -n "$d" ]] && ALL_LOCKED+="${d}"$'\n'
        done < <(ls -1d "$basedir"/*/ 2>/dev/null | sed 's:/$::')
    done
    LOCKED_DIRS_JSON=$(printf '%s' "$ALL_LOCKED" | grep -v '^$' | sort -u | jq -R . | jq -s . 2>/dev/null || echo "[]")

    jq -n \
        --arg hash "$CURRENT_HASH" \
        --argjson dirs "$LOCKED_DIRS_JSON" \
        --slurpfile perms "$CONFIG" \
        '{version: 1, permissions_hash: $hash, locked_dirs: $dirs, users: $perms[0]}' \
        > "$TMP_LOCK" && mv -f "$TMP_LOCK" "$LOCK"

    echo "Lock file updated: $LOCK"
fi

if [[ "$DRY_RUN" == true ]]; then
    echo "[dry-run] Re-run without --dry-run to apply."
else
    echo "Done. ACLs synced to permissions.json."
fi

show.sh — View Permissions

Shows a user's effective ACL permissions, comparing configured values with actually effective values.

# Usage
bash show.sh <username>    # View a specific user
bash show.sh               # View all users
show.sh source
#!/usr/bin/env bash
# show.sh — Show a user's effective access on /home paths (excluding their own home)
# Usage: bash show.sh <username>
# Usage: bash show.sh              (shows all users in permissions.json)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG="$SCRIPT_DIR/permissions.json"

if [[ ! -f "$CONFIG" ]]; then
    echo "Error: $CONFIG not found." >&2
    exit 1
fi

for cmd in jq getfacl; do
    if ! command -v "$cmd" &>/dev/null; then
        echo "Error: $cmd is required." >&2
        exit 1
    fi
done

# ---- Color helpers -------------------------------------------------------- #
if [[ -t 1 ]]; then
    GREEN='\033[0;32m'; YELLOW='\033[0;33m'; RED='\033[0;31m'
    CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m'
else
    GREEN=''; YELLOW=''; RED=''; CYAN=''; BOLD=''; RESET=''
fi

fmt_perm() {
    local perm="$1"
    if [[ "$perm" == *"w"* ]]; then
        echo -e "${GREEN}${perm}${RESET}"
    elif [[ "$perm" == *"r"* ]]; then
        echo -e "${YELLOW}${perm}${RESET}"
    else
        echo -e "${RED}${perm}${RESET}"
    fi
}

# ---- Core: get effective permissions for a user on a path ----------------- #
get_effective_perm() {
    local user="$1" path="$2"
    local perm
    # getfacl → find user-specific ACL entry → extract permission bits
    perm=$(getfacl -p "$path" 2>/dev/null | grep "^user:${user}:" | head -1 | cut -d: -f3)
    if [[ -n "$perm" ]]; then
        echo "$perm"
        return
    fi

    # Fall back to group membership check
    local gid owner_group
    owner_group=$(stat -c '%G' "$path" 2>/dev/null)
    if id -nG "$user" 2>/dev/null | tr ' ' '\n' | grep -qx "$owner_group"; then
        perm=$(getfacl -p "$path" 2>/dev/null | grep "^group::$" -A0 | head -1)
        perm=${perm##*:}
        if [[ -z "$perm" ]]; then
            perm=$(stat -c '%A' "$path" 2>/dev/null | cut -c5-7)
        fi
        echo "$perm"
        return
    fi

    # Fall back to 'other'
    perm=$(getfacl -p "$path" 2>/dev/null | grep "^other::" | head -1 | cut -d: -f3)
    echo "${perm:----}"
}

# ---- Display one user ---------------------------------------------------- #
show_user() {
    local user="$1"

    if ! id "$user" &>/dev/null; then
        echo -e "${RED}User '$user' does not exist.${RESET}" >&2
        return 1
    fi

    local home_dir
    home_dir=$(eval echo "~$user")

    echo -e "${BOLD}${CYAN}User: ${user}${RESET}"
    echo -e "${BOLD}${CYAN}$(printf '=%.0s' {1..60})${RESET}"

    # -- Section 1: Configured permissions (from JSON) ---------------------- #
    echo -e "\n${BOLD}Configured (permissions.json):${RESET}"

    local has_config=false

    local rw_paths
    rw_paths=$(jq -r --arg u "$user" '.[$u].rw // [] | .[]' "$CONFIG" 2>/dev/null)
    if [[ -n "$rw_paths" ]]; then
        has_config=true
        while IFS= read -r path; do
            local eff
            eff=$(get_effective_perm "$user" "$path")
            local status="✓"
            [[ "$eff" != *"w"* ]] && status="✗ (not applied)"
            echo -e "  rw  $path  →  effective: $(fmt_perm "$eff")  $status"
        done <<< "$rw_paths"
    fi

    local ro_paths
    ro_paths=$(jq -r --arg u "$user" '.[$u].ro // [] | .[]' "$CONFIG" 2>/dev/null)
    if [[ -n "$ro_paths" ]]; then
        has_config=true
        while IFS= read -r path; do
            local eff
            eff=$(get_effective_perm "$user" "$path")
            local status="✓"
            [[ "$eff" != *"r"* ]] && status="✗ (not applied)"
            echo -e "  ro  $path  →  effective: $(fmt_perm "$eff")  $status"
        done <<< "$ro_paths"
    fi

    if [[ "$has_config" == false ]]; then
        echo "  (no entries)"
    fi

    # -- Section 2: Scan all /home/* paths for any extra access ------------- #
    echo -e "\n${BOLD}All access under /home (excluding $home_dir):${RESET}"

    local found_any=false
    for entry in /home/*/; do
        entry="${entry%/}"
        [[ "$entry" == "$home_dir" ]] && continue

        local eff
        eff=$(get_effective_perm "$user" "$entry")
        [[ "$eff" == "---" ]] && continue

        found_any=true
        echo -e "  $(fmt_perm "$eff")  $entry"

        # Also scan one level deeper for browsable base dirs
        local base_dir
        base_dir=$(jq -r '
            [.[].rw // [], .[].ro // []] | flatten | .[] |
            split("/") | .[0:3] | join("/")
        ' "$CONFIG" | sort -u | head -1)
        if [[ "$entry" == "$base_dir" ]]; then
            for subentry in "$entry"/*/; do
                subentry="${subentry%/}"
                local sub_eff
                sub_eff=$(get_effective_perm "$user" "$subentry")
                [[ "$sub_eff" == "---" ]] && continue
                echo -e "    $(fmt_perm "$sub_eff")  $subentry"
            done
        fi
    done

    if [[ "$found_any" == false ]]; then
        echo "  (no access found)"
    fi

    echo ""
}

# ---- Main ---------------------------------------------------------------- #
if [[ $# -ge 1 ]]; then
    show_user "$1"
else
    USERS=$(jq -r 'keys[]' "$CONFIG")
    for user in $USERS; do
        show_user "$user"
    done
fi

test.sh — Verify Permissions

Verifies that ACL permission boundaries match permissions.json. Tests include: positive access on authorized paths, system directory denial, home directory isolation, unauthorized path denial, etc.

# Usage
sudo bash test.sh
test.sh source
#!/usr/bin/env bash
# test.sh — Verify ACL permission boundaries match permissions.json (Efficient Edition)
#
# Usage: sudo bash test.sh
#
# For each user in permissions.json, tests:
#   1. ro positive:  read a ro-authorized path (ls + file read)
#   2. rw positive:  create+read+delete temp file in a rw-authorized path
#   3. system deny:  cannot access system dirs (/etc, /root, /usr, /var)
#   4. home isolation: cannot access another user's home dir (/home/<user>)
#   5. unauthorized data: cannot access real paths not in permissions.json
#   6. base dir traversal: locked dirs (cd-only, no ls) / browsable dirs (everyone can ls)
#   7. browsable isolation: can list browsable base dir but cannot access unauthorized subdirs
#
# Strategy: Random sampling for efficiency, but guarantees critical boundary tests.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG="$SCRIPT_DIR/permissions.json"
TIMESTAMP=$(date +%s)
TMPFILE=".acl_test_${TIMESTAMP}"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BOLD='\033[1m'
RESET='\033[0m'

TOTAL=0
PASSED=0
FAILED=0

SYSTEM_DIRS=("/etc" "/root" "/usr" "/var")

# Base dirs where everyone can list first-level contents (must match apply.sh)
BROWSABLE_BASE_DIRS=("/home/shared")

is_browsable_dir() {
    local dir="$1"
    for bd in "${BROWSABLE_BASE_DIRS[@]}"; do
        [[ "$dir" == "$bd" ]] && return 0
    done
    return 1
}

if [[ ! -f "$CONFIG" ]]; then
    echo "Error: $CONFIG not found." >&2; exit 1
fi

if [[ $EUID -ne 0 ]]; then
    echo "Error: run with sudo." >&2; exit 1
fi

for cmd in jq; do
    if ! command -v "$cmd" &>/dev/null; then
        echo "Error: $cmd is required." >&2; exit 1
    fi
done

pass() {
    echo -e "  ${GREEN}PASS${RESET}  $1"
    ((PASSED++)) || true
    ((TOTAL++)) || true
}

fail() {
    echo -e "  ${RED}FAIL${RESET}  $1"
    ((FAILED++)) || true
    ((TOTAL++)) || true
}

skip() {
    echo -e "  ${YELLOW}SKIP${RESET}  $1"
    ((TOTAL++)) || true
}

as_user() {
    local user="$1"; shift
    sudo -u "$user" "$@" 2>/dev/null
}

user_all_paths() {
    local user="$1"
    jq -r --arg u "$user" '
        [(.[$u].rw // []), (.[$u].ro // [])] | flatten | .[]
    ' "$CONFIG"
}

user_rw_paths() {
    local user="$1"
    jq -r --arg u "$user" '.[$u].rw // [] | .[]' "$CONFIG"
}

user_ro_paths() {
    local user="$1"
    jq -r --arg u "$user" '.[$u].ro // [] | .[]' "$CONFIG"
}

# Get all authorized paths from config (flattened)
all_authorized_paths() {
    jq -r '(.[].rw // []), (.[].ro // []) | .[]' "$CONFIG" | sort -u
}

# Discover real directories under base paths that are NOT in permissions.json
find_unauthorized_paths() {
    local user="$1"
    local all_auth all_subdirs unauthorized

    # Get all paths this user is authorized for
    all_auth=$(user_all_paths "$user" | sort -u)

    unauthorized=()

    # Check base dirs for real directories
    BASE_DIRS=$(jq -r '
        [.[].rw // [], .[].ro // []] | flatten | .[] |
        split("/") | .[0:3] | join("/")
    ' "$CONFIG" | sort -u)

    for base in $BASE_DIRS; do
        [[ -d "$base" ]] || continue

        # Find immediate subdirectories
        while IFS= read -r -d '' subdir; do
            local basename
            basename=$(basename "$subdir")
            local fullpath="$base/$basename"

            # Skip if user is authorized for this exact path or a parent
            local is_authorized=false
            while IFS= read -r auth_path; do
                [[ -z "$auth_path" ]] && continue
                if [[ "$fullpath" == "$auth_path" || "$fullpath" == "$auth_path"/* || "$auth_path" == "$fullpath"/* ]]; then
                    is_authorized=true
                    break
                fi
            done <<< "$all_auth"

            if [[ "$is_authorized" == false ]]; then
                unauthorized+=("$fullpath")
            fi
        done < <(find "$base" -maxdepth 1 -mindepth 1 -type d -print0 2>/dev/null)
    done

    printf '%s\n' "${unauthorized[@]}"
}

# --- Collect users ---
USERS=$(jq -r 'keys[]' "$CONFIG")

echo -e "${BOLD}=== ACL Permission Test ===${RESET}"
echo "Config: $CONFIG"
echo "Strategy: Random sampling for efficiency, critical paths guaranteed"
echo ""

for user in $USERS; do
    if ! id "$user" &>/dev/null; then
        echo -e "${YELLOW}SKIP${RESET}  user '$user' does not exist"
        echo ""
        continue
    fi

    echo -e "${BOLD}=== $user ===${RESET}"

    declare -A MY_PATHS=()
    while IFS= read -r p; do
        MY_PATHS["$p"]=1
    done < <(user_all_paths "$user")

    # ---- 1. RO positive: read a ro-authorized path ----
    RO_PATHS=()
    while IFS= read -r p; do
        [[ -n "$p" ]] && RO_PATHS+=("$p")
    done < <(user_ro_paths "$user")

    if [[ ${#RO_PATHS[@]} -gt 0 ]]; then
        target="${RO_PATHS[$((RANDOM % ${#RO_PATHS[@]}))]}"
        if [[ ! -e "$target" ]]; then
            skip "ro  read    $target  (path does not exist)"
        elif as_user "$user" ls "$target" &>/dev/null; then
            testfile=$(find "$target" -maxdepth 1 -type f -print -quit 2>/dev/null)
            if [[ -n "$testfile" ]] && as_user "$user" cat "$testfile" >/dev/null 2>&1; then
                pass "ro  read    $target  (dir ls + file read)"
            else
                pass "ro  read    $target  (dir ls, no testable file)"
            fi
        else
            fail "ro  read    $target  (should be readable)"
        fi
    else
        skip "ro  (no ro paths configured)"
    fi

    # ---- 2. RW positive: create+read+delete temp file ----
    RW_PATHS=()
    while IFS= read -r p; do
        [[ -n "$p" ]] && RW_PATHS+=("$p")
    done < <(user_rw_paths "$user")

    if [[ ${#RW_PATHS[@]} -gt 0 ]]; then
        target="${RW_PATHS[$((RANDOM % ${#RW_PATHS[@]}))]}"
        if [[ ! -e "$target" ]]; then
            skip "rw  write   $target  (path does not exist)"
        else
            rw_pass=true
            if ! as_user "$user" touch "$target/$TMPFILE" 2>/dev/null; then
                rw_pass=false
            fi
            if [[ "$rw_pass" == true ]] && ! as_user "$user" cat "$target/$TMPFILE" >/dev/null 2>&1; then
                rw_pass=false
            fi
            if [[ "$rw_pass" == true ]] && ! as_user "$user" rm -f "$target/$TMPFILE" 2>/dev/null; then
                rw_pass=false
            fi
            if [[ "$rw_pass" == false ]]; then
                as_user "$user" rm -f "$target/$TMPFILE" 2>/dev/null || true
                fail "rw  write   $target  (create/read/delete failed)"
            else
                pass "rw  write   $target  (create+read+delete)"
            fi
        fi
    else
        skip "rw  (no rw paths configured)"
    fi

    # ---- 3. System directory write denial ----
    for sysdir in "${SYSTEM_DIRS[@]}"; do
        if [[ ! -d "$sysdir" ]]; then
            skip "deny-write  $sysdir  (does not exist)"
            continue
        fi

        if [[ "$sysdir" == "/root" ]]; then
            if as_user "$user" ls "$sysdir" &>/dev/null; then
                fail "deny  $sysdir  (CRITICAL: /root should NEVER be accessible)"
            else
                pass "deny  $sysdir  (root dir blocked)"
            fi
        else
            if as_user "$user" touch "$sysdir/.acl_test_${TIMESTAMP}_${user}" 2>/dev/null; then
                rm -f "$sysdir/.acl_test_${TIMESTAMP}_${user}" 2>/dev/null || true
                fail "deny-write  $sysdir  (SYSTEM DIR — should NEVER be writable)"
            else
                pass "deny-write  $sysdir  (not writable)"
            fi
        fi
    done

    # ---- 4. Home isolation: cannot access another user's home ----
    OTHER_USERS=()
    for u in $USERS; do
        [[ "$u" != "$user" ]] && OTHER_USERS+=("$u")
    done

    if [[ ${#OTHER_USERS[@]} -gt 0 ]]; then
        shuffled=()
        while IFS= read -r -d '' u; do
            shuffled+=("$u")
        done < <(printf '%s\0' "${OTHER_USERS[@]}" | shuf -z 2>/dev/null || printf '%s\0' "${OTHER_USERS[@]}")

        test_count=0
        for other_u in "${shuffled[@]}"; do
            [[ $test_count -ge 2 ]] && break
            other_home="/home/$other_u"

            if [[ ! -d "$other_home" ]]; then
                skip "deny  $other_home  (dir does not exist)"
                ((test_count++)) || true
                continue
            fi

            owner=$(stat -c '%U' "$other_home" 2>/dev/null || echo "")
            if [[ "$owner" == "$user" ]]; then
                skip "deny  $other_home  ($user is owner)"
            elif as_user "$user" ls "$other_home" &>/dev/null; then
                fail "deny  $other_home  ($other_u home dir — should NEVER access)"
            else
                pass "deny  $other_home  ($other_u home dir blocked)"
            fi
            ((test_count++)) || true
        done
    fi

    # ---- 5. Unauthorized real paths: discover and test ----
    UNAUTH_PATHS=()
    while IFS= read -r p; do
        [[ -n "$p" ]] && UNAUTH_PATHS+=("$p")
    done < <(find_unauthorized_paths "$user")

    if [[ ${#UNAUTH_PATHS[@]} -gt 0 ]]; then
        shuffled_unauth=()
        while IFS= read -r -d '' p; do
            shuffled_unauth+=("$p")
        done < <(printf '%s\0' "${UNAUTH_PATHS[@]}" | shuf -z 2>/dev/null || printf '%s\0' "${UNAUTH_PATHS[@]}")

        test_count=0
        for target in "${shuffled_unauth[@]}"; do
            [[ $test_count -ge 2 ]] && break

            owner=$(stat -c '%U' "$target" 2>/dev/null || echo "")
            if [[ "$owner" == "$user" ]]; then
                skip "deny  $target  ($user is owner)"
            elif as_user "$user" ls "$target" &>/dev/null; then
                fail "deny  $target  (unauthorized path — should NOT access)"
            else
                pass "deny  $target  (unauthorized path blocked)"
            fi
            ((test_count++)) || true
        done
    else
        skip "deny  (no unauthorized real paths to test)"
    fi

    # ---- 6. Base dir traversal / browsability check ----
    BASE_DIRS=$(jq -r '
        [.[].rw // [], .[].ro // []] | flatten | .[] |
        split("/") | .[0:3] | join("/")
    ' "$CONFIG" | sort -u)

    for basedir in $BASE_DIRS; do
        [[ -d "$basedir" ]] || continue
        basedir_owner=$(stat -c '%U' "$basedir" 2>/dev/null || echo "")

        if [[ "$basedir_owner" == "$user" ]]; then
            skip "traverse  $basedir  ($user is owner)"
            continue
        fi

        if is_browsable_dir "$basedir"; then
            if as_user "$user" ls "$basedir" &>/dev/null; then
                pass "browsable  $basedir  (can list first-level dirs)"
            else
                fail "browsable  $basedir  (should be able to list first-level dirs)"
            fi
        else
            has_path_under=false
            while IFS= read -r p; do
                if [[ "$p" == "$basedir"/* ]]; then
                    has_path_under=true
                    break
                fi
            done < <(user_all_paths "$user")

            if [[ "$has_path_under" == true ]]; then
                if as_user "$user" test -x "$basedir" 2>/dev/null; then
                    if as_user "$user" ls "$basedir" &>/dev/null; then
                        fail "traverse  $basedir  (can list contents, should only traverse)"
                    else
                        pass "traverse  $basedir  (can cd through, cannot ls)"
                    fi
                else
                    fail "traverse  $basedir  (cannot even traverse)"
                fi
            else
                if as_user "$user" ls "$basedir" &>/dev/null; then
                    fail "deny  $basedir  (no paths under here, should NOT access)"
                else
                    pass "deny  $basedir  (no paths under here)"
                fi
            fi
        fi
    done

    # ---- 7. Browsable base dir: subdirectory isolation ----
    for browsable_dir in "${BROWSABLE_BASE_DIRS[@]}"; do
        [[ -d "$browsable_dir" ]] || continue

        unauth_subdirs=()
        while IFS= read -r -d '' subdir; do
            is_auth=false
            while IFS= read -r auth_path; do
                [[ -z "$auth_path" ]] && continue
                if [[ "$subdir" == "$auth_path" || "$subdir" == "$auth_path"/* || "$auth_path" == "$subdir"/* ]]; then
                    is_auth=true
                    break
                fi
            done < <(user_all_paths "$user")
            [[ "$is_auth" == false ]] && unauth_subdirs+=("$subdir")
        done < <(find "$browsable_dir" -maxdepth 1 -mindepth 1 -type d -print0 2>/dev/null)

        if [[ ${#unauth_subdirs[@]} -eq 0 ]]; then
            skip "isolation  $browsable_dir  (all first-level subdirs authorized)"
            continue
        fi

        test_count=0
        for subdir in "${unauth_subdirs[@]}"; do
            [[ $test_count -ge 2 ]] && break
            subdir_owner=$(stat -c '%U' "$subdir" 2>/dev/null || echo "")
            if [[ "$subdir_owner" == "$user" ]]; then
                skip "isolation  $subdir  ($user is owner)"
                ((test_count++)) || true
                continue
            elif as_user "$user" ls "$subdir" &>/dev/null; then
                fail "isolation  $subdir  (unauthorized — should NOT list)"
            else
                pass "isolation  $subdir  (unauthorized subdir blocked)"
            fi

            testfile=$(find "$subdir" -maxdepth 1 -type f -readable -print -quit 2>/dev/null)
            if [[ -n "$testfile" ]]; then
                if as_user "$user" cat "$testfile" >/dev/null 2>&1; then
                    fail "isolation  $testfile  (unauthorized file — should NOT read)"
                else
                    pass "isolation  $testfile  (unauthorized file blocked)"
                fi
            fi
            ((test_count++)) || true
        done

        # Verify files directly in browsable base dir cannot be downloaded
        while IFS= read -r -d '' basefile; do
            file_owner=$(stat -c '%U' "$basefile" 2>/dev/null || echo "")
            if [[ "$file_owner" == "$user" ]]; then
                skip "isolation  $basefile  ($user is owner)"
            elif as_user "$user" cat "$basefile" >/dev/null 2>&1; then
                fail "isolation  $basefile  (browsable-only file — should NOT download)"
            else
                pass "isolation  $basefile  (browsable-only file blocked)"
            fi
        done < <(find "$browsable_dir" -maxdepth 1 -type f -print0 2>/dev/null)
    done

    unset MY_PATHS
    echo ""
done

# --- Summary ---
echo -e "${BOLD}--- Results ---${RESET}"
echo -e "  Total: $TOTAL"
echo -e "  ${GREEN}Passed: $PASSED${RESET}"
if [[ "$FAILED" -gt 0 ]]; then
    echo -e "  ${RED}Failed: $FAILED${RESET}"
    exit 1
else
    echo -e "  ${GREEN}Failed: 0${RESET}"
fi

FAQ

How to revoke a user's access?

Remove the user's entry from permissions.json and re-run apply.sh. The script will automatically revoke all ACL entries for that user.