diff --git a/README.md b/README.md index 4782055..3744e5e 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,10 @@ Docker (always present on a dokku host), and nginx built with 5. Note the client ID and client secret. -### 2. Configure the plugin (one time) +### 2. Configure the plugin + +`configure` is re-runnable: every flag it takes is persisted and can be +changed later by passing it again. ```bash dokku google-auth:configure \ @@ -151,12 +154,60 @@ Other flags (all optional, all persisted): |---|---|---| | `--allow-domain ` | allow any verified `*@d` account (repeatable; replaces the stored list) | — | | `--allow-email ` | allow a specific address, e.g. an outside collaborator (repeatable) | — | +| `--deny-email ` | block a specific address even if the allow lists cover it (repeatable; replaces the stored list) | — | +| `--clear-deny-emails` | empty the deny list | — | | `--session-ttl ` | how long a sign-in lasts (`24h`, `72h`, `30m`, …) | `24h` | | `--cookie-name ` | session cookie name | `_google_auth` | | `--port

` | host port (127.0.0.1 only) for the auth service | `2999` | | `--regenerate-cookie-secret` | rotate the session encryption key (signs everyone out) | — | -### 3. Protect apps +These flags **replace** the list they name, which suits initial setup. For +one-at-a-time changes afterwards, see the next section. + +### 3. Decide who is allowed in + +`--allow-domain` and `--allow-email` together form the **allowlist**, and at +least one entry is required. Any account matching neither is rejected — there +is no "allow everyone" mode. So to limit access to a specific list of people, +use only addresses and no domain: + +```bash +dokku google-auth:allow greyson@signal.org alice@signal.org +``` + +The **deny list** is checked first and wins over both allow rules, which is +how you cut off one person without narrowing the whole domain: + +```bash +dokku google-auth:allow signal.org # everyone at signal.org… +dokku google-auth:deny former@signal.org # …except this account +``` + +These four commands each change one entry at a time and restart the auth +service for you: + +```bash +dokku google-auth:allow # show the allow list +dokku google-auth:allow signal.org # a domain (any verified account there) +dokku google-auth:allow guest@partner.com # one address +dokku google-auth:unallow guest@partner.com # remove either kind + +dokku google-auth:deny # show the deny list +dokku google-auth:deny former@signal.org +dokku google-auth:undeny former@signal.org +``` + +Changes take effect on the affected user's **next request**: session cookies +are re-checked against the current lists rather than trusted until they +expire, so denying (or unallowing) someone with a live session ends it. To +sign out everyone at once instead, use +`configure --regenerate-cookie-secret`. + +`unallow` refuses to remove the last allow entry, since an empty allowlist +locks everyone out of every enabled app. `dokku google-auth:report` shows +both lists as they currently stand. + +### 4. Protect apps ```bash dokku google-auth:enable my-app @@ -167,7 +218,7 @@ dokku google-auth:enable other-app That's it. Visit the app in a browser — you'll be bounced through Google and back. -### 4. Exclude paths (optional, per app) +### 5. Exclude paths (optional, per app) ```bash # Path prefix — everything under it is open: @@ -187,8 +238,10 @@ protect them yourself (API key, HMAC signature, etc.). ### Day-to-day commands ```bash -dokku google-auth:report # global + per-app status +dokku google-auth:report # global + per-app status, incl. both access lists dokku google-auth:report my-app # one app +dokku google-auth:allow alice@signal.org # let someone in +dokku google-auth:deny former@signal.org # cut someone off dokku google-auth:disable my-app # turn SSO off for an app dokku google-auth:logs -t # follow auth service logs (sign-ins, denials) dokku google-auth:restart # restart the auth service diff --git a/commands b/commands index bbe0269..18492b4 100755 --- a/commands +++ b/commands @@ -6,7 +6,11 @@ case "$1" in help | google-auth:help) help_content() { cat <...], Let a domain or address sign in (no arguments lists the allow list) + google-auth:unallow , Remove a domain or address from the allow list + google-auth:deny [

...], Block an address even if the allow list covers it (no arguments lists the deny list) + google-auth:undeny , Remove an address from the deny list google-auth:enable , Require Google sign-in for all requests to google-auth:disable , Remove Google sign-in from google-auth:exclude , Exempt path prefixes (/path) or regexes (re:^/x) from sign-in @@ -26,6 +30,27 @@ help_content echo '' echo 'Commands:' help_content | sort | column -c2 -t -s, + echo '' + echo 'google-auth:configure options (all persisted; re-run to change any of them):' + echo '' + echo ' --client-id Google OAuth client id' + echo ' --client-secret Google OAuth client secret' + echo ' --auth-host the one host registered as a redirect URI with Google' + echo ' --allow-domain allow any verified account at (repeatable)' + echo ' --allow-email
allow one specific address (repeatable)' + echo ' --deny-email
block one address, even if a rule above allows it (repeatable)' + echo ' --clear-deny-emails empty the deny list' + echo ' --session-ttl how long a sign-in lasts (default 24h)' + echo ' --cookie-name session cookie name (default _google_auth)' + echo ' --port 127.0.0.1 port for the auth service (default 2999)' + echo ' --regenerate-cookie-secret rotate the session key (signs everyone out)' + echo '' + echo 'The allow rules are a strict allowlist: an account that matches none of them' + echo 'is rejected, and at least one rule is required. The deny list wins over both.' + echo '' + echo 'These flags REPLACE the list they name, so they suit initial setup. To add or' + echo 'remove one person afterwards, use google-auth:allow / :unallow / :deny /' + echo ':undeny, which change one entry at a time and restart the service for you.' else help_content fi diff --git a/functions b/functions index 9feda4d..e7d4f39 100644 --- a/functions +++ b/functions @@ -73,6 +73,65 @@ fn-ga-global-set-list() { chmod 600 "$file" } +# Additive counterparts to fn-ga-global-set-list, for the allow/deny commands. +fn-ga-global-list-add() { + declare KEY="$1" VALUE="$2" + mkdir -p "$GOOGLE_AUTH_DATA_ROOT/global" + local file="$GOOGLE_AUTH_DATA_ROOT/global/$KEY" + touch "$file" + chmod 600 "$file" + grep -qxF "$VALUE" "$file" || printf '%s\n' "$VALUE" >>"$file" +} + +fn-ga-global-list-remove() { + declare KEY="$1" VALUE="$2" + local file="$GOOGLE_AUTH_DATA_ROOT/global/$KEY" tmp + [[ -f "$file" ]] || return 0 + tmp="$(mktemp)" + grep -vxF "$VALUE" "$file" >"$tmp" || true + cat "$tmp" >"$file" + rm -f "$tmp" +} + +fn-ga-global-list-contains() { + declare KEY="$1" VALUE="$2" + grep -qxF "$VALUE" "$GOOGLE_AUTH_DATA_ROOT/global/$KEY" 2>/dev/null +} + +fn-ga-global-list-count() { + declare KEY="$1" + fn-ga-global-get-list "$KEY" | grep -c . || true +} + +# Entries end up in a comma/space separated env var, so they may contain +# neither. +fn-ga-validate-list-entry() { + declare ENTRY="$1" + [[ -n "$ENTRY" ]] || return 1 + ! printf '%s' "$ENTRY" | grep -qE '[,[:space:]]' +} + +# "signal.org" and "@signal.org" name a domain; "guest@partner.com" names one +# address. Callers strip any leading "@" themselves. +fn-ga-allow-entry-kind() { + declare ENTRY="$1" + if [[ "$ENTRY" == @* || "$ENTRY" != *@* ]]; then + echo domain + else + echo email + fi +} + +# The service reads the allow/deny lists from its env file at startup, so +# changing them means recreating the container. +fn-ga-reload-service-config() { + if fn-ga-service-running; then + fn-ga-service-start + else + dokku_log_verbose "auth service is not running; changes apply when it starts" + fi +} + fn-ga-app-dir() { declare APP="$1" echo "$GOOGLE_AUTH_DATA_ROOT/apps/$APP" @@ -422,9 +481,10 @@ fn-ga-service-running() { fn-ga-write-env-file() { local envfile="$GOOGLE_AUTH_DATA_ROOT/service.env" - local domains emails + local domains emails denied domains="$(fn-ga-global-get-list allowed-domains | paste -sd, -)" emails="$(fn-ga-global-get-list allowed-emails | paste -sd, -)" + denied="$(fn-ga-global-get-list denied-emails | paste -sd, -)" mkdir -p "$GOOGLE_AUTH_DATA_ROOT" umask 077 cat >"$envfile" <')${GOOGLE_AUTH_ROUTE_PREFIX}/callback" dokku_log_verbose "Allowed domains: $(fn-ga-global-get-list allowed-domains | paste -sd' ' -)" dokku_log_verbose "Allowed emails: $(fn-ga-global-get-list allowed-emails | paste -sd' ' -)" + dokku_log_verbose "Denied emails: $(fn-ga-global-get-list denied-emails | paste -sd' ' -)" dokku_log_verbose "Session TTL: $(fn-ga-global-get session-ttl 24h)" dokku_log_verbose "Service port: 127.0.0.1:$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")" if fn-ga-service-running; then diff --git a/subcommands/unallow b/subcommands/unallow new file mode 100755 index 0000000..d52c24e --- /dev/null +++ b/subcommands/unallow @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-unallow() { + declare desc="remove a domain or address from the allow list" + local cmd="google-auth:unallow" + [[ "$1" == "$cmd" ]] && shift 1 + + [[ $# -gt 0 ]] || dokku_log_fail "usage: dokku google-auth:unallow (see: dokku google-auth:allow)" + + # Lowercase, drop any leading "@", and de-duplicate so the lockout check + # below counts each entry once. + local entries=() + mapfile -t entries < <(printf '%s\n' "$@" | tr '[:upper:]' '[:lower:]' | sed 's/^@//' | awk '!seen[$0]++') + + # Refuse before touching anything if this would leave the allow list empty, + # which locks everyone out of every enabled app. + local entry total present=0 + total=$(($(fn-ga-global-list-count allowed-domains) + $(fn-ga-global-list-count allowed-emails))) + for entry in "${entries[@]}"; do + fn-ga-global-list-contains allowed-domains "$entry" && present=$((present + 1)) + fn-ga-global-list-contains allowed-emails "$entry" && present=$((present + 1)) + done + if [[ $((total - present)) -le 0 ]]; then + dokku_log_fail "that would empty the allow list and lock everyone out; add the replacement first with: dokku google-auth:allow " + fi + + local removed + for entry in "${entries[@]}"; do + removed=false + if fn-ga-global-list-contains allowed-domains "$entry"; then + fn-ga-global-list-remove allowed-domains "$entry" + dokku_log_info1 "removed allowed domain: $entry" + removed=true + fi + if fn-ga-global-list-contains allowed-emails "$entry"; then + fn-ga-global-list-remove allowed-emails "$entry" + dokku_log_info1 "removed: $entry" + removed=true + fi + [[ "$removed" == "true" ]] || dokku_log_warn "$entry was not on the allow list" + done + + dokku_log_verbose "removed users lose access on their next request; existing sessions are not honored" + fn-ga-reload-service-config +} + +cmd-google-auth-unallow "$@" diff --git a/subcommands/undeny b/subcommands/undeny new file mode 100755 index 0000000..f2451ea --- /dev/null +++ b/subcommands/undeny @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -eo pipefail +[[ $DOKKU_TRACE ]] && set -x +source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions" + +cmd-google-auth-undeny() { + declare desc="remove an address from the deny list" + local cmd="google-auth:undeny" + [[ "$1" == "$cmd" ]] && shift 1 + + [[ $# -gt 0 ]] || dokku_log_fail "usage: dokku google-auth:undeny (see: dokku google-auth:deny)" + + local entry + for entry in "$@"; do + entry="${entry,,}" + if ! fn-ga-global-list-contains denied-emails "$entry"; then + dokku_log_warn "$entry was not on the deny list" + continue + fi + fn-ga-global-list-remove denied-emails "$entry" + dokku_log_info1 "removed from deny list: $entry" + # Undenying only stops the explicit block; the allow list still decides. + if ! fn-ga-global-list-contains allowed-emails "$entry" && + ! fn-ga-global-list-contains allowed-domains "${entry#*@}"; then + dokku_log_warn "$entry still cannot sign in — nothing on the allow list covers it (see: dokku google-auth:allow)" + fi + done + + fn-ga-reload-service-config +} + +cmd-google-auth-undeny "$@" diff --git a/test/access-list-test.sh b/test/access-list-test.sh new file mode 100755 index 0000000..a6c3254 --- /dev/null +++ b/test/access-list-test.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Exercises the allow/deny list helpers and the google-auth:allow / :unallow / +# :deny / :undeny subcommands against a fake dokku layout, and checks that the +# lists reach the env file the auth service reads. +set -eo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Fake dokku host layout. +export DOKKU_ROOT="$WORK/dokku-root" +export DOKKU_LIB_ROOT="$WORK/dokku-lib" +export PLUGIN_CORE_AVAILABLE_PATH="$WORK/nonexistent" # force built-in fallbacks +GLOBAL="$DOKKU_LIB_ROOT/data/google-auth/global" +mkdir -p "$GLOBAL" + +# Stub docker so the subcommands see the service as not running and never touch +# a real container. This test is about list handling, not container management. +mkdir -p "$WORK/bin" +printf '#!/bin/sh\nexit 1\n' >"$WORK/bin/docker" +chmod +x "$WORK/bin/docker" +export PATH="$WORK/bin:$PATH" + +# shellcheck disable=SC1091 +source "$ROOT/functions" + +fail() { + echo "FAIL: $*" 1>&2 + exit 1 +} + +ga() { + local sub="$1" + shift + "$ROOT/subcommands/$sub" "google-auth:$sub" "$@" +} + +# expect_output [args...] — asserts the subcommand's +# output (stdout + stderr) contains . Collects the output first rather +# than piping into grep, which would SIGPIPE the writer under pipefail. +expect_output() { + local needle="$1" out + shift + out="$(ga "$@" 2>&1 || true)" + grep -qF "$needle" <<<"$out" || fail "expected '$needle' in google-auth:$1 output, got: $out" +} + +# --- entry validation and classification --- +fn-ga-validate-list-entry "guest@partner.com" || fail "address should be a valid entry" +fn-ga-validate-list-entry "signal.org" || fail "domain should be a valid entry" +fn-ga-validate-list-entry "a@b.com,c@d.com" && fail "comma should be rejected (it splits the env var)" +fn-ga-validate-list-entry "not a domain" && fail "whitespace should be rejected" +fn-ga-validate-list-entry "" && fail "empty entry should be rejected" +[[ "$(fn-ga-allow-entry-kind "signal.org")" == "domain" ]] || fail "bare domain misclassified" +[[ "$(fn-ga-allow-entry-kind "@signal.org")" == "domain" ]] || fail "@domain misclassified" +[[ "$(fn-ga-allow-entry-kind "guest@partner.com")" == "email" ]] || fail "address misclassified" +echo "ok: entry validation and classification" + +# --- list helpers --- +fn-ga-global-list-add allowed-emails "a@x.com" +fn-ga-global-list-add allowed-emails "a@x.com" # idempotent +[[ "$(fn-ga-global-list-count allowed-emails)" -eq 1 ]] || fail "duplicate add should be a no-op" +fn-ga-global-list-contains allowed-emails "a@x.com" || fail "contains should find the entry" +fn-ga-global-list-contains allowed-emails "a@x.co" && fail "contains should match whole lines only" +fn-ga-global-list-remove allowed-emails "a@x.com" +[[ "$(fn-ga-global-list-count allowed-emails)" -eq 0 ]] || fail "remove should empty the list" +fn-ga-global-list-remove allowed-emails "nope@x.com" # missing entry is not an error +echo "ok: list helpers" + +# --- allow --- +ga allow >/dev/null || fail "listing an empty allow list should succeed" +ga allow Signal.org @Example.com Guest@Partner.com >/dev/null +grep -qx "signal.org" "$GLOBAL/allowed-domains" || fail "bare domain should be stored lowercased" +grep -qx "example.com" "$GLOBAL/allowed-domains" || fail "@domain should be stored without the @" +grep -qx "guest@partner.com" "$GLOBAL/allowed-emails" || fail "address should be stored lowercased" +ga allow "a@b.com,c@d.com" 2>/dev/null && fail "allow should reject an entry with a comma" +ga allow "localhost" 2>/dev/null && fail "allow should reject a domain with no dot" +[[ "$(fn-ga-global-list-count allowed-domains)" -eq 2 ]] || fail "rejected entries should not be stored" +echo "ok: allow" + +# --- deny wins, and contradictions are surfaced --- +ga deny Former@Signal.org >/dev/null +grep -qx "former@signal.org" "$GLOBAL/denied-emails" || fail "deny should store the address lowercased" +ga deny signal.org 2>/dev/null && fail "deny should reject a bare domain" +ga deny guest@partner.com >/dev/null +# guest@partner.com is on both lists now; allow must say deny wins. +expect_output "deny list" allow guest@partner.com +echo "ok: deny" + +# --- undeny --- +ga undeny Guest@Partner.com >/dev/null +fn-ga-global-list-contains denied-emails "guest@partner.com" && fail "undeny should remove the address" +expect_output "was not on the deny list" undeny never@denied.com +ga deny stranger@elsewhere.com >/dev/null +expect_output "still cannot sign in" undeny stranger@elsewhere.com +echo "ok: undeny" + +# --- unallow, including the lockout guardrail --- +ga unallow @Example.com >/dev/null +fn-ga-global-list-contains allowed-domains "example.com" && fail "unallow should remove the domain" +expect_output "was not on the allow list" unallow absent@nowhere.com + +# signal.org + guest@partner.com remain; removing both (with a duplicate to +# check de-duplication) must be refused, and must not change anything. +before="$(cat "$GLOBAL/allowed-domains" "$GLOBAL/allowed-emails")" +ga unallow signal.org guest@partner.com SIGNAL.ORG 2>/dev/null && + fail "unallow should refuse to empty the allow list" +[[ "$(cat "$GLOBAL/allowed-domains" "$GLOBAL/allowed-emails")" == "$before" ]] || + fail "a refused unallow must leave the lists untouched" +ga unallow guest@partner.com >/dev/null || fail "unallow should still remove a non-final entry" +[[ "$(fn-ga-global-list-count allowed-domains)" -eq 1 ]] || fail "signal.org should remain allowed" +echo "ok: unallow and lockout guardrail" + +# --- configure's replace-the-list flags stay consistent with the above --- +"$ROOT/subcommands/configure" google-auth:configure \ + --allow-domain signal.org --deny-email one@signal.org --deny-email two@signal.org >/dev/null 2>&1 || true +[[ "$(fn-ga-global-list-count denied-emails)" -eq 2 ]] || fail "--deny-email should replace the deny list" +"$ROOT/subcommands/configure" google-auth:configure --clear-deny-emails >/dev/null 2>&1 || true +[[ "$(fn-ga-global-list-count denied-emails)" -eq 0 ]] || fail "--clear-deny-emails should empty the deny list" +echo "ok: configure flags" + +# --- the lists reach the service env file --- +ga deny former@signal.org >/dev/null +ga allow guest@partner.com >/dev/null +fn-ga-write-env-file +ENV_FILE="$DOKKU_LIB_ROOT/data/google-auth/service.env" +grep -qx "GOOGLE_AUTH_ALLOWED_DOMAINS=signal.org" "$ENV_FILE" || fail "env file missing allowed domains" +grep -qx "GOOGLE_AUTH_ALLOWED_EMAILS=guest@partner.com" "$ENV_FILE" || fail "env file missing allowed emails" +grep -qx "GOOGLE_AUTH_DENIED_EMAILS=former@signal.org" "$ENV_FILE" || fail "env file missing denied emails" +echo "ok: service env file" + +echo "ALL ACCESS LIST TESTS PASSED"