authelia: add reusable per-service access scoping (universal vs specific users)
Every domain with an access_control rule was reachable by any Authelia
user by default (the existing catch-all *.${AUTHELIA_DOMAIN} rule) — no
way to restrict a specific service to a subset of users without hand-
editing configuration.yml and users.yml directly.
_authelia_scope_access(SERVICE_ID, DOMAIN) is a new generic, reusable
helper: call it after any service finishes being protected by Authelia
(forward_auth gate or native OIDC alike — it only cares about the domain).
Offers universal vs. specific-users access; if scoped, creates a
"<service_id>-only" group, adds every listed username to it (creating
accounts on the fly for names that don't exist yet, via the new
_authelia_create_user_noninteractive — a non-interactive sibling to
add_authelia_user, same extraction pattern already used for
_authelia_provision_oidc_client), and inserts an allow+deny rule pair
above the general catch-all. Idempotent on rerun.
_authelia_report_access_scope() (new menu option 6) is the read side —
lists who has universal vs. service-scoped access, and offers to promote
a scoped user back to universal by removing their "-only" group
membership.
services/gitea.sh's _gitea_offer_authelia_sso() is the reference
integration, calling _authelia_scope_access after successfully wiring up
Gitea's OIDC login. The other services with a plain "Protect X with
Authelia?" prompt (magicmirror, wolf-pair, js99er, drum-rhythm-game,
iopaint, paintplus, stirling-pdf, wolf) are natural follow-ups once this
is confirmed working live — each just needs one added call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8
This commit is contained in:
@@ -390,6 +390,45 @@ existing login page. Reuse `_authelia_provision_oidc_client()` (guarded by
|
||||
instead of duplicating Authelia's client-secret-generation/config-patching
|
||||
logic again.
|
||||
|
||||
**Scoping a domain to specific users instead of every Authelia user.**
|
||||
By default, any domain with an `access_control` rule at all is reachable by
|
||||
every Authelia user (the existing catch-all `*.${AUTHELIA_DOMAIN}` rule).
|
||||
`services/authelia.sh`'s `_authelia_scope_access(SERVICE_ID, DOMAIN)` is a
|
||||
generic, reusable opt-in on top of that — call it right after *any* service
|
||||
finishes being protected by Authelia, forward_auth gate or native OIDC
|
||||
alike (it only cares about the domain, not the gating mechanism; see
|
||||
`_gitea_offer_authelia_sso()` for the reference caller). Asks whether
|
||||
access should stay universal or be scoped to specific usernames; if scoped,
|
||||
creates a dedicated `<service_id>-only` group, adds every listed username
|
||||
to it (creating accounts on the fly via
|
||||
`_authelia_create_user_noninteractive()` for names that don't exist yet,
|
||||
printing their temp password), and inserts two rules *above* the general
|
||||
catch-all — allow that group on this domain, deny that group on every
|
||||
other protected domain. Idempotent: reruns against an already-scoped
|
||||
domain just report the existing group instead of duplicating rules.
|
||||
Guard every cross-file call with `declare -F`, same convention as the OIDC
|
||||
helper above — a service can run standalone with authelia.sh never sourced.
|
||||
|
||||
`_authelia_report_access_scope()` (menu option 6 on an existing Authelia
|
||||
install) is the read side: lists who has universal access versus who's
|
||||
scoped to which service(s), and offers to promote a scoped user back to
|
||||
universal by removing them from their `-only` group(s) — a pure users.yml
|
||||
edit, since universal access is just the *absence* of a restricting group,
|
||||
not a rule of its own.
|
||||
|
||||
Only `services/gitea.sh` calls `_authelia_scope_access()` so far (the
|
||||
reference integration). The other services that already offer a plain
|
||||
"Protect X with Authelia SSO?" prompt (`magicmirror`, `wolf-pair`,
|
||||
`js99er`, `drum-rhythm-game`, `iopaint`, `paintplus`, `stirling-pdf`,
|
||||
`wolf`) are natural, mechanical follow-ups — each just needs one added
|
||||
call to `_authelia_scope_access` after its existing
|
||||
`configure_caddy_for_service` step, once Gitea's integration has been
|
||||
confirmed working live. Extending *native* OIDC support (the pattern
|
||||
above, not just scoping) to the "has built-in auth" services beyond Gitea
|
||||
needs verifying per service first — not every app in that list actually
|
||||
has its own OAuth2/OIDC provider field, so don't assume one exists without
|
||||
checking that service's real settings.
|
||||
|
||||
**No built-in auth — should be protected:**
|
||||
`magicmirror`, `wolf-pair`, `js99er`, `drum-rhythm-game`, `iopaint`,
|
||||
`paintplus`, `stirling-pdf`, `wolf` (web UI). Each of these prompts
|
||||
|
||||
+253
-2
@@ -232,10 +232,11 @@ install_authelia() {
|
||||
echo " Vaultwarden, or any other app with its own \"Enable OpenID\" setting)"
|
||||
echo " 5) Reconfigure from scratch (regenerates secrets/users — breaks"
|
||||
echo " existing sessions for every domain already on this instance)"
|
||||
echo " 6) Leave as-is"
|
||||
echo " 6) Show who has universal vs. service-scoped access"
|
||||
echo " 7) Leave as-is"
|
||||
echo ""
|
||||
local EXISTING_CHOICE=""
|
||||
prompt_text " Choice [1/2/3/4/5/6]:" "6" EXISTING_CHOICE
|
||||
prompt_text " Choice [1/2/3/4/5/6/7]:" "7" EXISTING_CHOICE
|
||||
case "$EXISTING_CHOICE" in
|
||||
1)
|
||||
add_authelia_domain
|
||||
@@ -256,6 +257,10 @@ install_authelia() {
|
||||
5)
|
||||
: # fall through to the full reinstall flow below
|
||||
;;
|
||||
6)
|
||||
_authelia_report_access_scope
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)"
|
||||
return 0
|
||||
@@ -915,6 +920,252 @@ _authelia_toggle_admin() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Same shape as _authelia_toggle_admin but for an arbitrary group name —
|
||||
# used to scope a user's access to a single service (see
|
||||
# _authelia_scope_access below) rather than the fixed "admins" group.
|
||||
_authelia_toggle_group() {
|
||||
local users_file="$1" start="$2" end="$3" group="$4" enable="$5"
|
||||
if [ "$enable" = "true" ]; then
|
||||
if ! sed -n "${start},${end}p" "$users_file" | grep -qF " - ${group}"; then
|
||||
awk -v s="$start" -v e="$end" -v grp=" - ${group}" '
|
||||
{ print }
|
||||
NR>=s && NR<=e && /^ groups:$/ { print grp }
|
||||
' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file"
|
||||
fi
|
||||
else
|
||||
awk -v s="$start" -v e="$end" -v grpline=" - ${group}" '
|
||||
NR>=s && NR<=e && $0==grpline { next }
|
||||
{ print }
|
||||
' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Non-interactive core of add_authelia_user() below — no prompts, takes
|
||||
# everything as args, generates a temp password + hash, and writes the user
|
||||
# block directly into an arbitrary extra group (not just "users"). Used by
|
||||
# _authelia_scope_access() to create users on the fly when someone lists a
|
||||
# username that doesn't exist yet. Deliberately a separate function rather
|
||||
# than a refactor of add_authelia_user() itself — that one's already in
|
||||
# regular use via the interactive menu and this repo's convention is to
|
||||
# extract a non-interactive core only when a second caller actually needs
|
||||
# it (see _authelia_provision_oidc_client for the same reasoning), which
|
||||
# keeps this addition low-risk to the existing, working function.
|
||||
#
|
||||
# Args: USERNAME DISPLAY EMAIL GROUP
|
||||
# Out-param (not `local`): AUTHELIA_NEW_USER_TEMP_PASSWORD
|
||||
# Returns 1 if the user already exists or hash generation fails.
|
||||
_authelia_create_user_noninteractive() {
|
||||
local username="$1" display="$2" email="$3" group="$4"
|
||||
local users_file="$DOCKER_DIR/authelia/config/users.yml"
|
||||
|
||||
AUTHELIA_NEW_USER_TEMP_PASSWORD=""
|
||||
|
||||
if grep -qE "^ ${username}:$" "$users_file" 2>/dev/null; then
|
||||
log_warning "'$username' already exists in $users_file."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local temp_pass new_hash
|
||||
temp_pass="$(_authelia_gen_temp_password)"
|
||||
new_hash=$(docker run --rm authelia/authelia:4.39.20 \
|
||||
authelia crypto hash generate argon2 --password "$temp_pass" 2>/dev/null \
|
||||
| grep -oP '(?<=Digest: ).*')
|
||||
if [ -z "$new_hash" ]; then
|
||||
log_warning "Couldn't generate a password hash for '$username' automatically."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local user_block=" ${username}:
|
||||
displayname: \"${display}\"
|
||||
email: ${email}
|
||||
password: \"${new_hash}\"
|
||||
groups:
|
||||
- ${group}"
|
||||
|
||||
awk -v block="$user_block" '
|
||||
{ print }
|
||||
/^users:$/ && !done { print block; done=1 }
|
||||
' "$users_file" > "$users_file.tmp" && mv "$users_file.tmp" "$users_file"
|
||||
chown 1000:1000 "$users_file" 2>/dev/null || true
|
||||
|
||||
AUTHELIA_NEW_USER_TEMP_PASSWORD="$temp_pass"
|
||||
log_success "Created user '$username' (group: $group)"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Reusable by ANY service, after it's already been protected by Authelia —
|
||||
# forward_auth gate or native OIDC alike, since this only cares about the
|
||||
# domain, not the gating mechanism. Asks whether access to $DOMAIN should be
|
||||
# open to any Authelia user (today's only behavior, before this existed) or
|
||||
# scoped to a specific list. If scoped: creates a dedicated group named
|
||||
# "<service_id>-only", adds every listed username to it (creating any that
|
||||
# don't exist yet via _authelia_create_user_noninteractive), and inserts two
|
||||
# access_control rules ABOVE the general catch-all — allow this group on
|
||||
# $DOMAIN, deny this group on every other protected domain on the instance —
|
||||
# so members can reach ONLY this one domain. Idempotent: reruns against a
|
||||
# domain that's already scoped just report the existing group instead of
|
||||
# duplicating rules.
|
||||
#
|
||||
# Args: SERVICE_ID DOMAIN
|
||||
_authelia_scope_access() {
|
||||
local service_id="$1" domain="$2"
|
||||
local authelia_dir="$DOCKER_DIR/authelia"
|
||||
local config_file="$authelia_dir/config/configuration.yml"
|
||||
local users_file="$authelia_dir/config/users.yml"
|
||||
|
||||
[ -f "$config_file" ] || return 0
|
||||
|
||||
local group="${service_id}-only"
|
||||
|
||||
if grep -qF "subject: \"group:${group}\"" "$config_file" 2>/dev/null; then
|
||||
log_info "Access to $domain is already scoped to group '$group'."
|
||||
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) Any Authelia user (default — same access as everything else)"
|
||||
echo " 2) Specific users only"
|
||||
local scope_choice=""
|
||||
prompt_text " Choice [1/2]:" "1" scope_choice
|
||||
[ "$scope_choice" = "2" ] || return 0
|
||||
|
||||
echo " Usernames who should have access (space-separated). Anyone listed"
|
||||
echo " who doesn't already have an Authelia account gets one created —"
|
||||
echo " you'll get their temporary password to hand over."
|
||||
local raw_users=""
|
||||
prompt_text " Usernames:" "" raw_users
|
||||
local -a usernames
|
||||
read -ra usernames <<< "$raw_users"
|
||||
if [ "${#usernames[@]}" -eq 0 ]; then
|
||||
log_warning "No usernames entered — leaving $domain open to all Authelia users."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local u start_end start end
|
||||
for u in "${usernames[@]}"; do
|
||||
u="$(echo "$u" | tr -cs 'a-z0-9_-' '-' | sed 's/^-*//;s/-*$//')"
|
||||
[ -z "$u" ] && continue
|
||||
if grep -qE "^ ${u}:$" "$users_file" 2>/dev/null; then
|
||||
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'"
|
||||
else
|
||||
local email_default="${u}@${SITE_DOMAIN:-example.com}"
|
||||
if _authelia_create_user_noninteractive "$u" "$u" "$email_default" "$group"; then
|
||||
echo " Temp password for '$u': $AUTHELIA_NEW_USER_TEMP_PASSWORD"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Two rules, both above the general catch-all: allow this group on the
|
||||
# target domain, deny this group on every other protected domain. Order
|
||||
# matters — Authelia takes the first matching rule, so both must land
|
||||
# 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")"
|
||||
local scope_rules=" - domain: \"${domain}\"
|
||||
subject: \"group:${group}\"
|
||||
policy: two_factor
|
||||
- domain: \"*.${authelia_domain}\"
|
||||
subject: \"group:${group}\"
|
||||
policy: deny"
|
||||
|
||||
awk -v block="$scope_rules" '
|
||||
/^ rules:$/ && !done { print; print block; done=1; next }
|
||||
{ print }
|
||||
' "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
|
||||
chown 1000:1000 "$config_file" 2>/dev/null || true
|
||||
|
||||
local restart_auth=""
|
||||
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_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
|
||||
# user to universal by removing them from all their "-only" groups. Doesn't
|
||||
# touch access_control.rules at all — universal access is just the absence
|
||||
# of a restricting group, so "promoting" someone is purely a users.yml edit.
|
||||
_authelia_report_access_scope() {
|
||||
local users_file="$DOCKER_DIR/authelia/config/users.yml"
|
||||
[ -f "$users_file" ] || { log_warning "No users.yml found — install Authelia first."; return 1; }
|
||||
|
||||
local -a all_users
|
||||
mapfile -t all_users < <(_authelia_list_usernames "$users_file")
|
||||
if [ "${#all_users[@]}" -eq 0 ]; then
|
||||
log_warning "No users found in $users_file."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Universal access (every protected domain):"
|
||||
local -a universal=() restricted=()
|
||||
local u start_end start end groups_in_range
|
||||
for u in "${all_users[@]}"; do
|
||||
start_end="$(_authelia_user_line_range "$users_file" "$u")"
|
||||
start="${start_end% *}"; end="${start_end#* }"
|
||||
groups_in_range="$(sed -n "${start},${end}p" "$users_file" | grep -oE '\- [a-z0-9_-]+-only$' | sed 's/^- //')"
|
||||
if [ -z "$groups_in_range" ]; then
|
||||
universal+=("$u")
|
||||
echo " - $u"
|
||||
else
|
||||
restricted+=("$u ($(echo "$groups_in_range" | tr '\n' ',' | sed 's/,$//'))")
|
||||
fi
|
||||
done
|
||||
[ "${#universal[@]}" -eq 0 ] && echo " (none)"
|
||||
|
||||
echo ""
|
||||
echo " Scoped to specific services only:"
|
||||
if [ "${#restricted[@]}" -eq 0 ]; then
|
||||
echo " (none)"
|
||||
else
|
||||
printf ' - %s\n' "${restricted[@]}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
local promote=""
|
||||
prompt_yn " Promote a scoped user to universal access? (y/n):" "n" promote
|
||||
[[ "$promote" =~ ^[Yy]$ ]] || return 0
|
||||
|
||||
local target=""
|
||||
prompt_text " Username to promote:" "" target
|
||||
[ -z "$target" ] && return 0
|
||||
if ! grep -qE "^ ${target}:$" "$users_file" 2>/dev/null; then
|
||||
log_warning "'$target' not found in $users_file."
|
||||
return 0
|
||||
fi
|
||||
|
||||
start_end="$(_authelia_user_line_range "$users_file" "$target")"
|
||||
start="${start_end% *}"; end="${start_end#* }"
|
||||
local -a target_groups
|
||||
mapfile -t target_groups < <(sed -n "${start},${end}p" "$users_file" | grep -oE '\- [a-z0-9_-]+-only$' | sed 's/^- //')
|
||||
if [ "${#target_groups[@]}" -eq 0 ]; then
|
||||
log_info "'$target' already has universal access."
|
||||
return 0
|
||||
fi
|
||||
local g
|
||||
for g in "${target_groups[@]}"; do
|
||||
_authelia_toggle_group "$users_file" "$start" "$end" "$g" "false"
|
||||
done
|
||||
log_success "'$target' removed from: ${target_groups[*]} — now has universal access."
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# action="exempt": inserts a "policy: one_factor / subject: user:<name>" rule
|
||||
# immediately before EVERY plain "policy: two_factor" catch-all domain rule in
|
||||
# configuration.yml (handles multi-domain instances from add_authelia_domain
|
||||
|
||||
@@ -236,6 +236,8 @@ _gitea_offer_authelia_sso() {
|
||||
log_warning " Discovery URL: $_discovery_url"
|
||||
log_warning " (The Client Secret above is shown once — it isn't stored in plaintext anywhere.)"
|
||||
fi
|
||||
|
||||
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "gitea" "$GITEA_OIDC_DOMAIN"
|
||||
}
|
||||
|
||||
# Offers to enable Gitea Actions (Gitea's own CI, largely GitHub-Actions-
|
||||
|
||||
Reference in New Issue
Block a user