Give admins guaranteed access to every Authelia-protected site, old and new

Adds a "subject: group:admins" rule ahead of every domain's other rules, so
admins always match first regardless of any per-service scoping (existing or
future) on that domain — a group's deny-elsewhere rule can no longer catch an
admin even if they're accidentally added to that group later.

- install_authelia and add_authelia_domain bake the rule in at creation time
- _authelia_scope_access retrofits it just-in-time before inserting its own
  deny-elsewhere rule, and anchors that rule below it instead of at the top
- new menu option 13 (_authelia_ensure_admin_access_everywhere) backfills it
  across every domain on an install that predates this
- remove_authelia_domain cleans the rule up too when a domain is removed,
  and its domain picker dedupes since two rules now share one domain string
This commit is contained in:
Claude
2026-08-24 04:17:35 +00:00
parent 9a7989b31d
commit 753a8fdd43
+175 -4
View File
@@ -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 "<service>-only" group) versus
# which are scoped to specific services, then offers to promote a scoped