diff --git a/services/authelia.sh b/services/authelia.sh index 5afde51..f4bed01 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -244,10 +244,13 @@ install_authelia() { echo " 11) Un-protect a site (undoes option 10 for one site)" echo " 12) Export/import user data (backup accounts + 2FA before a reinstall," echo " or restore a previous export)" + echo " 13) Make sure admins always have access to every site (old and new —" + echo " safe to re-run any time)" + echo " 14) Rename an outside-access group (e.g. \"customer1\" -> \"acme-corp\")" echo " 0) Leave as-is / exit" echo "" local EXISTING_CHOICE="" - prompt_text " Choice [1-12, 0 to exit]:" "0" EXISTING_CHOICE + prompt_text " Choice [1-14, 0 to exit]:" "0" EXISTING_CHOICE case "$EXISTING_CHOICE" in 1) add_authelia_domain @@ -296,6 +299,14 @@ install_authelia() { _authelia_export_import_users_menu return 0 ;; + 13) + _authelia_ensure_admin_access_everywhere + return 0 + ;; + 14) + _authelia_rename_group + return 0 + ;; 0|*) echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)" return 0 @@ -451,6 +462,14 @@ authentication_backend: access_control: default_policy: deny rules: + # Admins always match first, on every domain this instance protects — this + # rule must stay above any per-service scoping rule (old or new) below it, + # or a group's "deny elsewhere" rule could accidentally catch an admin. + # add_authelia_domain and _authelia_scope_access both preserve this + # ordering automatically — see _authelia_ensure_admin_bypass. + - domain: "*.${AUTHELIA_DOMAIN}" + subject: "group:admins" + policy: two_factor - domain: "*.${AUTHELIA_DOMAIN}" policy: two_factor @@ -733,9 +752,16 @@ add_authelia_domain() { NEW_PORTAL_DOMAIN="${NEW_PORTAL_SUBDOMAIN}.${NEW_DOMAIN}" # ── access_control.rules: insert right after "rules:" ──────────────────── + # The admin-bypass rule goes first, this domain's own catch-all second — + # both inserted together in one shot so a fresh domain never has a window + # where the catch-all exists without the bypass above it. See + # _authelia_ensure_admin_bypass for why this ordering has to hold. awk -v domain="$NEW_DOMAIN" ' { print } /^ rules:$/ && !done { + print " - domain: \"*." domain "\"" + print " subject: \"group:admins\"" + print " policy: two_factor" print " - domain: \"*." domain "\"" print " policy: two_factor" done=1 @@ -861,7 +887,10 @@ remove_authelia_domain() { fi local -a apex_domains - mapfile -t apex_domains < <(grep -oE '^ - domain: "\*\.[^"]+"' "$CONFIG_FILE" | sed -E 's/^ - domain: "\*\.(.+)"$/\1/') + # Each apex domain now has two rules sharing this exact domain string — + # its admin-bypass rule and its plain catch-all (see + # _authelia_ensure_admin_bypass) — so dedupe or it'd list every domain twice. + mapfile -t apex_domains < <(grep -oE '^ - domain: "\*\.[^"]+"' "$CONFIG_FILE" | sed -E 's/^ - domain: "\*\.(.+)"$/\1/' | awk '!seen[$0]++') echo "" if [ "${#apex_domains[@]}" -eq 0 ]; then @@ -913,6 +942,23 @@ remove_authelia_domain() { prompt_yn " Continue? (y/n):" "n" CONFIRM_RM [[ "$CONFIRM_RM" =~ ^[Yy]$ ]] || { log_info "Cancelled — nothing changed."; return 0; } + # ── access_control.rules: remove this domain's admin-bypass rule first ──── + # (3 lines: domain/subject/policy — see _authelia_ensure_admin_bypass) — + # has to run before the plain catch-all removal below, which only knows + # how to strip a 2-line domain/policy pair and would otherwise leave this + # rule's own "policy:" line orphaned, the exact corruption class this + # instance was repaired from once already. + awk -v domain="$RM_DOMAIN" ' + BEGIN { skip=0 } + skip > 0 { skip--; next } + $0 == " - domain: \"*." domain "\"" { + getline nxt + if (nxt == " subject: \"group:admins\"") { skip=1; next } + print; print nxt; next + } + { print } + ' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" + # ── access_control.rules: remove the "- domain: "*.X"" + "policy: ..." pair ── awk -v domain="$RM_DOMAIN" ' BEGIN { skip=0 } @@ -1543,7 +1589,85 @@ _authelia_create_user_noninteractive() { # domain that's already scoped just report the existing group instead of # duplicating rules. # +# Idempotent: makes sure a "subject: group:admins" rule exists for $domain's +# access_control.rules, above every other rule for that domain, then returns +# the 1-indexed line number its "policy:" line ended up on (via ADMIN_BYPASS_LINE, +# not stdout — awk already uses stdout for the rewritten file). Callers that +# insert a NEW rule for this domain (_authelia_scope_access's deny-elsewhere +# rule in particular) must insert it AFTER that line, never at the literal +# top of "rules:" — Authelia takes the first matching rule, so a deny rule +# landing above the admin-bypass rule would catch an admin who's also (by +# mistake, or by some future feature) a member of the group being denied. +# install_authelia and add_authelia_domain both bake this rule in directly +# at creation time instead of calling this — it's for retrofitting an apex +# domain that predates this (see _authelia_ensure_admin_access_everywhere, +# the menu-driven bulk version of this for an existing install) and for +# _authelia_scope_access's own just-in-time safety net. +_authelia_ensure_admin_bypass() { + local config_file="$1" domain="$2" + ADMIN_BYPASS_LINE="" + ADMIN_BYPASS_ADDED="false" + + local result + result="$(awk -v domain="$domain" ' + { lines[NR] = $0 } + END { + for (i = 1; i < NR; i++) { + if (lines[i] == " - domain: \"*." domain "\"" && lines[i+1] == " subject: \"group:admins\"") { + print i + 2 + exit + } + } + } + ' "$config_file")" + + if [ -n "$result" ]; then + ADMIN_BYPASS_LINE="$result" + return 0 + fi + + ADMIN_BYPASS_ADDED="true" + awk -v domain="$domain" ' + { print } + /^ rules:$/ && !done { + print " - domain: \"*." domain "\"" + print " subject: \"group:admins\"" + print " policy: two_factor" + done=1 + } + ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" + chown 1000:1000 "$config_file" 2>/dev/null || true + + # The block we just inserted is always lines 2-4 counting from " rules:" + # (which awk just placed the block right after) — but simplest and least + # fragile is to just re-run the same lookup now that it exists. + ADMIN_BYPASS_LINE="$(awk -v domain="$domain" ' + { lines[NR] = $0 } + END { + for (i = 1; i < NR; i++) { + if (lines[i] == " - domain: \"*." domain "\"" && lines[i+1] == " subject: \"group:admins\"") { + print i + 2 + exit + } + } + } + ' "$config_file")" +} + # Args: SERVICE_ID DOMAIN +# +# service_id is only ever used as the SUGGESTED group name when creating a +# brand-new group — the actual group is whatever the user picks or types +# below, so the same group (e.g. "customer1-only") can be attached to +# several different sites over time instead of getting a fresh +# "-only" group every call. The "-only" suffix itself is kept +# internally (never shown to the user, who just sees "customer1") because +# it's load-bearing elsewhere: _authelia_list_scoped_groups, +# _authelia_report_access_scope, edit_authelia_user's per-user group +# toggle, and _authelia_unprotect_site's cleanup all already key off that +# exact suffix pattern to find "this is a site-scoping group, not some +# other group a user happens to be in" — dropping it would mean touching +# all four of those instead of just this one function. _authelia_scope_access() { local service_id="$1" domain="$2" local authelia_dir="$DOCKER_DIR/authelia" @@ -1552,54 +1676,100 @@ _authelia_scope_access() { [ -f "$config_file" ] || return 0 - local group="${service_id}-only" + echo "" + echo " Who should be able to reach $domain via Authelia?" + echo " 0) Native — your own users, no extra restriction (default — same" + echo " access as everything else)" + echo " 1) Outside access — a named group of specific users only" + local scope_choice="" + prompt_text " Choice [0 for native, 1 for outside access]:" "0" scope_choice + [ "$scope_choice" = "1" ] || return 0 - if grep -qF "subject: \"group:${group}\"" "$config_file" 2>/dev/null; then - log_info "Access to $domain is already scoped to group '$group'." + local -a existing_groups + mapfile -t existing_groups < <(_authelia_list_scoped_groups "$users_file") + local group="" is_new_group="true" i + if [ "${#existing_groups[@]}" -gt 0 ]; then + echo " Existing outside-access groups:" + for i in "${!existing_groups[@]}"; do + echo " $((i + 1))) ${existing_groups[$i]%-only}" + done + echo " Pick a number to add $domain to one of these, or type a new group" + echo " name (e.g. \"customer1\") to create one." + else + echo " No outside-access groups exist yet — type a name to create one" + echo " (e.g. \"customer1\")." + fi + local group_choice="" + prompt_text " Group:" "${service_id}" group_choice + if [ -z "$group_choice" ]; then + log_warning "No group entered — leaving $domain open to all Authelia users." + return 0 + fi + if [[ "$group_choice" =~ ^[0-9]+$ ]] && [ "$group_choice" -ge 1 ] && [ "$group_choice" -le "${#existing_groups[@]}" ]; then + group="${existing_groups[$((group_choice - 1))]}" + is_new_group="false" + else + local clean_name + clean_name="$(echo "$group_choice" | tr -cs 'a-zA-Z0-9_-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$clean_name" ]; then + log_warning "Invalid group name — leaving $domain open to all Authelia users." + return 0 + fi + group="${clean_name}-only" + for i in "${existing_groups[@]}"; do + [ "$i" = "$group" ] && is_new_group="false" + done + fi + + if grep -A1 -F " - domain: \"${domain}\"" "$config_file" 2>/dev/null | grep -qF " subject: \"group:${group}\""; then + log_info "$domain is already scoped to group '${group%-only}'." log_info "Manage its members via this menu's \"Edit an existing user\" (toggle their groups by hand in users.yml), or the universal-access report below." return 0 fi - echo "" - echo " Who should be able to reach $domain via Authelia?" - echo " 1) Specific users only" - echo " 0) Any Authelia user (default — same access as everything else)" - local scope_choice="" - prompt_text " Choice [1, or 0 for any user]:" "0" scope_choice - [ "$scope_choice" = "1" ] || return 0 - - local -a existing_users - mapfile -t existing_users < <(_authelia_list_usernames "$users_file") - local i - if [ "${#existing_users[@]}" -gt 0 ]; then - echo " Existing Authelia users:" - for i in "${!existing_users[@]}"; do - echo " $((i + 1))) ${existing_users[$i]}" - done - echo " Pick by number (space-separated), and/or type new usernames directly" - echo " to create them — mix freely, e.g. \"1 3 newperson\"." - else - echo " No existing Authelia users yet — type usernames below to create them fresh." - fi - echo " Anyone typed (not picked by number) who doesn't already have an" - echo " Authelia account gets one created — you'll get their temporary" - echo " password to hand over." - local raw_users="" - prompt_text " Usernames/numbers:" "" raw_users - local -a raw_tokens usernames - read -ra raw_tokens <<< "$raw_users" - if [ "${#raw_tokens[@]}" -eq 0 ]; then - log_warning "No usernames entered — leaving $domain open to all Authelia users." - return 0 - fi - local t - for t in "${raw_tokens[@]}"; do - if [[ "$t" =~ ^[0-9]+$ ]] && [ "$t" -ge 1 ] && [ "$t" -le "${#existing_users[@]}" ]; then - usernames+=("${existing_users[$((t - 1))]}") - else - usernames+=("$t") + local -a usernames + if [ "$is_new_group" = "false" ]; then + log_info "Reusing existing group '${group%-only}' — its current members already have access." + local add_more="" + prompt_yn " Add more users to '${group%-only}' now? (y/n):" "n" add_more + if [[ ! "$add_more" =~ ^[Yy]$ ]]; then + usernames=() fi - done + fi + + if [ "$is_new_group" = "true" ] || [[ "${add_more:-}" =~ ^[Yy]$ ]]; then + local -a existing_users + mapfile -t existing_users < <(_authelia_list_usernames "$users_file") + if [ "${#existing_users[@]}" -gt 0 ]; then + echo " Existing Authelia users:" + for i in "${!existing_users[@]}"; do + echo " $((i + 1))) ${existing_users[$i]}" + done + echo " Pick by number (space-separated), and/or type new usernames directly" + echo " to create them — mix freely, e.g. \"1 3 newperson\"." + else + echo " No existing Authelia users yet — type usernames below to create them fresh." + fi + echo " Anyone typed (not picked by number) who doesn't already have an" + echo " Authelia account gets one created — you'll get their temporary" + echo " password to hand over." + local raw_users="" + prompt_text " Usernames/numbers:" "" raw_users + local -a raw_tokens + read -ra raw_tokens <<< "$raw_users" + if [ "${#raw_tokens[@]}" -eq 0 ] && [ "$is_new_group" = "true" ]; then + log_warning "No usernames entered — leaving $domain open to all Authelia users." + return 0 + fi + local t + for t in "${raw_tokens[@]}"; do + if [[ "$t" =~ ^[0-9]+$ ]] && [ "$t" -ge 1 ] && [ "$t" -le "${#existing_users[@]}" ]; then + usernames+=("${existing_users[$((t - 1))]}") + else + usernames+=("$t") + fi + done + fi local u start_end start end for u in "${usernames[@]}"; do @@ -1609,7 +1779,7 @@ _authelia_scope_access() { start_end="$(_authelia_user_line_range "$users_file" "$u")" start="${start_end% *}"; end="${start_end#* }" _authelia_toggle_group "$users_file" "$start" "$end" "$group" "true" - log_success "Added '$u' to group '$group'" + log_success "Added '$u' to group '${group%-only}'" else local email_default="${u}@${SITE_DOMAIN:-example.com}" if _authelia_create_user_noninteractive "$u" "$u" "$email_default" "$group"; then @@ -1624,6 +1794,16 @@ _authelia_scope_access() { # before access_control's existing "*.${AUTHELIA_DOMAIN}" catch-all. local authelia_domain authelia_domain="$(awk '/^ cookies:$/{f=1; next} f && /domain:/{print $3; exit}' "$config_file")" + + # ...but BELOW the admin-bypass rule for this apex — otherwise the + # deny-elsewhere rule below would outrank it, and an admin who ever ends + # up in this group (by mistake, or a future feature) would be locked out + # of every other domain on the instance. Ensures the bypass rule exists + # first (retrofits it if this instance predates the feature), then + # inserts right after it rather than at the literal top of "rules:". + _authelia_ensure_admin_bypass "$config_file" "$authelia_domain" + local anchor_line="$ADMIN_BYPASS_LINE" + local scope_rules=" - domain: \"${domain}\" subject: \"group:${group}\" policy: two_factor @@ -1631,9 +1811,10 @@ _authelia_scope_access() { subject: \"group:${group}\" policy: deny" - awk -v block="$scope_rules" ' - /^ rules:$/ && !done { print; print block; done=1; next } + awk -v block="$scope_rules" -v anchor="$anchor_line" ' { print } + anchor != "" && NR == anchor + 0 { print block } + anchor == "" && /^ rules:$/ && !done { print block; done=1 } ' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file" chown 1000:1000 "$config_file" 2>/dev/null || true @@ -1641,7 +1822,128 @@ _authelia_scope_access() { prompt_yn " Restart Authelia to apply this scoping? (y/n):" "y" restart_auth if [[ "$restart_auth" =~ ^[Yy]$ ]]; then (cd "$authelia_dir" && docker compose restart authelia 2>/dev/null) \ - && log_success "Authelia restarted — $domain is now restricted to group '$group'." \ + && log_success "Authelia restarted — $domain is now restricted to group '${group%-only}'." \ + || log_warning "Restart failed — check: docker compose logs authelia" + fi +} + +# Renames an existing outside-access group everywhere it appears — every +# "subject: group:" line in configuration.yml's access_control.rules, +# and every member's "- " entry under their own groups: list in +# users.yml. A plain find/replace on the "-only"-suffixed internal name; +# the display name typed at the prompt (what _authelia_scope_access shows +# without the suffix) is what the user actually renames. +_authelia_rename_group() { + local users_file="$DOCKER_DIR/authelia/config/users.yml" + local config_file="$DOCKER_DIR/authelia/config/configuration.yml" + [ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; } + + local -a existing_groups + mapfile -t existing_groups < <(_authelia_list_scoped_groups "$users_file") + if [ "${#existing_groups[@]}" -eq 0 ]; then + log_info "No outside-access groups exist yet." + return 0 + fi + + echo "" + echo " Outside-access groups:" + local i + for i in "${!existing_groups[@]}"; do + echo " $((i + 1))) ${existing_groups[$i]%-only}" + done + echo " 0) Cancel" + local choice="" + prompt_text " Which group to rename? [0]:" "0" choice + if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt "${#existing_groups[@]}" ]; then + log_info "Cancelled — nothing changed." + return 0 + fi + local old_group="${existing_groups[$((choice - 1))]}" + + local new_name="" + prompt_text " New name for '${old_group%-only}':" "" new_name + local clean_name + clean_name="$(echo "$new_name" | tr -cs 'a-zA-Z0-9_-' '-' | sed 's/^-*//;s/-*$//')" + if [ -z "$clean_name" ]; then + log_warning "No name entered — nothing changed." + return 0 + fi + local new_group="${clean_name}-only" + if [ "$new_group" = "$old_group" ]; then + log_info "Same name — nothing changed." + return 0 + fi + for i in "${existing_groups[@]}"; do + if [ "$i" = "$new_group" ]; then + log_warning "A group named '${clean_name}' already exists — pick a different name, or use that group directly instead of renaming into it." + return 1 + fi + done + + sed -i "s/subject: \"group:${old_group}\"/subject: \"group:${new_group}\"/g" "$config_file" + sed -i "s/^ - ${old_group}\$/ - ${new_group}/g" "$users_file" + chown 1000:1000 "$config_file" "$users_file" 2>/dev/null || true + log_success "Renamed '${old_group%-only}' to '${clean_name}' — updated every access rule and member using it." + + local restart_auth="" + prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth + if [[ "$restart_auth" =~ ^[Yy]$ ]]; then + (cd "$DOCKER_DIR/authelia" && docker compose restart authelia 2>/dev/null) \ + && log_success "Authelia restarted" \ + || log_warning "Restart failed — check: docker compose logs authelia" + fi +} + +# Menu-driven, idempotent bulk version of _authelia_ensure_admin_bypass — +# backfills the admin-bypass rule for every apex domain currently on this +# instance in one pass. install_authelia and add_authelia_domain bake the +# rule in automatically for anything created from here on, and +# _authelia_scope_access retrofits it just-in-time for whichever domain it's +# scoping — this is for catching every OTHER domain an install already had +# before this feature existed (or just double-checking one that's fine). +# Safe to re-run any time; only touches domains actually missing the rule. +_authelia_ensure_admin_access_everywhere() { + local config_file="$DOCKER_DIR/authelia/config/configuration.yml" + [ -f "$config_file" ] || { log_warning "No configuration.yml found — install Authelia first."; return 1; } + + local -a apex_domains + mapfile -t apex_domains < <(grep -oE '^ - domain: "\*\.[^"]+"' "$config_file" | sed -E 's/^ - domain: "\*\.(.+)"$/\1/' | awk '!seen[$0]++') + + if [ "${#apex_domains[@]}" -eq 0 ]; then + log_info "No apex domains found on this instance." + return 0 + fi + + echo "" + echo " Checking the admin-bypass rule for every domain on this instance:" + local d any_added="false" + for d in "${apex_domains[@]}"; do + _authelia_ensure_admin_bypass "$config_file" "$d" + if [ "$ADMIN_BYPASS_ADDED" = "true" ]; then + echo " ${d} — added" + any_added="true" + else + echo " ${d} — already present" + fi + done + + if [ "$any_added" = "false" ]; then + log_success "Every domain already had it — nothing to change." + return 0 + fi + + log_success "Members of the 'admins' group now always have access to every domain" + log_success "listed above, regardless of any per-service scoping already in place —" + log_success "or added later, since _authelia_scope_access checks for this automatically" + log_success "from now on." + log_warning "Make sure your admin account(s) are actually in the 'admins' group in" + log_warning "config/users.yml — the default admin created at install time already is." + + local restart_auth="" + prompt_yn " Restart Authelia to apply? (y/n):" "y" restart_auth + if [[ "$restart_auth" =~ ^[Yy]$ ]]; then + (cd "$DOCKER_DIR/authelia" && docker compose restart authelia 2>/dev/null) \ + && log_success "Authelia restarted" \ || log_warning "Restart failed — check: docker compose logs authelia" fi } @@ -1664,7 +1966,7 @@ _authelia_report_access_scope() { fi echo "" - echo " Universal access (every protected domain):" + echo " Native (universal — every protected domain, no outside-access group):" local -a universal=() restricted=() local u start_end start end groups_in_range for u in "${all_users[@]}"; do @@ -1681,7 +1983,7 @@ _authelia_report_access_scope() { [ "${#universal[@]}" -eq 0 ] && echo " (none)" echo "" - echo " Scoped to specific services only:" + echo " Outside access (limited to a named group):" if [ "${#restricted[@]}" -eq 0 ]; then echo " (none)" else