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.
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.
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.