diff --git a/services/authelia.sh b/services/authelia.sh index 5afde51..f1ef80b 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -244,10 +244,12 @@ 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 " 0) Leave as-is / exit" echo "" local EXISTING_CHOICE="" - prompt_text " Choice [1-12, 0 to exit]:" "0" EXISTING_CHOICE + prompt_text " Choice [1-13, 0 to exit]:" "0" EXISTING_CHOICE case "$EXISTING_CHOICE" in 1) add_authelia_domain @@ -296,6 +298,10 @@ install_authelia() { _authelia_export_import_users_menu return 0 ;; + 13) + _authelia_ensure_admin_access_everywhere + return 0 + ;; 0|*) echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)" return 0 @@ -451,6 +457,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 +747,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 +882,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 +937,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,6 +1584,71 @@ _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 _authelia_scope_access() { local service_id="$1" domain="$2" @@ -1624,6 +1730,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 +1747,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 @@ -1646,6 +1763,60 @@ _authelia_scope_access() { 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 +} + # Reporting/management: lists which users have "universal" access (every # protected domain — anyone not locked into a "-only" group) versus # which are scoped to specific services, then offers to promote a scoped