From ab6b554cd821bfaaf95e7af97b2a15a02ac4fd56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:29:36 +0000 Subject: [PATCH] authelia: add reusable per-service access scoping (universal vs specific users) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 "-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 Claude-Session: https://claude.ai/code/session_01YEQNc4NfBST1m9NtCZVYa8 --- CLAUDE.md | 39 +++++++ services/authelia.sh | 255 ++++++++++++++++++++++++++++++++++++++++++- services/gitea.sh | 2 + 3 files changed, 294 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4b2a30f..fc66348 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `-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 diff --git a/services/authelia.sh b/services/authelia.sh index e1194d3..babcd1c 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -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 +# "-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 "-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:" rule # immediately before EVERY plain "policy: two_factor" catch-all domain rule in # configuration.yml (handles multi-domain instances from add_authelia_domain diff --git a/services/gitea.sh b/services/gitea.sh index c815714..9b6890e 100644 --- a/services/gitea.sh +++ b/services/gitea.sh @@ -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-