Simplify passwordless sudo grant lifecycle
This commit is contained in:
1 parent
5bfc8b5cc3
commit
c46f321680
6 files changed
+525
-944
No files matched your search
+134
-286
@@ -24,12 +24,11 @@ set -euo pipefail
|
||||
|
||||
readonly DEFAULT_MINUTES=15
|
||||
readonly MAX_MINUTES=1440
|
||||
readonly STATE_DIR=/var/lib/omarchy/sudo-passwordless
|
||||
readonly RUNTIME_DIR=/run/omarchy/sudo-passwordless
|
||||
readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock
|
||||
readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf
|
||||
readonly PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook
|
||||
readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing
|
||||
readonly MIGRATION_MARKER=/var/lib/omarchy/migrations/1788163635
|
||||
readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless
|
||||
readonly STATUS_INACTIVE=3
|
||||
|
||||
@@ -73,26 +72,6 @@ verify_sudo_caller() {
|
||||
resolve_account "$requested_uid"
|
||||
}
|
||||
|
||||
prepare_root_state() {
|
||||
omarchy_security_assert_root_directory /var 755 || return 1
|
||||
[[ -d /var/lib && ! -L /var/lib ]] || return 1
|
||||
[[ $(/usr/bin/stat -Lc '%u' /var/lib) == 0 ]] || return 1
|
||||
! ((8#$(/usr/bin/stat -Lc '%a' /var/lib) & 022)) || return 1
|
||||
|
||||
if [[ ! -e /var/lib/omarchy && ! -L /var/lib/omarchy ]]; then
|
||||
/usr/bin/install -d -o root -g root -m 0755 /var/lib/omarchy || return 1
|
||||
fi
|
||||
omarchy_security_assert_root_directory /var/lib/omarchy 755 || return 1
|
||||
omarchy_security_prepare_private_root_directory "$STATE_DIR" /var/lib/omarchy || return 1
|
||||
|
||||
omarchy_security_assert_root_directory /run 755 || return 1
|
||||
if [[ ! -e /run/omarchy && ! -L /run/omarchy ]]; then
|
||||
/usr/bin/install -d -o root -g root -m 0755 /run/omarchy || return 1
|
||||
fi
|
||||
omarchy_security_assert_root_directory /run/omarchy 755 || return 1
|
||||
omarchy_security_prepare_private_root_directory "$RUNTIME_DIR" /run/omarchy
|
||||
}
|
||||
|
||||
with_root_lock() {
|
||||
local fd rc=0
|
||||
# The boot cleanup cannot depend on STATE_DIR or RUNTIME_DIR being healthy:
|
||||
@@ -116,67 +95,23 @@ rule_file() {
|
||||
printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1"
|
||||
}
|
||||
|
||||
state_file() {
|
||||
printf '%s/%s.state' "$STATE_DIR" "$1"
|
||||
}
|
||||
|
||||
read_state_record() {
|
||||
local uid="$1" file state_uid name expires timer canonical_uid
|
||||
local -a lines=()
|
||||
valid_uid "$uid" || return 1
|
||||
canonical_uid=$((10#$uid))
|
||||
file=$(state_file "$uid")
|
||||
[[ -f $file && ! -L $file ]] || return 1
|
||||
mapfile -t lines <"$file" || return 1
|
||||
(( ${#lines[@]} == 4 )) || return 1
|
||||
[[ ${lines[0]} == UID=* && ${lines[1]} == USER=* &&
|
||||
${lines[2]} == EXPIRES=* && ${lines[3]} == TIMER=* ]] || return 1
|
||||
state_uid=${lines[0]#UID=}
|
||||
name=${lines[1]#USER=}
|
||||
expires=${lines[2]#EXPIRES=}
|
||||
timer=${lines[3]#TIMER=}
|
||||
[[ $state_uid == "$canonical_uid" ]] || return 1
|
||||
valid_account_name "$name" || return 1
|
||||
[[ $expires =~ ^[1-9][0-9]{0,10}$ ]] || return 1
|
||||
[[ $timer =~ ^omarchy-nopasswd-expire-${canonical_uid}-[0-9a-f]{32}$ ]] || return 1
|
||||
printf '%s\t%s\t%s' "$name" "$expires" "$timer"
|
||||
}
|
||||
|
||||
read_state_timer() {
|
||||
local record
|
||||
record=$(read_state_record "$1") || return 1
|
||||
printf '%s' "${record##*$'\t'}"
|
||||
}
|
||||
|
||||
current_epoch() {
|
||||
local now
|
||||
now=$(/usr/bin/date +%s) || return 1
|
||||
[[ $now =~ ^[1-9][0-9]{0,10}$ ]] || return 1
|
||||
printf '%s' "$now"
|
||||
}
|
||||
|
||||
valid_expiry() {
|
||||
[[ $1 =~ ^[1-9][0-9]{0,10}$ ]]
|
||||
}
|
||||
|
||||
valid_timer_for_uid() {
|
||||
local uid="$1" timer="$2"
|
||||
valid_uid "$uid" || return 1
|
||||
uid=$((10#$uid))
|
||||
[[ $timer =~ ^omarchy-nopasswd-expire-${uid}-[0-9a-f]{32}$ ]]
|
||||
}
|
||||
|
||||
stop_timer() {
|
||||
local timer="$1"
|
||||
[[ $timer =~ ^omarchy-nopasswd-expire-[0-9]+-[0-9a-f]{32}$ ]] || return 0
|
||||
/usr/bin/systemctl stop "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true
|
||||
/usr/bin/systemctl reset-failed "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true
|
||||
# The sudoers rule is the only grant record. A missing file is distinct from
|
||||
# an unreadable, unsafe, or administrator-modified file.
|
||||
read_grant() {
|
||||
local file contents
|
||||
file=$(rule_file "$1")
|
||||
[[ -e $file || -L $file ]] || return "$STATUS_INACTIVE"
|
||||
verify_root_path "$file" && [[ -f $file ]] || return 2
|
||||
contents=$(/usr/bin/cat -- "$file") || return 2
|
||||
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2
|
||||
GRANT_NAME=${BASH_REMATCH[1]}
|
||||
GRANT_DEADLINE=${BASH_REMATCH[2]}
|
||||
valid_account_name "$GRANT_NAME" || return 2
|
||||
}
|
||||
|
||||
classify_generated_rule() {
|
||||
local file=$1 suffix contents name
|
||||
|
||||
GENERATED_RULE_LEGACY_TIMER=""
|
||||
[[ -f $file && ! -L $file ]] || return 1
|
||||
contents=$(/usr/bin/cat -- "$file") || return 2
|
||||
suffix=${file##*/99-omarchy-nopasswd-}
|
||||
@@ -189,81 +124,40 @@ classify_generated_rule() {
|
||||
name=${contents%%' ALL=(ALL) NOTAFTER='*}
|
||||
valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]]
|
||||
elif valid_account_name "$suffix" && [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then
|
||||
GENERATED_RULE_LEGACY_TIMER="omarchy-nopasswd-expire-${suffix}"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
remove_known_legacy_rules() {
|
||||
cleanup_uid_locked() {
|
||||
local file
|
||||
file=$(rule_file "$1")
|
||||
[[ -e $file || -L $file ]] || return 0
|
||||
verify_root_path "$file" && classify_generated_rule "$file" || return 1
|
||||
/usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]
|
||||
}
|
||||
|
||||
cleanup_all_locked() {
|
||||
local file classification failed=0
|
||||
shopt -s nullglob
|
||||
verify_root_path /etc/sudoers.d || return 1
|
||||
for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do
|
||||
[[ -e $file || -L $file ]] || continue
|
||||
if classify_generated_rule "$file"; then
|
||||
# A crash after publishing the numeric rule but before its state rename
|
||||
# must not survive the next boot. Do not require the account to still
|
||||
# exist: a deleted account could otherwise make the rule immortal and a
|
||||
# later username reuse could activate it again.
|
||||
if /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]; then
|
||||
[[ -z $GENERATED_RULE_LEGACY_TIMER ]] ||
|
||||
/usr/bin/systemctl stop "${GENERATED_RULE_LEGACY_TIMER}.timer" \
|
||||
"${GENERATED_RULE_LEGACY_TIMER}.service" >/dev/null 2>&1 || true
|
||||
else
|
||||
if ! /usr/bin/rm -f -- "$file" || [[ -e $file || -L $file ]]; then
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
classification=$?
|
||||
# An unreadable candidate cannot be proven inert. A symlink, non-file,
|
||||
# or administrator-authored body is unrelated and remains untouched.
|
||||
(( classification == 1 )) || failed=1
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
cleanup_uid_locked() {
|
||||
local uid="$1" timer=""
|
||||
valid_uid "$uid" || return 1
|
||||
timer=$(read_state_timer "$uid" 2>/dev/null || true)
|
||||
# Remove policy first. A failed timer stop can only leave an inert cleanup
|
||||
# job behind, never extend passwordless access.
|
||||
/usr/bin/rm -f -- "$(rule_file "$uid")" || return 1
|
||||
[[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]] || return 1
|
||||
/usr/bin/rm -f -- "$(state_file "$uid")" || return 1
|
||||
[[ -z $timer ]] || stop_timer "$timer"
|
||||
}
|
||||
|
||||
cleanup_all_locked() {
|
||||
local state uid failed=0 file classification
|
||||
shopt -s nullglob
|
||||
for state in "$STATE_DIR"/*.state; do
|
||||
uid=${state##*/}
|
||||
uid=${uid%.state}
|
||||
if valid_uid "$uid" && ! cleanup_uid_locked "$uid"; then failed=1; fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
remove_known_legacy_rules || failed=1
|
||||
|
||||
# Never report a successful boot cleanup while an exact rule emitted by any
|
||||
# Omarchy implementation is still active. Administrator-extended files do
|
||||
# not match these complete bodies and remain untouched.
|
||||
shopt -s nullglob
|
||||
for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do
|
||||
if classify_generated_rule "$file"; then
|
||||
failed=1
|
||||
else
|
||||
classification=$?
|
||||
(( classification == 1 )) || failed=1
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
verify_root_policy_file() {
|
||||
verify_root_path() {
|
||||
local file=$1 owner mode canonical current
|
||||
[[ -f $file && ! -L $file ]] || return 1
|
||||
[[ ( -f $file || -d $file ) && ! -L $file ]] || return 1
|
||||
canonical=$(/usr/bin/realpath -e -- "$file") || return 1
|
||||
[[ $canonical == "$file" ]] || return 1
|
||||
owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1
|
||||
@@ -286,10 +180,10 @@ verify_root_policy_file() {
|
||||
verify_boot_cleanup() {
|
||||
local active_rules hook
|
||||
[[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1
|
||||
verify_root_policy_file "$BOOT_CLEANUP_FILE" || return 1
|
||||
verify_root_path "$BOOT_CLEANUP_FILE" || return 1
|
||||
active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1
|
||||
[[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || return 1
|
||||
verify_root_policy_file "$PACKAGE_HOOK" || return 1
|
||||
verify_root_path "$PACKAGE_HOOK" || return 1
|
||||
hook=$(/usr/bin/cat -- "$PACKAGE_HOOK") || return 1
|
||||
[[ $hook == '[Trigger]
|
||||
Operation = Upgrade
|
||||
@@ -313,166 +207,113 @@ package_removing_locked() {
|
||||
cleanup_all_locked
|
||||
}
|
||||
|
||||
prepare_state_file() {
|
||||
local uid="$1" name="$2" expires="$3" timer="$4" tmp
|
||||
tmp=$(/usr/bin/mktemp "$STATE_DIR/.state.XXXXXX") || return 1
|
||||
if ! /usr/bin/printf 'UID=%s\nUSER=%s\nEXPIRES=%s\nTIMER=%s\n' \
|
||||
"$uid" "$name" "$expires" "$timer" >"$tmp" ||
|
||||
! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0600 "$tmp"; then
|
||||
/usr/bin/rm -f -- "$tmp"
|
||||
return 1
|
||||
migration_complete() {
|
||||
[[ -f $MIGRATION_MARKER && ! -s $MIGRATION_MARKER ]] && verify_root_path "$MIGRATION_MARKER"
|
||||
}
|
||||
|
||||
migrate_locked() {
|
||||
local directory
|
||||
if migration_complete; then
|
||||
return 0
|
||||
fi
|
||||
printf '%s' "$tmp"
|
||||
[[ ! -e $MIGRATION_MARKER && ! -L $MIGRATION_MARKER ]] || return 1
|
||||
verify_root_path /var/lib || return 1
|
||||
for directory in /var/lib/omarchy /var/lib/omarchy/migrations; do
|
||||
if [[ ! -e $directory && ! -L $directory ]]; then
|
||||
/usr/bin/install -d -o root -g root -m 0755 -- "$directory" || return 1
|
||||
fi
|
||||
verify_root_path "$directory" || return 1
|
||||
done
|
||||
cleanup_all_locked || return 1
|
||||
# The empty marker is written only after cleanup succeeds, under the same
|
||||
# machine lock. Later accounts need no sudo and cannot revoke newer grants.
|
||||
/usr/bin/install -o root -g root -m 0644 /dev/null "$MIGRATION_MARKER"
|
||||
}
|
||||
|
||||
start_expiry_timer() {
|
||||
local uid="$1" expires="$2" timer="$3"
|
||||
valid_uid "$uid" && valid_expiry "$expires" && valid_timer_for_uid "$uid" "$timer" || return 1
|
||||
# Calendar timers use CLOCK_REALTIME and catch up immediately after resume;
|
||||
# a monotonic OnActiveSec timer pauses while the machine is suspended.
|
||||
/usr/bin/systemd-run --quiet --collect --on-calendar="@${expires}" \
|
||||
--timer-property=AccuracySec=1s --unit="$timer" \
|
||||
-- "$INSTALLED_SELF" __expire "$uid" "$timer" || return 1
|
||||
/usr/bin/systemctl is-active --quiet "${timer}.timer"
|
||||
}
|
||||
|
||||
publish_rule() {
|
||||
local uid="$1" name="$2" expires="$3" destination tmp deadline
|
||||
valid_expiry "$expires" || return 1
|
||||
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
|
||||
[[ $deadline =~ ^[0-9]{14}Z$ ]] || return 1
|
||||
destination=$(rule_file "$uid")
|
||||
tmp=$(/usr/bin/mktemp "$STATE_DIR/.sudoers.XXXXXX") || return 1
|
||||
if ! /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$name" "$deadline" >"$tmp" ||
|
||||
! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0440 "$tmp" ||
|
||||
! /usr/sbin/visudo -cf "$tmp" >/dev/null ||
|
||||
! /usr/bin/install -o root -g root -m 0440 -- "$tmp" "$destination"; then
|
||||
/usr/bin/rm -f -- "$tmp"
|
||||
return 1
|
||||
fi
|
||||
/usr/bin/rm -f -- "$tmp"
|
||||
}
|
||||
|
||||
abort_enable_locked() {
|
||||
local uid=$1 timer=$2 old_timer=$3 pending_state=$4
|
||||
# Publication can install policy and then fail while cleaning its temporary
|
||||
# file. Never disarm either expiry job until policy revocation is confirmed.
|
||||
if cleanup_uid_locked "$uid"; then
|
||||
stop_timer "$timer"
|
||||
[[ -z $old_timer ]] || stop_timer "$old_timer"
|
||||
# Old callbacks only remove an expired current rule. Renewing a grant never
|
||||
# needs a second state file or a stored timer generation to identify it.
|
||||
expire_locked() {
|
||||
local status now
|
||||
if read_grant "$1"; then
|
||||
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
|
||||
[[ $now < $GRANT_DEADLINE ]] && return 0
|
||||
cleanup_uid_locked "$1"
|
||||
else
|
||||
echo "Could not revoke passwordless sudo after a failed grant; expiry jobs remain armed. Administrator cleanup is required." >&2
|
||||
status=$?
|
||||
if (( status == STATUS_INACTIVE )); then
|
||||
return 0
|
||||
else
|
||||
cleanup_uid_locked "$1"
|
||||
fi
|
||||
fi
|
||||
/usr/bin/rm -f -- "$pending_state" || true
|
||||
return 1
|
||||
}
|
||||
|
||||
enable_locked() {
|
||||
local uid="$1" minutes="$2" old_timer="" timer token expires pending_state now
|
||||
resolve_account "$uid" || return 1
|
||||
valid_minutes "$minutes" || return 1
|
||||
prepare_root_state || return 1
|
||||
verify_boot_cleanup || {
|
||||
echo "omarchy-sudo-passwordless: package-owned boot cleanup or transaction hook is missing or unsafe" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
old_timer=$(read_state_timer "$uid" 2>/dev/null || true)
|
||||
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid)
|
||||
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
timer="omarchy-nopasswd-expire-${uid}-${token}"
|
||||
now=$(current_epoch) || return 1
|
||||
expires=$((10#$now + 10#$minutes * 60))
|
||||
|
||||
# State and a verified timer exist before the policy becomes reachable. If
|
||||
# publication fails, cleanup removes both. During an update the old timer is
|
||||
# deliberately kept until the replacement is active, so failure shortens the
|
||||
# grant rather than extending it.
|
||||
pending_state=$(prepare_state_file "$uid" "$ACCOUNT_NAME" "$expires" "$timer") || return 1
|
||||
if ! start_expiry_timer "$uid" "$expires" "$timer"; then
|
||||
abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state"
|
||||
return 1
|
||||
fi
|
||||
if ! /usr/bin/mv -fT -- "$pending_state" "$(state_file "$uid")"; then
|
||||
abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state"
|
||||
return 1
|
||||
fi
|
||||
if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME" "$expires"; then
|
||||
abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state"
|
||||
return 1
|
||||
fi
|
||||
now=$(current_epoch) || {
|
||||
abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state"
|
||||
return 1
|
||||
}
|
||||
if ((10#$now >= 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer" || ! verify_boot_cleanup; then
|
||||
# The timer may have expired or failed between its initial verification and
|
||||
# rule publication. Revoke synchronously so a suspended or heavily loaded
|
||||
# machine cannot turn a short grant into a reboot-long one.
|
||||
abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state"
|
||||
return 1
|
||||
fi
|
||||
[[ -z $old_timer || $old_timer == "$timer" ]] || stop_timer "$old_timer"
|
||||
}
|
||||
|
||||
status_locked() {
|
||||
local uid="$1" record state_name expires timer now remainder
|
||||
resolve_account "$uid" || return 2
|
||||
if [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]]; then
|
||||
local status now
|
||||
resolve_account "$1" || return 2
|
||||
if read_grant "$1"; then
|
||||
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2
|
||||
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
|
||||
if [[ $now < $GRANT_DEADLINE ]]; then
|
||||
return 0
|
||||
fi
|
||||
cleanup_uid_locked "$1" || return 2
|
||||
return "$STATUS_INACTIVE"
|
||||
fi
|
||||
record=$(read_state_record "$uid") || {
|
||||
revoke_inactive_grant "$uid"
|
||||
return $?
|
||||
}
|
||||
state_name=${record%%$'\t'*}
|
||||
remainder=${record#*$'\t'}
|
||||
expires=${remainder%%$'\t'*}
|
||||
timer=${record##*$'\t'}
|
||||
[[ $state_name == "$ACCOUNT_NAME" ]] || {
|
||||
revoke_inactive_grant "$uid"
|
||||
return $?
|
||||
}
|
||||
now=$(current_epoch) || {
|
||||
revoke_inactive_grant "$uid"
|
||||
return $?
|
||||
}
|
||||
((10#$now < 10#$expires)) || {
|
||||
revoke_inactive_grant "$uid"
|
||||
return $?
|
||||
}
|
||||
/usr/bin/systemctl is-active --quiet "${timer}.timer" || {
|
||||
revoke_inactive_grant "$uid"
|
||||
return $?
|
||||
}
|
||||
}
|
||||
|
||||
revoke_inactive_grant() {
|
||||
if cleanup_uid_locked "$1"; then
|
||||
return "$STATUS_INACTIVE"
|
||||
else
|
||||
echo "Could not revoke invalid or expired passwordless sudo. Administrator cleanup is required." >&2
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
expire_locked() {
|
||||
local uid=$1 timer=${2:-} current_timer status
|
||||
if [[ -n $timer ]]; then
|
||||
current_timer=$(read_state_timer "$uid" 2>/dev/null || true)
|
||||
# A delayed predecessor must not revoke a newer, independently timed grant.
|
||||
[[ -z $current_timer || $current_timer == "$timer" ]] || return 0
|
||||
cleanup_uid_locked "$uid"
|
||||
elif status_locked "$uid"; then
|
||||
# Compatibility with already scheduled UID-only jobs: enforce the current
|
||||
# grant's expiry instead of letting an old timer shorten its replacement.
|
||||
return 0
|
||||
else
|
||||
status=$?
|
||||
(( status == STATUS_INACTIVE ))
|
||||
return "$status"
|
||||
fi
|
||||
}
|
||||
|
||||
finish_enable() {
|
||||
local status=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if (( status != 0 )); then
|
||||
if cleanup_uid_locked "$uid"; then
|
||||
[[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true
|
||||
else
|
||||
echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2
|
||||
fi
|
||||
fi
|
||||
[[ -z $pending ]] || /usr/bin/rm -f -- "$pending"
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
enable_locked() (
|
||||
local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status
|
||||
resolve_account "$uid" && valid_minutes "$minutes" || return 1
|
||||
verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1
|
||||
file=$(rule_file "$uid")
|
||||
if read_grant "$uid"; then
|
||||
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1
|
||||
else
|
||||
status=$?
|
||||
(( status == STATUS_INACTIVE )) || return 1
|
||||
fi
|
||||
trap finish_enable EXIT
|
||||
omarchy_security_install_signal_exit_traps
|
||||
now=$(/usr/bin/date +%s) || return 1
|
||||
expires=$((now + 10#$minutes * 60))
|
||||
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
|
||||
pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1
|
||||
/usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1
|
||||
/usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1
|
||||
/usr/sbin/visudo -cf "$pending" >/dev/null || return 1
|
||||
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid) || return 1
|
||||
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
timer="omarchy-nopasswd-expire-$uid-$token"
|
||||
/usr/bin/systemd-run --quiet --collect --on-calendar="@$expires" \
|
||||
--timer-property=AccuracySec=1s --unit="$timer" \
|
||||
-- "$INSTALLED_SELF" __expire "$uid" || return 1
|
||||
/usr/bin/systemctl is-active --quiet "$timer.timer" || return 1
|
||||
# The temporary filename contains a dot, so sudo ignores it. Rename within
|
||||
# sudoers.d publishes the complete validated policy in one operation.
|
||||
/usr/bin/mv -fT -- "$pending" "$file" || return 1
|
||||
pending=""
|
||||
now=$(/usr/bin/date +%s) || return 1
|
||||
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
|
||||
)
|
||||
|
||||
root_dispatch() {
|
||||
local action="$1"
|
||||
shift
|
||||
@@ -491,9 +332,16 @@ root_dispatch() {
|
||||
;;
|
||||
__expire)
|
||||
(($# == 1 || $# == 2)) && ((EUID == 0)) && valid_uid "$1" || return 1
|
||||
[[ -z ${2:-} ]] || valid_timer_for_uid "$1" "$2" || return 1
|
||||
[[ -z ${2:-} || $2 =~ ^omarchy-nopasswd-expire-${1}-[0-9a-f]{32}$ ]] || return 1
|
||||
with_root_lock expire_locked "$@"
|
||||
;;
|
||||
__migration-complete)
|
||||
(($# == 0)) && migration_complete
|
||||
;;
|
||||
__migrate)
|
||||
(($# == 0)) && ((EUID == 0)) || return 1
|
||||
with_root_lock migrate_locked
|
||||
;;
|
||||
__cleanup-all)
|
||||
(($# == 0)) && ((EUID == 0)) || return 1
|
||||
with_root_lock cleanup_all_locked
|
||||
@@ -507,7 +355,7 @@ root_dispatch() {
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
__status|__enable|__disable|__expire|__cleanup-all|__package-removing)
|
||||
__status|__enable|__disable|__expire|__cleanup-all|__package-removing|__migrate|__migration-complete)
|
||||
action=$1
|
||||
shift
|
||||
root_dispatch "$action" "$@"
|
||||
@@ -542,7 +390,7 @@ if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then
|
||||
echo "Passwordless sudo has been DISABLED. Sudo will require a password again."
|
||||
else
|
||||
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
|
||||
echo "Passwordless sudo timer updated. It will automatically disable in ${minutes} minutes."
|
||||
echo "Passwordless sudo expiry updated. It will automatically disable in ${minutes} minutes."
|
||||
fi
|
||||
else
|
||||
status=$?
|
||||
@@ -559,7 +407,7 @@ else
|
||||
echo "Anyone or anything with access to your user account gets full root."
|
||||
echo ""
|
||||
echo "Passwordless sudo will automatically disable after ${minutes} minutes,"
|
||||
echo "including if the machine reboots before the timer fires."
|
||||
echo "including if the machine reboots before the deadline."
|
||||
echo "Run this command again to disable it early."
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
|
||||
## Grant lifecycle
|
||||
|
||||
Root state records the resolved account name, absolute expiry epoch and unique timer name. A calendar timer is armed and verified before the generated policy becomes active. The sudoers rule also embeds the same UTC deadline with `NOTAFTER`, so sudo independently rejects it after expiry even if timer cleanup is delayed. Publication rechecks the package-owned boot cleanup before and after installing policy. Policy revocation must succeed before expiry jobs are stopped; a deletion error leaves those jobs armed and reports that administrator cleanup is required.
|
||||
The sudoers rule is the only grant record: it contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed.
|
||||
|
||||
An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists.
|
||||
|
||||
Each new expiry callback carries its timer identity. A delayed predecessor cannot revoke a newer grant. Already scheduled UID-only callbacks remain compatible by checking the current grant's expiry. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; it does not run during routine non-boot tmpfiles maintenance.
|
||||
Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone.
|
||||
|
||||
Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted; generated legacy policy is removed conservatively and administrator-modified policy is preserved by the migration.
|
||||
|
||||
## Package ownership
|
||||
|
||||
@@ -20,6 +22,6 @@ The runtime marker need not survive reboot: pre-removal revokes the old grants b
|
||||
|
||||
## Validation
|
||||
|
||||
`test/shell.d/nopasswd-sudo-expiry-test.sh` covers the public interface, cold authentication, timer setup, boot cleanup, package transitions and lock contention. `test/shell.d/passwordless-grant-lifecycle-test.sh` covers publication/cleanup failures, error status, supported account syntax, predecessor callbacks and the shared package-removal lock. Supply `OMARCHY_PKGS_PATH` as either a repository root or its `pkgbuilds` directory.
|
||||
The two passwordless-sudo test suites share a private filesystem and command fixture. They cover caller validation, the public prompt boundary, atomic publication, renewal failures, expiry, old callbacks, machine migration, and the source/package lock. Supply `OMARCHY_PKGS_PATH` as either a repository root or its `pkgbuilds` directory. An optional `OMARCHY_TEST_SUDOERS` path to sudo's upstream `testsudoers` executable evaluates the generated policy before and after its deadline without root or changing host policy.
|
||||
|
||||
These tests use private filesystem fixtures and mapped privileged commands. Package archive ownership, actual install/upgrade/removal, real calendar expiry, suspend/resume and boot cleanup must also be validated in a disposable VM before claiming release readiness. Changes to the common library require integration checks on the downstream update, migration, installer, package-picker and diagnostic PRs.
|
||||
These local tests do not establish release readiness. The simplified candidate needs fresh installed-package, suspend/resume, boot-cleanup, and package-removal validation in a disposable VM. The shared security library and its interface are unchanged for downstream PRs.
|
||||
@@ -1,6 +1,6 @@
|
||||
echo "Remove legacy temporary passwordless sudo grants"
|
||||
|
||||
# This removes current numeric grants, exact legacy username grants, corrupt or
|
||||
# orphaned state, and their known timers. Administrator-authored sudoers files
|
||||
# whose contents do not exactly match Omarchy's generated grammar are preserved.
|
||||
sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all
|
||||
# Migration queues are per-user; the privileged repair is once per machine.
|
||||
if ! /usr/bin/omarchy-sudo-passwordless __migration-complete; then
|
||||
sudo /usr/bin/omarchy-sudo-passwordless __migrate
|
||||
fi
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exercise complete production functions with private paths and harmless
|
||||
# command stand-ins. Never install sudo policy or start a host timer.
|
||||
test_tmp=$(mktemp -d)
|
||||
children=()
|
||||
cleanup_grant_fixture() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
if (( ${#children[@]} )); then
|
||||
kill "${children[@]}" 2>/dev/null || true
|
||||
wait "${children[@]}" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$test_tmp"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup_grant_fixture EXIT
|
||||
export TEST_GRANT_ROOT=$test_tmp
|
||||
mkdir -p "$test_tmp/bin" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/var/lib" "$test_tmp/hooks"
|
||||
cat >"$test_tmp/bin/mock" <<'STUB'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
name=${0##*/}
|
||||
printf '%s %s\n' "$name" "$*" >>"$TEST_GRANT_ROOT/commands"
|
||||
case "$name" in
|
||||
stat)
|
||||
path=${@: -1}
|
||||
owner=0
|
||||
mode=$(/usr/bin/stat -Lc '%a' -- "$path")
|
||||
[[ $path != /tmp ]] || mode=755
|
||||
[[ $path != "${TEST_BAD_PATH:-}" ]] || owner=1000
|
||||
case $2 in
|
||||
'%u') echo "$owner" ;;
|
||||
'%a') echo "$mode" ;;
|
||||
'%u %a') echo "$owner $mode" ;;
|
||||
*) exec /usr/bin/stat "$@" ;;
|
||||
esac
|
||||
;;
|
||||
chown) exit 0 ;;
|
||||
install)
|
||||
args=()
|
||||
while (($#)); do
|
||||
case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac
|
||||
done
|
||||
exec /usr/bin/install "${args[@]}"
|
||||
;;
|
||||
rm)
|
||||
for path in "$@"; do
|
||||
if [[ ${TEST_DELETE_FAIL:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/99-omarchy-nopasswd-1000" ]]; then exit 1; fi
|
||||
done
|
||||
exec /usr/bin/rm "$@"
|
||||
;;
|
||||
mv)
|
||||
[[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1
|
||||
/usr/bin/mv "$@"
|
||||
[[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing"
|
||||
;;
|
||||
systemd-run)
|
||||
[[ ${TEST_TIMER_FAIL:-0} != 1 ]] || exit 1
|
||||
if [[ ${TEST_CANCEL_ENABLE:-0} == 1 ]]; then kill -TERM "$PPID"; fi
|
||||
;;
|
||||
systemctl)
|
||||
[[ $1 != "is-active" || ${TEST_INACTIVE_TIMER:-0} != 1 ]]
|
||||
;;
|
||||
date)
|
||||
if [[ ${TEST_EXPIRED:-0} == 1 && $* == '-u +%Y%m%d%H%M%SZ' ]]; then echo 99991231235959Z; else /usr/bin/date "$@"; fi
|
||||
;;
|
||||
getent) printf '%s:x:1000:1000:Test:/nonexistent:/bin/bash\n' "${TEST_ACCOUNT:-audituser}" ;;
|
||||
sudo)
|
||||
if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi
|
||||
if [[ ${1:-} == -k ]]; then exit 0; fi
|
||||
if [[ ${1:-} == -N ]]; then shift; fi
|
||||
if [[ ${1:-} == -- ]]; then shift; fi
|
||||
if [[ ${TEST_MIGRATION:-0} == 1 ]]; then
|
||||
[[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1
|
||||
TEST_EUID=0 /usr/bin/bash -p "$@"
|
||||
else
|
||||
[[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}"
|
||||
fi
|
||||
;;
|
||||
gum) exit 1 ;;
|
||||
*) exit 99 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$test_tmp/bin/mock"
|
||||
for name in stat chown install rm mv systemd-run systemctl date getent sudo gum; do
|
||||
ln -s mock "$test_tmp/bin/$name"
|
||||
done
|
||||
|
||||
python3 - "$ROOT" "$test_tmp" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
root, temp = map(Path, sys.argv[1:])
|
||||
for name in ('omarchy-sudo-passwordless', 'omarchy-security-functions'):
|
||||
text = (root/'bin'/name).read_text()
|
||||
for path in ('/etc/', '/var/lib', '/run/', '/usr/share/libalpm/hooks'):
|
||||
target = str(temp/'hooks') if path == '/usr/share/libalpm/hooks' else str(temp) + path
|
||||
text = text.replace(path, target)
|
||||
text = text.replace('((EUID == 0))', '((${TEST_EUID:-1} == 0))')
|
||||
for command in ('stat', 'chown', 'install', 'rm', 'mv', 'systemd-run', 'systemctl', 'date', 'getent', 'sudo', 'gum'):
|
||||
text = text.replace('/usr/bin/' + command, str(temp/'bin'/command))
|
||||
(temp/name).write_text(text)
|
||||
(temp/name).chmod(0o755)
|
||||
PY
|
||||
library="$test_tmp/functions.sh"
|
||||
{
|
||||
printf 'source %q\n' "$test_tmp/omarchy-security-functions"
|
||||
awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$test_tmp/omarchy-sudo-passwordless"
|
||||
} >"$library"
|
||||
cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/"
|
||||
sed "s|/etc/|$test_tmp/etc/|g" "$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
|
||||
: >"$test_tmp/commands"
|
||||
|
||||
# New subshell per case prevents one test's overrides and readonly constants
|
||||
# from affecting the next. External commands log enough to verify ordering.
|
||||
assert_status() {
|
||||
local expected=$1 actual=0
|
||||
shift
|
||||
"$@" || actual=$?
|
||||
(( actual == expected )) || fail "expected status $expected, got $actual from $*"
|
||||
}
|
||||
reset_grant() {
|
||||
rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
|
||||
: >"$test_tmp/commands"
|
||||
}
|
||||
@@ -1,458 +1,163 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh"
|
||||
|
||||
command_path="$ROOT/bin/omarchy-sudo-passwordless"
|
||||
security_library_path="$ROOT/bin/omarchy-security-functions"
|
||||
tmpfiles_path="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
|
||||
migration_path="$ROOT/migrations/1788163635.sh"
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
function_prefix() {
|
||||
printf 'source %q\n' "$security_library_path"
|
||||
awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$command_path"
|
||||
}
|
||||
|
||||
# Exercise the validation code itself. Leading zeroes remain numeric, but zero,
|
||||
# negatives, oversized grants, and shell syntax are rejected.
|
||||
(
|
||||
source <(function_prefix)
|
||||
for minutes in 1 15 1440 00015; do
|
||||
valid_minutes "$minutes" || fail "passwordless sudo accepts bounded duration $minutes"
|
||||
done
|
||||
for minutes in 0 1441 -1 1m '1;id' '' 18446744073709551617; do
|
||||
! valid_minutes "$minutes" || fail "passwordless sudo rejects invalid duration '$minutes'"
|
||||
done
|
||||
source "$library"
|
||||
for minutes in 1 15 1440 00015; do valid_minutes "$minutes" || exit 1; done
|
||||
for minutes in 0 1441 -1 1m '' 18446744073709551617; do ! valid_minutes "$minutes" || exit 1; done
|
||||
for name in audituser 'buildbot$'; do valid_account_name "$name" || exit 1; done
|
||||
for name in 'a$b' '$' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do ! valid_account_name "$name" || exit 1; done
|
||||
! valid_uid 18446744073709551617
|
||||
)
|
||||
pass "passwordless sudo validates a bounded positive duration"
|
||||
pass "duration and account validation retains bounded inputs and trailing-dollar usernames"
|
||||
|
||||
# The public entry point uses the kernel-backed numeric identity; $USER is
|
||||
# never interpolated into a privileged filename or sudoers rule.
|
||||
grep -F 'uid=$(/usr/bin/id -u)' "$command_path" >/dev/null ||
|
||||
fail "passwordless sudo derives the caller from id -u"
|
||||
! grep -Eq '\$\{?USER\}?' "$command_path" ||
|
||||
fail "passwordless sudo does not trust USER for privileged policy"
|
||||
grep -F '[[ ${SUDO_UID:-} =~ ^[0-9]+$ ]]' "$command_path" >/dev/null ||
|
||||
fail "passwordless sudo validates sudo provenance"
|
||||
pass "passwordless sudo derives and validates trusted account identity"
|
||||
(
|
||||
source "$library"
|
||||
assert_status 2 root_dispatch __status 1000
|
||||
assert_status 2 env TEST_EUID=0 SUDO_UID=1001 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000
|
||||
assert_status 3 env TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000
|
||||
)
|
||||
pass "internal actions reject missing root and mismatched sudo identity"
|
||||
|
||||
# Status inspection and the confirmation UI are mixed-trust: a normal sudo
|
||||
# status call would publish a timestamp that a hostile prompt helper could use
|
||||
# even when the user declines the grant. Exercise the public flow with a sudo
|
||||
# model that publishes a token only when -N is missing.
|
||||
grep -Fxq '#!/bin/bash -p' "$command_path" ||
|
||||
fail "passwordless sudo no longer suppresses Bash startup injection"
|
||||
|
||||
public_sudo_stub="$test_tmp/public-sudo"
|
||||
public_gum_stub="$test_tmp/public-gum"
|
||||
public_token="$test_tmp/public-token"
|
||||
public_exploit="$test_tmp/public-exploit"
|
||||
cat >"$public_sudo_stub" <<'STUB'
|
||||
#!/bin/bash
|
||||
if [[ ${1:-} == -h ]]; then
|
||||
echo 'usage: sudo [-ABbEHkNnPS] command'
|
||||
exit 0
|
||||
fi
|
||||
if [[ ${1:-} == -k ]]; then
|
||||
rm -f -- "$TEST_PUBLIC_TOKEN"
|
||||
exit 0
|
||||
fi
|
||||
no_update=0
|
||||
if [[ ${1:-} == -N ]]; then no_update=1; shift; fi
|
||||
[[ ${1:-} != -- ]] || shift
|
||||
((no_update)) || : >"$TEST_PUBLIC_TOKEN"
|
||||
case "${2:-}" in
|
||||
__status) exit "${TEST_PUBLIC_STATUS:-3}" ;;
|
||||
__enable|__disable) exit 0 ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
STUB
|
||||
cat >"$public_gum_stub" <<'STUB'
|
||||
#!/bin/bash
|
||||
[[ -z ${TEST_PUBLIC_GUM_LOG:-} ]] || : >"$TEST_PUBLIC_GUM_LOG"
|
||||
[[ ! -e $TEST_PUBLIC_TOKEN ]] || : >"$TEST_PUBLIC_EXPLOIT"
|
||||
exit 1
|
||||
STUB
|
||||
chmod 0755 "$public_sudo_stub" "$public_gum_stub"
|
||||
public_flow="$test_tmp/passwordless-public-flow"
|
||||
/usr/bin/sed "s#/usr/bin/sudo#$public_sudo_stub#g" "$security_library_path" >"$test_tmp/omarchy-security-functions"
|
||||
/usr/bin/sed \
|
||||
-e "s#/usr/bin/sudo#$public_sudo_stub#g" \
|
||||
-e "s#/usr/bin/gum#$public_gum_stub#g" \
|
||||
"$command_path" >"$public_flow"
|
||||
chmod 0755 "$public_flow"
|
||||
TEST_PUBLIC_TOKEN="$public_token" TEST_PUBLIC_EXPLOIT="$public_exploit" \
|
||||
/usr/bin/bash -p "$public_flow" 15 >/dev/null
|
||||
[[ ! -e $public_token && ! -e $public_exploit ]] ||
|
||||
fail "passwordless confirmation inherited a reusable status credential"
|
||||
for status in 1 2; do
|
||||
if TEST_PUBLIC_TOKEN="$public_token" TEST_PUBLIC_EXPLOIT="$public_exploit" \
|
||||
TEST_PUBLIC_STATUS="$status" TEST_PUBLIC_GUM_LOG="$test_tmp/unsafe-status-confirmation" \
|
||||
/usr/bin/bash -p "$public_flow" 15 >"$test_tmp/status-error.output" 2>&1; then
|
||||
fail "passwordless sudo treats status/authorization failure $status as inactive"
|
||||
for status in 1 2 3; do
|
||||
: >"$test_tmp/commands"
|
||||
result=0
|
||||
TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 || result=$?
|
||||
if (( status == 3 )); then
|
||||
(( result == 0 )) && grep -q '^gum confirm ' "$test_tmp/commands" || fail "inactive status must allow confirmation"
|
||||
else
|
||||
(( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement"
|
||||
fi
|
||||
[[ ! -e $test_tmp/unsafe-status-confirmation ]] || fail "failed status inspection opens the enable prompt"
|
||||
grep -q 'Could not safely inspect passwordless sudo' "$test_tmp/status-error.output" ||
|
||||
fail "failed status inspection lacks recovery guidance"
|
||||
grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization"
|
||||
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization"
|
||||
done
|
||||
pass "public status distinguishes inactive from errors and revokes authorization on exit"
|
||||
|
||||
startup_env="$test_tmp/passwordless-bash-env"
|
||||
startup_marker="$test_tmp/passwordless-bash-env-ran"
|
||||
cat >"$startup_env" <<'STUB'
|
||||
: >"$TEST_STARTUP_MARKER"
|
||||
set -o privileged
|
||||
unset BASH_ENV
|
||||
STUB
|
||||
if BASH_ENV="$startup_env" TEST_STARTUP_MARKER="$startup_marker" \
|
||||
/usr/bin/bash "$public_flow" -p >/dev/null 2>&1; then
|
||||
fail "passwordless sudo accepted an unsafe interpreter with a decoy -p"
|
||||
printf ': >"$TEST_STARTUP_MARKER"\nset -o privileged\nunset BASH_ENV\n' >"$test_tmp/startup"
|
||||
: >"$test_tmp/commands"
|
||||
if TEST_STARTUP_MARKER="$test_tmp/startup-ran" BASH_ENV="$test_tmp/startup" bash "$test_tmp/omarchy-sudo-passwordless" -p >/dev/null 2>&1; then
|
||||
fail "ordinary Bash with a decoy -p was accepted"
|
||||
fi
|
||||
[[ -e $startup_marker && ! -e $public_token && ! -e $public_exploit ]] ||
|
||||
fail "unsafe passwordless startup reached its sudo workflow"
|
||||
pass "passwordless confirmation uses a cold command-scoped credential boundary"
|
||||
[[ -f $test_tmp/startup-ran && ! -s $test_tmp/commands ]] || fail "startup rejection must precede sudo"
|
||||
pass "startup validation rejects ordinary Bash before authorization"
|
||||
|
||||
# Source a path-rewritten copy so the real cleanup implementation can be
|
||||
# exercised without touching /etc. Exact generated numeric rules are removed
|
||||
# even after account deletion or a crash before state publication. Anything an
|
||||
# administrator changed, and every symlink, is preserved.
|
||||
fake_sudoers="$test_tmp/sudoers.d"
|
||||
mkdir "$fake_sudoers"
|
||||
rewritten="$test_tmp/passwordless-lib.sh"
|
||||
function_prefix | sed "s#/etc/sudoers.d#$fake_sudoers#g" >"$rewritten"
|
||||
(
|
||||
source "$rewritten"
|
||||
printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$fake_sudoers/99-omarchy-nopasswd-424242"
|
||||
printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/pacman\n' >"$fake_sudoers/99-omarchy-nopasswd-424243"
|
||||
ln -s "$fake_sudoers/99-omarchy-nopasswd-424243" "$fake_sudoers/99-omarchy-nopasswd-424244"
|
||||
remove_known_legacy_rules
|
||||
)
|
||||
[[ ! -e $fake_sudoers/99-omarchy-nopasswd-424242 ]] ||
|
||||
fail "boot cleanup removes a state-less numeric orphan"
|
||||
[[ -f $fake_sudoers/99-omarchy-nopasswd-424243 ]] ||
|
||||
fail "boot cleanup preserves administrator-authored policy"
|
||||
[[ -L $fake_sudoers/99-omarchy-nopasswd-424244 ]] ||
|
||||
fail "boot cleanup refuses sudoers symlinks"
|
||||
pass "boot cleanup removes crash/deleted-account orphans conservatively"
|
||||
|
||||
# A boot gate must not report success when deletion itself fails. Exercise the
|
||||
# real cleanup and post-cleanup verification with a deterministic failing rm.
|
||||
rm_failure_dir="$test_tmp/rm-failure-sudoers"
|
||||
mkdir "$rm_failure_dir"
|
||||
printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$rm_failure_dir/99-omarchy-nopasswd-424245"
|
||||
failing_rm="$test_tmp/failing-rm"
|
||||
cat >"$failing_rm" <<'FAILING_RM'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
FAILING_RM
|
||||
chmod +x "$failing_rm"
|
||||
rm_failure_lib="$test_tmp/rm-failure-lib.sh"
|
||||
function_prefix |
|
||||
sed -e "s#/etc/sudoers.d#$rm_failure_dir#g" \
|
||||
-e "s#/var/lib/omarchy/sudo-passwordless#$test_tmp/empty-state#g" \
|
||||
-e "s#/usr/bin/rm#$failing_rm#g" >"$rm_failure_lib"
|
||||
mkdir "$test_tmp/empty-state"
|
||||
(
|
||||
source "$rm_failure_lib"
|
||||
! cleanup_all_locked
|
||||
) || fail "boot cleanup fails when an Omarchy rule cannot be removed"
|
||||
[[ -f $rm_failure_dir/99-omarchy-nopasswd-424245 ]] ||
|
||||
fail "rm-failure fixture remains available for verification"
|
||||
pass "boot cleanup fails closed when policy deletion fails"
|
||||
|
||||
# Reproduce the migration's real sudo provenance: sudo sets SUDO_UID. Rewrite
|
||||
# only the read-only EUID probe so this unprivileged test can exercise the root
|
||||
# dispatcher, then assert that cleanup (which can only revoke privilege) runs.
|
||||
dispatch_lib="$test_tmp/dispatch-lib.sh"
|
||||
function_prefix | sed 's/((EUID == 0))/((TEST_EUID == 0))/g' >"$dispatch_lib"
|
||||
(
|
||||
source "$dispatch_lib"
|
||||
called=""
|
||||
cleanup_all_locked() { called=cleanup; }
|
||||
with_root_lock() { "$@"; }
|
||||
TEST_EUID=0 SUDO_UID=1000 root_dispatch __cleanup-all
|
||||
[[ $called == cleanup ]]
|
||||
) || fail "migration cleanup dispatch accepts authenticated sudo provenance"
|
||||
pass "migration can invoke fail-closed cleanup through sudo"
|
||||
|
||||
# A grant cannot be published until the static unit is verified/enabled, and a
|
||||
# timer setup failure removes its pending state without calling publish_rule.
|
||||
transaction_dir="$test_tmp/transaction"
|
||||
mkdir "$transaction_dir"
|
||||
transaction_lib="$test_tmp/transaction-lib.sh"
|
||||
function_prefix | sed "s#/var/lib/omarchy/sudo-passwordless#$transaction_dir#g" >"$transaction_lib"
|
||||
(
|
||||
source "$transaction_lib"
|
||||
ACCOUNT_NAME=audituser
|
||||
resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; }
|
||||
prepare_root_state() { :; }
|
||||
verify_boot_cleanup() { return 1; }
|
||||
publish_rule() { return 99; }
|
||||
! enable_locked 1000 15
|
||||
)
|
||||
(
|
||||
source "$transaction_lib"
|
||||
ACCOUNT_NAME=audituser
|
||||
resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; }
|
||||
prepare_root_state() { :; }
|
||||
verify_boot_cleanup() { return 0; }
|
||||
read_state_timer() { return 1; }
|
||||
prepare_state_file() { local pending="$transaction_dir/pending"; : >"$pending"; printf %s "$pending"; }
|
||||
start_expiry_timer() { return 1; }
|
||||
publish_rule() { printf published >"$transaction_dir/published"; }
|
||||
cleanup_uid_locked() { : >"$transaction_dir/failed-timer-cleanup"; }
|
||||
! enable_locked 1000 15
|
||||
[[ ! -e $transaction_dir/pending && ! -e $transaction_dir/published &&
|
||||
-e $transaction_dir/failed-timer-cleanup ]]
|
||||
) || fail "passwordless sudo fails closed on prerequisite/timer failure"
|
||||
pass "passwordless sudo publishes no rule after partial setup failure"
|
||||
|
||||
# Erik's predecessor fix revoked an already-active grant when an extension
|
||||
# could not arm its replacement timer. Keep that fail-closed property while
|
||||
# the new transaction deliberately leaves the old timer armed until the new
|
||||
# one is verified.
|
||||
replacement_state="$transaction_dir/1000.state"
|
||||
replacement_rule="$transaction_dir/1000.rule"
|
||||
replacement_stopped="$transaction_dir/old-timer-stopped"
|
||||
old_timer=omarchy-nopasswd-expire-1000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
printf 'UID=1000\nUSER=audituser\nEXPIRES=2000000000\nTIMER=%s\n' "$old_timer" >"$replacement_state"
|
||||
printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$replacement_rule"
|
||||
(
|
||||
source "$transaction_lib"
|
||||
resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; }
|
||||
prepare_root_state() { :; }
|
||||
verify_boot_cleanup() { return 0; }
|
||||
state_file() { printf '%s' "$replacement_state"; }
|
||||
rule_file() { printf '%s' "$replacement_rule"; }
|
||||
prepare_state_file() { local pending="$transaction_dir/replacement-pending"; : >"$pending"; printf %s "$pending"; }
|
||||
start_expiry_timer() { return 1; }
|
||||
stop_timer() { [[ $1 == "$old_timer" ]] && : >"$replacement_stopped"; }
|
||||
! enable_locked 1000 30
|
||||
[[ ! -e $replacement_state && ! -e $replacement_rule && -e $replacement_stopped ]]
|
||||
) || fail "passwordless sudo leaves an existing grant live after replacement timer failure"
|
||||
pass "replacement timer failure revokes the existing grant"
|
||||
|
||||
# Expiry is a wall-clock promise, so the transient timer must carry the exact
|
||||
# absolute epoch recorded in root state. A monotonic-only --on-active timer
|
||||
# pauses during suspend and can otherwise extend a short grant by hours.
|
||||
timer_args="$test_tmp/timer-args"
|
||||
calendar_systemd_run="$test_tmp/calendar-systemd-run"
|
||||
calendar_systemctl="$test_tmp/calendar-systemctl"
|
||||
cat >"$calendar_systemd_run" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$@" >"$TEST_TIMER_ARGS"
|
||||
STUB
|
||||
cat >"$calendar_systemctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
STUB
|
||||
chmod 0755 "$calendar_systemd_run" "$calendar_systemctl"
|
||||
calendar_lib="$test_tmp/calendar-lib.sh"
|
||||
function_prefix |
|
||||
sed -e "s#/usr/bin/systemd-run#$calendar_systemd_run#g" \
|
||||
-e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$calendar_lib"
|
||||
(
|
||||
source "$calendar_lib"
|
||||
TEST_TIMER_ARGS="$timer_args" start_expiry_timer 1000 2000000000 \
|
||||
omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef
|
||||
) || fail "passwordless sudo cannot arm its absolute expiry timer"
|
||||
grep -Fx -- '--on-calendar=@2000000000' "$timer_args" >/dev/null ||
|
||||
fail "passwordless sudo timer does not advance across suspend"
|
||||
pass "passwordless sudo arms the recorded absolute wall-clock expiry"
|
||||
|
||||
# A resumed machine can briefly observe the timer as active before systemd
|
||||
# dispatches its overdue service. Status must independently enforce EXPIRES and
|
||||
# synchronously remove policy instead of trusting timer activity alone.
|
||||
expired_state="$test_tmp/expired-state"
|
||||
expired_sudoers="$test_tmp/expired-sudoers"
|
||||
mkdir "$expired_state" "$expired_sudoers"
|
||||
expired_timer=omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef
|
||||
printf 'UID=1000\nUSER=audituser\nEXPIRES=1\nTIMER=%s\n' "$expired_timer" >"$expired_state/1000.state"
|
||||
printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$expired_sudoers/99-omarchy-nopasswd-1000"
|
||||
expired_lib="$test_tmp/expired-lib.sh"
|
||||
function_prefix |
|
||||
sed -e "s#/var/lib/omarchy/sudo-passwordless#$expired_state#g" \
|
||||
-e "s#/etc/sudoers.d#$expired_sudoers#g" \
|
||||
-e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$expired_lib"
|
||||
(
|
||||
source "$expired_lib"
|
||||
resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; }
|
||||
! status_locked 1000
|
||||
) || fail "passwordless sudo accepts expired root state while its timer is active"
|
||||
[[ ! -e $expired_state/1000.state && ! -e $expired_sudoers/99-omarchy-nopasswd-1000 ]] ||
|
||||
fail "passwordless sudo does not synchronously revoke expired state"
|
||||
pass "passwordless sudo enforces wall-clock expiry independently of timer dispatch"
|
||||
|
||||
# If the transient timer fires between its first active check and publication,
|
||||
# the just-created rule must be synchronously revoked instead of surviving to
|
||||
# reboot. Model that narrow transition with the real enable transaction.
|
||||
inactive_systemctl="$test_tmp/inactive-systemctl"
|
||||
cat >"$inactive_systemctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
exit 1
|
||||
STUB
|
||||
chmod 0755 "$inactive_systemctl"
|
||||
post_publish_lib="$test_tmp/post-publish-lib.sh"
|
||||
sed "s#/usr/bin/systemctl#$inactive_systemctl#g" "$transaction_lib" >"$post_publish_lib"
|
||||
(
|
||||
source "$post_publish_lib"
|
||||
resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; }
|
||||
prepare_root_state() { :; }
|
||||
verify_boot_cleanup() { return 0; }
|
||||
read_state_timer() { return 1; }
|
||||
prepare_state_file() { local pending="$transaction_dir/pending-after-arm"; : >"$pending"; printf %s "$pending"; }
|
||||
start_expiry_timer() { return 0; }
|
||||
publish_rule() { : >"$transaction_dir/published-after-arm"; }
|
||||
cleanup_uid_locked() { rm -f "$transaction_dir/published-after-arm"; : >"$transaction_dir/revoked-after-arm"; }
|
||||
! enable_locked 1000 15
|
||||
[[ ! -e $transaction_dir/published-after-arm && -e $transaction_dir/revoked-after-arm ]]
|
||||
) || fail "passwordless sudo leaves a grant when its armed timer expires before publication completes"
|
||||
pass "timer expiry during publication revokes the grant synchronously"
|
||||
|
||||
# Follow the maintainer's package-owned tmpfiles design: one boot-only rule
|
||||
# owns this filename namespace. A routine --remove leaves live grants alone;
|
||||
# early boot removes them before a user can log in. The migration only revokes
|
||||
# legacy runtime state and never writes static policy into /usr.
|
||||
mapfile -t tmpfiles_rules < <(/usr/bin/grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_path")
|
||||
(( ${#tmpfiles_rules[@]} == 1 )) || fail "passwordless sudo ships one boot cleanup rule"
|
||||
[[ ${tmpfiles_rules[0]} == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] ||
|
||||
fail "passwordless sudo boot cleanup does not own the exact generated namespace"
|
||||
fake_root="$test_tmp/tmpfiles-root"
|
||||
sudoers_dir="$fake_root/etc/sudoers.d"
|
||||
mkdir -p "$sudoers_dir"
|
||||
for name in alice buildbot-2 424242; do
|
||||
: >"$sudoers_dir/99-omarchy-nopasswd-$name"
|
||||
done
|
||||
: >"$sudoers_dir/omarchy-dns"
|
||||
/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[0]}"
|
||||
[[ -e $sudoers_dir/99-omarchy-nopasswd-alice ]] || fail "non-boot tmpfiles run shortened a live grant"
|
||||
/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[0]}"
|
||||
! find "$sudoers_dir" -name '99-omarchy-nopasswd-*' -print -quit | /usr/bin/grep -q . ||
|
||||
fail "boot cleanup left a generated passwordless grant"
|
||||
[[ -e $sudoers_dir/omarchy-dns ]] || fail "boot cleanup removed an unrelated sudoers rule"
|
||||
/usr/bin/grep -Fx 'sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all' "$migration_path" >/dev/null
|
||||
! /usr/bin/grep -q 'omarchy-sudo-passwordless-cleanup.service' "$migration_path" ||
|
||||
fail "migration retained a custom boot service instead of package-owned tmpfiles"
|
||||
pass "package-owned boot cleanup is narrow, boot-only, and migration-safe"
|
||||
|
||||
# Removing the settings package also removes the tmpfiles rule. Its package
|
||||
# lifecycle must therefore revoke the same owned namespace synchronously, while
|
||||
# preserving every unrelated sudoers file.
|
||||
pkgs_candidates=(
|
||||
"${OMARCHY_PKGS_PATH:-}"
|
||||
"$ROOT/../omarchy-pkgs"
|
||||
"$ROOT/../../omarchy-pkgs"
|
||||
"$HOME/Work/omarchy/omarchy-pkgs"
|
||||
"$HOME/Work/omacom/omarchy-pkgs"
|
||||
)
|
||||
pkgs_root=""
|
||||
for candidate in "${pkgs_candidates[@]}"; do
|
||||
if [[ -n $candidate && -d $candidate/pkgbuilds/omarchy-settings ]]; then
|
||||
pkgs_root=$candidate/pkgbuilds
|
||||
break
|
||||
elif [[ -n $candidate && -d $candidate/omarchy-settings ]]; then
|
||||
pkgs_root=$candidate
|
||||
break
|
||||
source "$library"
|
||||
enable_locked 1000 15
|
||||
read_grant 1000
|
||||
[[ $GRANT_NAME == audituser && $(stat -c '%a' "$(rule_file 1000)") == 440 ]]
|
||||
/usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null
|
||||
expiry=$(sed -n 's/^systemd-run .*--on-calendar=@\([0-9]*\).*$/\1/p' "$test_tmp/commands" | tail -1)
|
||||
[[ $GRANT_DEADLINE == "$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ)" ]]
|
||||
if [[ -n ${OMARCHY_TEST_SUDOERS:-} ]]; then
|
||||
[[ -x $OMARCHY_TEST_SUDOERS ]] || fail "OMARCHY_TEST_SUDOERS must name an executable"
|
||||
printf 'root:x:0:0:root:/root:/bin/bash\naudituser:x:1000:1000:Test:/nonexistent:/bin/bash\n' >"$test_tmp/passwd"
|
||||
printf 'root:x:0:\naudituser:x:1000:\n' >"$test_tmp/group"
|
||||
{ printf 'audituser ALL=(ALL) ALL\n'; cat "$(rule_file 1000)"; } >"$test_tmp/policy"
|
||||
for offset in -1 1; do
|
||||
when=$(/usr/bin/date -u -d "@$((expiry + offset))" +%Y%m%d%H%M%SZ)
|
||||
"$OMARCHY_TEST_SUDOERS" -p "$test_tmp/passwd" -P "$test_tmp/group" -T "$when" audituser /usr/bin/true <"$test_tmp/policy" >"$test_tmp/policy-result"
|
||||
if (( offset < 0 )); then
|
||||
! grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy requires a password before expiry"
|
||||
else
|
||||
grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy remains passwordless after expiry"
|
||||
fi
|
||||
done
|
||||
pass "native sudoers evaluation requires authentication after the generated deadline"
|
||||
fi
|
||||
done
|
||||
[[ -n $pkgs_root ]] || fail "omarchy-pkgs checkout found for passwordless package-removal coverage"
|
||||
assert_status 0 status_locked 1000
|
||||
TEST_INACTIVE_TIMER=1 assert_status 0 status_locked 1000
|
||||
[[ ! -e $test_tmp/var/lib/omarchy/sudo-passwordless ]]
|
||||
! compgen -G "$test_tmp/etc/sudoers.d/.omarchy-nopasswd.*"
|
||||
)
|
||||
pass "one complete mode-0440 sudoers rule holds the deadline with no separate grant state"
|
||||
|
||||
for package_name in omarchy-settings omarchy-settings-dev; do
|
||||
install_script="$pkgs_root/$package_name/$package_name.install"
|
||||
transformed_install="$test_tmp/$package_name.install"
|
||||
removal_root="$test_tmp/$package_name-remove"
|
||||
removal_sudoers="$removal_root/etc/sudoers.d"
|
||||
mkdir -p "$removal_sudoers" "$removal_root/run/lock" "$removal_root/etc/tmpfiles.d"
|
||||
: >"$removal_sudoers/99-omarchy-nopasswd-1000"
|
||||
: >"$removal_sudoers/99-omarchy-nopasswd-legacy-user"
|
||||
: >"$removal_sudoers/omarchy-dns"
|
||||
ln -s ../administrator/os-release "$removal_root/etc/os-release"
|
||||
package_stat="$test_tmp/package-stat"
|
||||
cat >"$package_stat" <<'STUB'
|
||||
#!/bin/bash
|
||||
if [[ $2 == '%u' ]]; then printf '0\n'; else /usr/bin/stat "$@"; fi
|
||||
STUB
|
||||
chmod +x "$package_stat"
|
||||
sed -e "s#/etc/#$removal_root/etc/#g" \
|
||||
-e "s#/run#$removal_root/run#g" \
|
||||
-e "s#/usr/bin/stat#$package_stat#g" "$install_script" >"$transformed_install"
|
||||
(
|
||||
source "$library"
|
||||
before=$(cat "$(rule_file 1000)")
|
||||
expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff
|
||||
[[ $(cat "$(rule_file 1000)") == "$before" ]]
|
||||
enable_locked 1000 30
|
||||
renewed=$(cat "$(rule_file 1000)")
|
||||
[[ $renewed != "$before" ]]
|
||||
expire_locked 1000
|
||||
[[ $(cat "$(rule_file 1000)") == "$renewed" ]]
|
||||
TEST_EXPIRED=1 expire_locked 1000
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
)
|
||||
pass "legacy and current callbacks preserve renewed grants and remove expired ones"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
enable_locked 1000 1
|
||||
assert_status 2 env TEST_EXPIRED=1 TEST_DELETE_FAIL=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000
|
||||
[[ -e $(rule_file 1000) ]]
|
||||
TEST_EXPIRED=1 assert_status 3 status_locked 1000
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
)
|
||||
pass "expired status reports cleanup failure separately from confirmed inactivity"
|
||||
|
||||
for failure in TEST_TIMER_FAIL TEST_INACTIVE_TIMER TEST_PUBLISH_FAIL TEST_POST_PUBLISH_FAIL TEST_CANCEL_ENABLE; do
|
||||
reset_grant
|
||||
expected=1
|
||||
if [[ $failure == "TEST_CANCEL_ENABLE" ]]; then expected=143; fi
|
||||
(
|
||||
source "$transformed_install"
|
||||
pre_remove
|
||||
[[ -f $removal_root/run/omarchy-sudo-passwordless-package-removing ]]
|
||||
post_remove
|
||||
) || fail "$package_name removal revokes active passwordless grants"
|
||||
! find "$removal_sudoers" -name '99-omarchy-nopasswd-*' -print -quit | grep -q . ||
|
||||
fail "$package_name removal leaves a passwordless grant behind"
|
||||
[[ -e $removal_sudoers/omarchy-dns ]] ||
|
||||
fail "$package_name removal deletes an unrelated sudoers policy"
|
||||
[[ $(readlink "$removal_root/etc/os-release") == ../administrator/os-release ]] ||
|
||||
fail "$package_name removal changes unrelated OS metadata"
|
||||
: >"$removal_sudoers/99-omarchy-nopasswd-1001"
|
||||
(
|
||||
source "$transformed_install"
|
||||
post_remove
|
||||
) || fail "$package_name removal handles administrator OS selector state"
|
||||
[[ $(readlink "$removal_root/etc/os-release") == ../administrator/os-release ]] ||
|
||||
fail "$package_name removal overwrites an administrator OS selector"
|
||||
[[ ! -e $removal_sudoers/99-omarchy-nopasswd-1001 ]] ||
|
||||
fail "$package_name removal grant cleanup depends on OS selector state"
|
||||
|
||||
(
|
||||
source "$transformed_install"
|
||||
_etc_overrides_apply() { :; }
|
||||
if post_install; then exit 1; fi
|
||||
[[ -f $removal_root/run/omarchy-sudo-passwordless-package-removing ]]
|
||||
: >"$removal_root/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
|
||||
post_install
|
||||
[[ ! -e $removal_root/run/omarchy-sudo-passwordless-package-removing ]]
|
||||
: >"$removal_sudoers/99-omarchy-nopasswd-1002"
|
||||
pre_upgrade
|
||||
[[ ! -e $removal_sudoers/99-omarchy-nopasswd-1002 ]]
|
||||
post_upgrade
|
||||
[[ ! -e $removal_root/run/omarchy-sudo-passwordless-package-removing ]]
|
||||
) || fail "$package_name restores grant availability only after boot cleanup is installed"
|
||||
source "$library"
|
||||
enable_locked 1000 15
|
||||
assert_status "$expected" env "$failure=1" TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 30
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
)
|
||||
done
|
||||
pass "settings package transitions revoke grants and preserve unrelated configuration"
|
||||
pass "timer, publication, post-publication and cancellation failures revoke renewed access"
|
||||
|
||||
# Exercise the production flock wrapper under contention. mkdir is an atomic
|
||||
# overlap detector; all workers must enter and leave the protected region.
|
||||
lock_dir="$test_tmp/lock-runtime"
|
||||
mkdir "$lock_dir"
|
||||
lock_lib="$test_tmp/lock-lib.sh"
|
||||
function_prefix |
|
||||
sed -e "s#/run/omarchy/sudo-passwordless#$lock_dir#g" \
|
||||
-e "s#/run/lock/omarchy-sudo-passwordless.lock#$test_tmp/passwordless.lock#g" \
|
||||
-e 's#/usr/bin/chown root:root "$LOCK_FILE"#/usr/bin/true#' >"$lock_lib"
|
||||
worker="$test_tmp/worker.sh"
|
||||
cat >"$worker" <<'WORKER'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
source "$LOCK_LIB"
|
||||
prepare_root_state() { :; }
|
||||
critical() {
|
||||
mkdir "$LOCK_SENTINEL"
|
||||
sleep 0.03
|
||||
rmdir "$LOCK_SENTINEL"
|
||||
printf x >>"$LOCK_RESULTS"
|
||||
}
|
||||
with_root_lock critical
|
||||
WORKER
|
||||
chmod +x "$worker"
|
||||
for _ in {1..8}; do
|
||||
LOCK_LIB="$lock_lib" LOCK_SENTINEL="$test_tmp/held" LOCK_RESULTS="$test_tmp/results" bash "$worker" &
|
||||
done
|
||||
wait
|
||||
[[ $(wc -c <"$test_tmp/results") == 8 ]] || fail "concurrent passwordless operations serialize"
|
||||
pass "passwordless sudo serializes concurrent operations"
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
TEST_POST_PUBLISH_FAIL=1 TEST_DELETE_FAIL=1 assert_status 1 enable_locked 1000 15
|
||||
[[ -e $(rule_file 1000) ]]
|
||||
! grep -q '^systemctl stop ' "$test_tmp/commands"
|
||||
)
|
||||
pass "failed policy deletion retains the timer and reports failure"
|
||||
|
||||
# Same-boot expiry calls the fixed installed cleanup command, and cleanup
|
||||
# removes policy before touching a timer so timer failures cannot extend it.
|
||||
grep -F '"$INSTALLED_SELF" __expire "$uid" "$timer"' "$command_path" >/dev/null
|
||||
cleanup_body=$(awk '/^cleanup_uid_locked\(\) \{/ { in_body=1 } in_body { print } in_body && /^}/ { exit }' "$command_path")
|
||||
rm_line=$(grep -n '/usr/bin/rm -f' <<<"$cleanup_body" | head -1 | cut -d: -f1)
|
||||
stop_line=$(grep -n 'stop_timer' <<<"$cleanup_body" | tail -1 | cut -d: -f1)
|
||||
((rm_line < stop_line)) || fail "expiry removes sudo policy before timer cleanup"
|
||||
pass "same-boot expiration is fixed-target and fail closed"
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$(rule_file 1000)"
|
||||
cp "$(rule_file 1000)" "$test_tmp/admin-rule"
|
||||
assert_status 2 status_locked 1000
|
||||
assert_status 1 enable_locked 1000 15
|
||||
assert_status 1 cleanup_uid_locked 1000
|
||||
cmp "$(rule_file 1000)" "$test_tmp/admin-rule"
|
||||
rm "$(rule_file 1000)"
|
||||
ln -s "$test_tmp/admin-rule" "$(rule_file 1000)"
|
||||
assert_status 2 status_locked 1000
|
||||
assert_status 1 cleanup_uid_locked 1000
|
||||
[[ -L $(rule_file 1000) ]]
|
||||
)
|
||||
pass "grant operations preserve administrator policies and reject symlinks"
|
||||
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
TEST_BAD_PATH="$test_tmp/etc/sudoers.d" assert_status 1 enable_locked 1000 15
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
rm "$PACKAGE_HOOK"
|
||||
assert_status 1 enable_locked 1000 15
|
||||
)
|
||||
pass "publication requires trusted paths and the packaged cleanup hook"
|
||||
|
||||
reset_grant
|
||||
cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/"
|
||||
(
|
||||
source "$library"
|
||||
TEST_ACCOUNT='buildbot$' enable_locked 1000 15
|
||||
read_grant 1000
|
||||
[[ $GRANT_NAME == 'buildbot$' ]]
|
||||
/usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null
|
||||
cleanup_uid_locked 1000
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
)
|
||||
pass "trailing-dollar accounts publish and revoke valid native policy"
|
||||
@@ -2,234 +2,135 @@
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
children=()
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
if (( ${#children[@]} )); then
|
||||
kill "${children[@]}" 2>/dev/null || true
|
||||
wait "${children[@]}" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$test_tmp"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# All policy, state, locks and command mutations stay in this private fixture.
|
||||
# Native visudo validates inert fragments; no test installs host sudo policy.
|
||||
mkdir -p "$test_tmp/bin" "$test_tmp/state" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/hooks"
|
||||
export TEST_GRANT_ROOT="$test_tmp"
|
||||
cat >"$test_tmp/bin/stat" <<'STUB'
|
||||
#!/bin/bash
|
||||
case $2 in
|
||||
'%u') printf '0\n' ;;
|
||||
'%a') if [[ -d ${@: -1} ]]; then printf '755\n'; else printf '644\n'; fi ;;
|
||||
'%u %a') if [[ -d ${@: -1} ]]; then printf '0 755\n'; else printf '0 644\n'; fi ;;
|
||||
*) exec /usr/bin/stat "$@" ;;
|
||||
esac
|
||||
STUB
|
||||
cat >"$test_tmp/bin/install" <<'STUB'
|
||||
#!/bin/bash
|
||||
args=()
|
||||
while (($#)); do
|
||||
case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac
|
||||
done
|
||||
exec /usr/bin/install "${args[@]}"
|
||||
STUB
|
||||
cat >"$test_tmp/bin/rm" <<'STUB'
|
||||
#!/bin/bash
|
||||
for path in "$@"; do
|
||||
if [[ ${TEST_FAIL_TEMP_CLEANUP:-0} == 1 && $path == "$TEST_GRANT_ROOT/state/".sudoers.* ]]; then exit 1; fi
|
||||
if [[ ${TEST_FAIL_RULE_DELETE:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/"* ]]; then exit 1; fi
|
||||
done
|
||||
exec /usr/bin/rm "$@"
|
||||
STUB
|
||||
cat >"$test_tmp/bin/systemctl" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$TEST_GRANT_ROOT/systemctl.log"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$test_tmp/bin/"*
|
||||
library="$test_tmp/grant-functions.sh"
|
||||
{
|
||||
printf 'source %q\n' "$ROOT/bin/omarchy-security-functions"
|
||||
awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$ROOT/bin/omarchy-sudo-passwordless"
|
||||
} | sed \
|
||||
-e "s|/var/lib/omarchy/sudo-passwordless|$test_tmp/state|g" \
|
||||
-e "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|g" \
|
||||
-e "s|/etc/tmpfiles.d|$test_tmp/etc/tmpfiles.d|g" \
|
||||
-e "s|/usr/share/libalpm/hooks|$test_tmp/hooks|g" \
|
||||
-e "s|/run/lock/omarchy-sudo-passwordless.lock|$test_tmp/run/lock/omarchy-sudo-passwordless.lock|g" \
|
||||
-e "s|/run/omarchy-sudo-passwordless-package-removing|$test_tmp/run/omarchy-sudo-passwordless-package-removing|g" \
|
||||
-e "s|/usr/bin/stat|$test_tmp/bin/stat|g" \
|
||||
-e "s|/usr/bin/install|$test_tmp/bin/install|g" \
|
||||
-e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \
|
||||
-e "s|/usr/bin/systemctl|$test_tmp/bin/systemctl|g" \
|
||||
-e 's|/usr/bin/chown|/usr/bin/true|g' >"$library"
|
||||
|
||||
cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/"
|
||||
|
||||
printf 'r! /etc/sudoers.d/99-omarchy-nopasswd-*\n' >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
|
||||
# The expected policy text is mapped along with its filename in this fixture.
|
||||
sed -i "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|" "$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf"
|
||||
source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
for name in 'buildbot$' audituser aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do
|
||||
valid_account_name "$name" || fail "supported account name rejected: $name"
|
||||
printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$name" >"$test_tmp/name-policy"
|
||||
/usr/sbin/visudo -cf "$test_tmp/name-policy" >/dev/null
|
||||
done
|
||||
for name in 'a$b' '$' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do
|
||||
! valid_account_name "$name" || fail "invalid account name accepted"
|
||||
done
|
||||
! valid_uid 18446744073709551617 || fail "overflowed UID accepted"
|
||||
printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)"
|
||||
printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$"
|
||||
remove_known_legacy_rules
|
||||
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot\$ ]]
|
||||
) || fail "supported account names and legacy cleanup disagree"
|
||||
pass "provisioning-compatible names validate as sudoers and clean up correctly"
|
||||
printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom"
|
||||
TEST_DELETE_FAIL=1 assert_status 1 cleanup_all_locked
|
||||
[[ -e $(rule_file 1000) ]]
|
||||
cleanup_all_locked
|
||||
[[ ! -e $(rule_file 1000) && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom ]]
|
||||
! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot*"
|
||||
)
|
||||
pass "legacy cleanup removes generated orphan rules and preserves custom policy"
|
||||
|
||||
transaction_setup() {
|
||||
resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; }
|
||||
prepare_root_state() { :; }
|
||||
start_expiry_timer() { printf '%s\n' "$3" >>"$test_tmp/armed"; }
|
||||
stop_timer() { printf '%s\n' "$1" >>"$test_tmp/stopped"; }
|
||||
# Run the actual migration queue for separate temporary homes. Sudo only calls
|
||||
# the mapped helper and can be refused without requesting host authorization.
|
||||
mkdir -p "$test_tmp/source/migrations"
|
||||
sed "s|/usr/bin/omarchy-sudo-passwordless|$test_tmp/omarchy-sudo-passwordless|g" \
|
||||
"$ROOT/migrations/1788163635.sh" >"$test_tmp/source/migrations/1788163635.sh"
|
||||
printf 'echo "later migration ran"\n' >"$test_tmp/source/migrations/1788163636.sh"
|
||||
run_migrations() {
|
||||
TEST_MIGRATION=1 OMARCHY_PATH="$test_tmp/source" OMARCHY_MIGRATION_STATE="$test_tmp/$1" \
|
||||
PATH="$test_tmp/bin:$PATH" /usr/bin/bash "$ROOT/bin/omarchy-migrate" >"$test_tmp/migrations.log" 2>&1
|
||||
}
|
||||
marker="$test_tmp/var/lib/omarchy/migrations/1788163635"
|
||||
(
|
||||
source "$library"
|
||||
printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)"
|
||||
TEST_DELETE_FAIL=1 assert_status 1 run_migrations first
|
||||
[[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]]
|
||||
run_migrations first
|
||||
[[ -f $marker && -f $test_tmp/first/1788163636.sh ]]
|
||||
enable_locked 1000 15
|
||||
cp "$(rule_file 1000)" "$test_tmp/renewed"
|
||||
: >"$test_tmp/commands"
|
||||
TEST_NO_SUDO=1 run_migrations second
|
||||
[[ -f $test_tmp/second/1788163636.sh ]]
|
||||
! grep -q '^sudo ' "$test_tmp/commands"
|
||||
cmp "$(rule_file 1000)" "$test_tmp/renewed"
|
||||
)
|
||||
pass "migration completion is machine-wide, retryable, and needs no sudo for later users"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
transaction_setup
|
||||
TEST_FAIL_TEMP_CLEANUP=1 enable_locked 1000 15 && exit 1
|
||||
[[ ! -e $(rule_file 1000) && ! -e $(state_file 1000) && -s $test_tmp/stopped ]]
|
||||
) || fail "post-publication cleanup failure did not revoke before timer cleanup"
|
||||
pass "failed temporary cleanup after publication revokes the live policy"
|
||||
|
||||
rm -f "$test_tmp/stopped"
|
||||
(
|
||||
source "$library"
|
||||
transaction_setup
|
||||
TEST_FAIL_TEMP_CLEANUP=1 TEST_FAIL_RULE_DELETE=1 enable_locked 1000 15 && exit 1
|
||||
[[ -f $(rule_file 1000) && -f $(state_file 1000) && ! -e $test_tmp/stopped ]]
|
||||
if TEST_FAIL_RULE_DELETE=1 revoke_inactive_grant 1000; then exit 1; else status=$?; fi
|
||||
(( status == 2 ))
|
||||
) || fail "failed policy revocation disarmed expiry or claimed inactive status"
|
||||
pass "failed revocation preserves expiry jobs and returns a distinct error"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
transaction_setup
|
||||
current_timer=$(read_state_timer 1000)
|
||||
expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff
|
||||
[[ -f $(rule_file 1000) ]]
|
||||
expire_locked 1000
|
||||
[[ -f $(rule_file 1000) ]]
|
||||
expire_locked 1000 "$current_timer"
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
) || fail "a predecessor timer invalidates its replacement"
|
||||
pass "old and legacy timer callbacks preserve a newer valid grant"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
transaction_setup
|
||||
start_expiry_timer() {
|
||||
: >"$REMOVAL_BLOCKER"
|
||||
return 0
|
||||
}
|
||||
enable_locked 1000 15 && exit 1
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
) || fail "publication ignores a lost package prerequisite"
|
||||
rm "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
|
||||
pass "grant publication rechecks package availability after timer setup"
|
||||
TEST_BAD_PATH="$marker" assert_status 1 migration_complete
|
||||
rm "$marker"
|
||||
ln -s "$test_tmp/renewed" "$marker"
|
||||
assert_status 1 migration_complete
|
||||
assert_status 1 migrate_locked
|
||||
[[ -L $marker ]]
|
||||
rm "$marker"
|
||||
)
|
||||
pass "migration checks marker ownership and rejects symlinks"
|
||||
|
||||
# Keep real package scripts in the contract: source and packaging share the
|
||||
# same lock and blocker, including the legacy scriptlet fallback.
|
||||
pkgs_path=${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs}
|
||||
[[ ! -d $pkgs_path/pkgbuilds ]] || pkgs_path=$pkgs_path/pkgbuilds
|
||||
package_script="$pkgs_path/omarchy-settings/omarchy-settings.install"
|
||||
[[ -f $package_script ]] || fail "package checkout is required for shared lifecycle coverage"
|
||||
sed -e "s|/etc/|$test_tmp/etc/|g" \
|
||||
-e "s|/run|$test_tmp/run|g" \
|
||||
-e "s|/usr/bin/stat|$test_tmp/bin/stat|g" \
|
||||
-e "s|/usr/bin/rm|$test_tmp/bin/rm|g" "$package_script" >"$test_tmp/package.install"
|
||||
for name in omarchy-settings omarchy-settings-dev; do
|
||||
script="$pkgs_path/$name/$name.install"
|
||||
[[ -f $script ]] || fail "set OMARCHY_PKGS_PATH to the companion package checkout"
|
||||
sed -e "s|/etc/|$test_tmp/etc/|g" -e "s|/run|$test_tmp/run|g" \
|
||||
-e "s|/usr/bin/stat|$test_tmp/bin/stat|g" -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \
|
||||
"$script" >"$test_tmp/$name.install"
|
||||
reset_grant
|
||||
(
|
||||
source "$library"
|
||||
source "$test_tmp/$name.install"
|
||||
_etc_overrides_apply() { :; }
|
||||
enable_locked 1000 15
|
||||
TEST_DELETE_FAIL=1 assert_status 1 pre_remove
|
||||
[[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]]
|
||||
assert_status 1 enable_locked 1000 15
|
||||
pre_remove && post_remove
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
post_install
|
||||
[[ ! -e $REMOVAL_BLOCKER ]]
|
||||
enable_locked 1000 15
|
||||
pre_upgrade && post_upgrade
|
||||
[[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]]
|
||||
)
|
||||
done
|
||||
pass "both settings packages revoke grants, block publication, and recover on installation"
|
||||
|
||||
worker="$test_tmp/publisher.sh"
|
||||
{
|
||||
printf '#!/bin/bash\nset -euo pipefail\nsource %q\n' "$library"
|
||||
declare -f transaction_setup
|
||||
printf 'test_tmp=%q\ntransaction_setup\n' "$test_tmp"
|
||||
cat <<'WORKER'
|
||||
publish_rule() {
|
||||
: >"$test_tmp/publisher.entered"
|
||||
while [[ ! -e $test_tmp/publisher.release ]]; do sleep 0.02; done
|
||||
printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file "$1")"
|
||||
reset_grant
|
||||
# Hold the source lock, then start package removal. A native flock on the
|
||||
# mapped file must serialize both implementations.
|
||||
cat >"$test_tmp/worker" <<'WORKER'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
source "$TEST_LIBRARY"
|
||||
critical() {
|
||||
touch "$TEST_GRANT_ROOT/entered"
|
||||
for (( attempt=0; attempt<500; attempt++ )); do
|
||||
[[ ! -e $TEST_GRANT_ROOT/release ]] || break
|
||||
sleep 0.01
|
||||
done
|
||||
[[ -e $TEST_GRANT_ROOT/release ]] || return 1
|
||||
enable_locked 1000 15
|
||||
}
|
||||
with_root_lock enable_locked 1000 15
|
||||
with_root_lock critical
|
||||
WORKER
|
||||
} >"$worker"
|
||||
bash "$worker" >"$test_tmp/publisher.output" 2>&1 &
|
||||
children+=("$!")
|
||||
for ((attempt = 0; attempt < 250; attempt++)); do
|
||||
[[ ! -e $test_tmp/publisher.entered ]] || break
|
||||
sleep 0.02
|
||||
TEST_LIBRARY="$library" /usr/bin/bash "$test_tmp/worker" >"$test_tmp/publisher.log" 2>&1 &
|
||||
publisher=$!
|
||||
children+=("$publisher")
|
||||
for (( attempt=0; attempt<200; attempt++ )); do
|
||||
[[ ! -e $test_tmp/entered ]] || break
|
||||
sleep 0.01
|
||||
done
|
||||
[[ -e $test_tmp/publisher.entered ]] || fail "grant publisher did not enter the shared lock"
|
||||
bash -euo pipefail -c 'source "$1"; : >"$2"; pre_remove; post_remove' bash \
|
||||
"$test_tmp/package.install" "$test_tmp/removal.started" >"$test_tmp/removal.output" 2>&1 &
|
||||
children+=("$!")
|
||||
for ((attempt = 0; attempt < 250; attempt++)); do
|
||||
[[ ! -e $test_tmp/removal.started ]] || break
|
||||
sleep 0.02
|
||||
done
|
||||
[[ -e $test_tmp/removal.started ]] || fail "package removal did not start"
|
||||
touch "$test_tmp/publisher.release"
|
||||
for child in "${children[@]}"; do wait "$child" || fail "shared lifecycle worker failed"; done
|
||||
[[ -f $test_tmp/entered ]] || fail "publisher failed to acquire the lock"
|
||||
/usr/bin/bash -euo pipefail -c 'source "$1"; pre_remove; post_remove' bash "$test_tmp/omarchy-settings.install" >"$test_tmp/removal.log" 2>&1 &
|
||||
removal=$!
|
||||
children+=("$removal")
|
||||
touch "$test_tmp/release"
|
||||
wait "$publisher" || fail "publisher failed" "$(cat "$test_tmp/publisher.log")"
|
||||
wait "$removal" || fail "removal failed" "$(cat "$test_tmp/removal.log")"
|
||||
children=()
|
||||
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "removal left a concurrently published grant"
|
||||
[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || fail "removal did not block later publication"
|
||||
(
|
||||
source "$library"
|
||||
transaction_setup
|
||||
! with_root_lock enable_locked 1000 15
|
||||
) || fail "a publisher can create a grant after package removal begins"
|
||||
pass "package removal shares the grant lock and blocks later publication"
|
||||
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]]
|
||||
[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]]
|
||||
pass "native lock serializes grant publication with package removal"
|
||||
|
||||
printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000"
|
||||
if TEST_FAIL_RULE_DELETE=1 bash -euo pipefail -c 'source "$1"; post_remove' bash "$test_tmp/package.install" >"$test_tmp/removal-failure.output" 2>&1; then
|
||||
fail "package removal hid a failed policy deletion"
|
||||
fi
|
||||
grep -q 'Administrator cleanup is required' "$test_tmp/removal-failure.output" || fail "package deletion failure lacks recovery guidance"
|
||||
pass "package removal reports cleanup failures instead of successful revocation"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
transaction_setup
|
||||
rm -f "$REMOVAL_BLOCKER"
|
||||
enable_locked 1000 5
|
||||
record=$(read_state_record 1000)
|
||||
expiry=${record#*$'\t'}
|
||||
expiry=${expiry%%$'\t'*}
|
||||
deadline=$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ)
|
||||
[[ $(cat "$(rule_file 1000)") == "audituser ALL=(ALL) NOTAFTER=$deadline NOPASSWD: ALL" ]]
|
||||
/usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null
|
||||
classify_generated_rule "$(rule_file 1000)"
|
||||
rm -f "$(state_file 1000)"
|
||||
remove_known_legacy_rules
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
) || fail "native sudo deadline or state-independent bounded rule cleanup is incorrect"
|
||||
pass "sudo policy contains the same deadline and bounded orphan rules are recognized"
|
||||
|
||||
(
|
||||
source "$library"
|
||||
transaction_setup
|
||||
rm -f "$REMOVAL_BLOCKER"
|
||||
enable_locked 1000 5
|
||||
if TEST_FAIL_RULE_DELETE=1 package_removing_locked; then exit 1; fi
|
||||
[[ -f $REMOVAL_BLOCKER && -f $(rule_file 1000) ]]
|
||||
! enable_locked 1000 5
|
||||
package_removing_locked
|
||||
[[ ! -e $(rule_file 1000) ]]
|
||||
rm -f "$REMOVAL_BLOCKER" "$PACKAGE_HOOK"
|
||||
! enable_locked 1000 5
|
||||
) || fail "pre-transaction revocation error or missing hook does not prevent new grants"
|
||||
pass "package hook fails closed and grants require its installed policy"
|
||||
# systemd-tmpfiles operates on an explicit disposable root, never the host.
|
||||
reset_grant
|
||||
: >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000"
|
||||
: >"$test_tmp/etc/sudoers.d/unrelated"
|
||||
rule='r! /etc/sudoers.d/99-omarchy-nopasswd-*'
|
||||
/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --inline "$rule"
|
||||
[[ -f $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "routine tmpfiles shortened a live grant"
|
||||
/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline "$rule"
|
||||
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 && -f $test_tmp/etc/sudoers.d/unrelated ]] || fail "boot cleanup boundary"
|
||||
pass "native boot cleanup removes grants while routine tmpfiles preserves them"
|
||||
Reference in new issue
Block a user