Allow per-app allow/deny.

This commit is contained in:
Greyson Parrelli
2026-08-06 13:53:24 -04:00
parent c15a1cc14c
commit 8ce2919627
19 changed files with 1151 additions and 187 deletions
+150 -55
View File
@@ -1,19 +1,21 @@
#!/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.
# :deny / :undeny subcommands against a fake dokku layout — both the global
# scope and per-app scopes — and checks that global lists reach the env file
# while per-app lists land where the container's bind mount expects them.
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# Fake dokku host layout.
# Fake dokku host layout, with two apps that exist as far as dokku is concerned.
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"
DATA="$DOKKU_LIB_ROOT/data/google-auth"
GLOBAL="$DATA/global"
mkdir -p "$GLOBAL" "$DOKKU_ROOT/my-app" "$DOKKU_ROOT/other-app"
# 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.
@@ -46,6 +48,13 @@ expect_output() {
grep -qF "$needle" <<<"$out" || fail "expected '$needle' in google-auth:$1 output, got: $out"
}
expect_fails() {
local why="$1"
shift
ga "$@" >/dev/null 2>&1 && fail "$why"
return 0
}
# --- 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"
@@ -57,77 +66,163 @@ fn-ga-validate-list-entry "" && fail "empty entry should be rejected"
[[ "$(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"
# --- list helpers, in both scopes ---
for scope in global my-app; do
fn-ga-list-add "$scope" allowed-emails "a@x.com"
fn-ga-list-add "$scope" allowed-emails "a@x.com" # idempotent
[[ "$(fn-ga-list-count "$scope" allowed-emails)" -eq 1 ]] || fail "$scope: duplicate add should be a no-op"
fn-ga-list-contains "$scope" allowed-emails "a@x.com" || fail "$scope: contains should find the entry"
fn-ga-list-contains "$scope" allowed-emails "a@x.co" && fail "$scope: contains should match whole lines only"
fn-ga-list-remove "$scope" allowed-emails "a@x.com"
[[ "$(fn-ga-list-count "$scope" allowed-emails)" -eq 0 ]] || fail "$scope: remove should empty the list"
fn-ga-list-remove "$scope" allowed-emails "nope@x.com" # missing entry is not an error
done
[[ "$(fn-ga-list-file global allowed-emails)" == "$GLOBAL/allowed-emails" ]] || fail "global list path wrong"
[[ "$(fn-ga-list-file my-app allowed-emails)" == "$DATA/apps/my-app/allowed-emails" ]] || fail "per-app list path wrong"
echo "ok: list helpers in both scopes"
# --- allow ---
ga allow >/dev/null || fail "listing an empty allow list should succeed"
ga allow Signal.org @Example.com Guest@Partner.com >/dev/null
# --- scope resolution ---
expect_fails "a missing scope should be rejected" allow
expect_fails "an unknown flag should be rejected" allow --oops x
expect_fails "an app that does not exist should be rejected" allow ghost-app a@b.com
expect_output "unknown flag" allow --oops x
expect_output "does not exist" allow ghost-app a@b.com
echo "ok: scope resolution"
# --- global allow ---
ga allow --global 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"
expect_fails "allow should reject an entry with a comma" allow --global "a@b.com,c@d.com"
expect_fails "allow should reject a domain with no dot" allow --global localhost
[[ "$(fn-ga-list-count global allowed-domains)" -eq 2 ]] || fail "rejected entries should not be stored"
echo "ok: global 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"
# --- per-app allow lives in the app's own directory and warns about the switch ---
expect_output "no longer uses the global allow list" allow my-app ceo@signal.org
grep -qx "ceo@signal.org" "$DATA/apps/my-app/allowed-emails" || fail "per-app entry should be stored under apps/<app>"
grep -qx "ceo@signal.org" "$GLOBAL/allowed-emails" && fail "a per-app entry must not touch the global list"
expect_output "(none — other-app uses the global allow list)" allow other-app
# The warning is only for the first entry, when the app stops inheriting.
out="$(ga allow my-app cto@signal.org 2>&1)"
grep -qF "no longer uses the global allow list" <<<"$out" &&
fail "the inheritance warning should only fire on the first entry"
echo "ok: per-app allow"
# --- the service must be able to read per-app lists through its bind mount ---
[[ "$(stat -c '%a' "$DATA/apps")" == "711" ]] || fail "apps/ must be traversable by the container uid"
[[ "$(stat -c '%a' "$DATA/apps/my-app")" == "711" ]] || fail "apps/<app>/ must be traversable by the container uid"
[[ "$(stat -c '%a' "$DATA/apps/my-app/allowed-emails")" == "644" ]] || fail "per-app lists must be readable by the container uid"
[[ "$(stat -c '%a' "$GLOBAL/allowed-emails")" == "600" ]] || fail "global lists should stay 0600"
echo "ok: per-app file modes"
# --- deny is per scope, and a global denial cannot be lifted by an app ---
ga deny --global former@signal.org >/dev/null
ga deny my-app bob@signal.org >/dev/null
grep -qx "bob@signal.org" "$DATA/apps/my-app/denied-emails" || fail "per-app denial should be stored under apps/<app>"
grep -qx "bob@signal.org" "$GLOBAL/denied-emails" && fail "a per-app denial must not touch the global list"
expect_fails "deny should reject a bare domain" deny my-app signal.org
# Listing an app's deny list also shows the global entries that apply to it.
expect_output "former@signal.org (global)" deny my-app
ga deny my-app former@signal.org >/dev/null
expect_output "still denied globally" undeny my-app former@signal.org
expect_output "denied globally, which no app can override" undeny other-app former@signal.org
echo "ok: deny scoping"
# --- allow warns when a deny list (either scope) will win ---
expect_output "on a deny list, which wins" allow my-app bob@signal.org
expect_output "on a deny list, which wins" allow --global former@signal.org
echo "ok: deny-wins warnings"
# --- 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
ga undeny my-app bob@signal.org >/dev/null
fn-ga-list-contains my-app denied-emails "bob@signal.org" && fail "undeny should remove the address"
expect_output "was not on the deny list" undeny my-app never@denied.com
ga deny other-app stranger@elsewhere.com >/dev/null
expect_output "still cannot sign in" undeny other-app 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
# --- unallow: the lockout guardrail is global-only ---
ga unallow --global @Example.com >/dev/null
fn-ga-list-contains global allowed-domains "example.com" && fail "unallow should remove the domain"
expect_output "was not on the allow list" unallow --global 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.
# Removing every remaining global entry (with a duplicate, to check
# de-duplication) must be refused and change nothing.
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"
expect_fails "unallow should refuse to empty the global allow list" \
unallow --global signal.org guest@partner.com former@signal.org SIGNAL.ORG
[[ "$(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"
ga unallow --global guest@partner.com >/dev/null || fail "unallow should still remove a non-final entry"
[[ "$(fn-ga-list-count global allowed-domains)" -eq 1 ]] || fail "signal.org should remain allowed"
# Emptying an app's list is allowed: it falls back to the global one.
expect_output "now uses the global allow list" \
unallow my-app ceo@signal.org cto@signal.org bob@signal.org
[[ "$(fn-ga-list-count my-app allowed-emails)" -eq 0 ]] || fail "the app's allow list should be empty"
[[ "$(fn-ga-list-count global allowed-domains)" -eq 1 ]] || fail "the global list must survive an app's unallow"
echo "ok: unallow and lockout guardrail"
# --- configure's replace-the-list flags stay consistent with the above ---
# --- configure's replace-the-list flags still drive the GLOBAL lists ---
"$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"
[[ "$(fn-ga-list-count global denied-emails)" -eq 2 ]] || fail "--deny-email should replace the global 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"
[[ "$(fn-ga-list-count global denied-emails)" -eq 0 ]] || fail "--clear-deny-emails should empty the global deny list"
[[ "$(fn-ga-list-count my-app denied-emails)" -eq 0 ]] || fail "configure should not touch per-app lists"
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
# --- global lists reach the env file; per-app lists reach the mount ---
# Start from a known set so the env file can be asserted exactly.
ga unallow --global former@signal.org >/dev/null 2>&1 || true
ga deny --global former@signal.org >/dev/null
ga allow --global guest@partner.com >/dev/null
ga allow my-app ceo@signal.org >/dev/null 2>&1
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"
ENV_FILE="$DATA/service.env"
grep -qx "GOOGLE_AUTH_ALLOWED_DOMAINS=signal.org" "$ENV_FILE" || fail "env file missing global allowed domains"
grep -qx "GOOGLE_AUTH_ALLOWED_EMAILS=guest@partner.com" "$ENV_FILE" || fail "env file missing global allowed emails"
grep -qx "GOOGLE_AUTH_DENIED_EMAILS=former@signal.org" "$ENV_FILE" || fail "env file missing global denied emails"
grep -qx "GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" "$ENV_FILE" || fail "env file must point the service at the mount"
grep -q "ceo@signal.org" "$ENV_FILE" && fail "per-app entries must not be baked into the env file"
echo "ok: service env file"
# --- report shows both scopes, enabled or not ---
out="$("$ROOT/subcommands/report" google-auth:report my-app)"
grep -qF "ceo@signal.org" <<<"$out" || fail "report should show the app's allow entries: $out"
grep -qF "replaces the global allow list" <<<"$out" || fail "report should say the app's list replaces global: $out"
out="$("$ROOT/subcommands/report" google-auth:report other-app)"
grep -qF "inherits the global allow list" <<<"$out" || fail "report should say an app inherits: $out"
echo "ok: report"
# --- setting an app's list refreshes its nginx config ---
# Per-app lists depend on nginx stamping the app name, so a config written
# before that header existed has to be rewritten; otherwise the app would
# silently fall back to the global lists.
cat >"$DOKKU_ROOT/my-app/nginx.conf" <<'EOF'
upstream my-app-5000 {
server 172.17.0.3:5000;
}
EOF
fn-ga-app-set-enabled my-app true
APP_CONF="$DOKKU_ROOT/my-app/nginx.conf.d/google-auth.conf"
mkdir -p "$(dirname "$APP_CONF")"
echo "# stale config from an older plugin version" >"$APP_CONF"
ga allow my-app auditor@signal.org >/dev/null 2>&1
grep -q 'proxy_set_header X-Google-Auth-App "my-app";' "$APP_CONF" ||
fail "changing an app's list should rewrite its nginx config to stamp the app name"
echo "ok: per-app list change refreshes the nginx config"
# --- lifecycle triggers carry per-app lists ---
"$ROOT/post-app-rename" my-app renamed-app
[[ ! -d "$DATA/apps/my-app" ]] || fail "rename should move the app's directory"
fn-ga-list-contains renamed-app allowed-emails "ceo@signal.org" || fail "rename should keep the app's allow list"
"$ROOT/post-app-clone" renamed-app clone-app
fn-ga-list-contains clone-app allowed-emails "ceo@signal.org" || fail "clone should copy the app's allow list"
"$ROOT/post-delete" clone-app
[[ ! -d "$DATA/apps/clone-app" ]] || fail "delete should remove the app's directory"
echo "ok: lifecycle triggers"
echo "ALL ACCESS LIST TESTS PASSED"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Checks the two shapes of help output: `dokku help` must get a single summary
# line (so the plugin doesn't spam the global command list), while
# `dokku google-auth:help` documents every subcommand the plugin ships.
set -eo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fail() {
echo "FAIL: $*" 1>&2
exit 1
}
# --- `dokku help`: exactly one "name, description" line ---
top="$("$ROOT/commands" help)"
[[ "$(wc -l <<<"$top")" -eq 1 ]] ||
fail "plain 'dokku help' should emit one summary line, got $(wc -l <<<"$top")"
[[ "$top" =~ ^[[:space:]]+google-auth,\ .+ ]] ||
fail "summary line should read ' google-auth, <description>', got: $top"
echo "ok: dokku help shows one line"
# --- `dokku google-auth:help`: the full list ---
full="$("$ROOT/commands" google-auth:help)"
grep -q 'Usage: dokku google-auth\[:COMMAND\]' <<<"$full" || fail "help should print a usage line"
# Every subcommand file (except the no-argument default) must be documented.
for path in "$ROOT"/subcommands/*; do
sub="$(basename "$path")"
[[ "$sub" == "default" ]] && continue
grep -q "google-auth:$sub" <<<"$full" ||
fail "google-auth:$sub exists in subcommands/ but is undocumented in google-auth:help"
done
echo "ok: every subcommand is documented"
# Descriptions must not contain commas: the table is rendered with
# `column -s,` so a comma splits the line into a bogus third column.
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]+google-auth: ]] || continue
[[ "$(tr -cd ',' <<<"$line" | wc -c)" -le 1 ]] ||
fail "help entry has more than one comma, which breaks the column layout: $line"
done < <(sed -n '/^help_content$/q;p' "$ROOT/commands")
echo "ok: help entries have no stray commas"
# --- unknown subcommands still fall through to dokku's dispatcher ---
set +e
"$ROOT/commands" google-auth:does-not-exist >/dev/null 2>&1
code=$?
set -e
[[ "$code" -eq 10 ]] || fail "unknown command should exit 10 (DOKKU_NOT_IMPLEMENTED_EXIT), got $code"
echo "ok: unknown subcommand exits 10"
echo "ALL HELP TESTS PASSED"
+81
View File
@@ -60,6 +60,32 @@ grep -q 'proxy_set_header X-Forwarded-Email \$google_auth_email;' "$CONF" || fai
grep -q 'proxy_set_header X-Forwarded-Email "";' "$CONF" || fail "excluded paths should strip identity headers"
echo "ok: conf contents"
# --- the app name must be stamped on every request that reaches the service ---
# Checking the invariant rather than a fixed list of locations: any location
# that proxies to the auth service must set X-Google-Auth-App to this app, or
# the service would fall back to the global lists (silently widening access for
# an app whose own allow list is narrower). Any location that proxies to the
# app must blank it, so clients cannot pass one through.
awk -v app="$APP" '
/^location/ { block = $0; inside = 1; to_service = 0; to_app = 0; stamped = 0; blanked = 0; next }
inside && /^}/ {
if (to_service && !stamped) { printf "location reaching the auth service without the app header: %s\n", block; bad = 1 }
if (to_app && !blanked) { printf "location reaching the app without blanking the app header: %s\n", block; bad = 1 }
inside = 0; next
}
inside {
if ($0 ~ /proxy_pass http:\/\/127\.0\.0\.1:/) to_service = 1
if ($0 ~ /proxy_pass http:\/\/myapp-5000;/) to_app = 1
if ($0 == sprintf(" proxy_set_header X-Google-Auth-App \"%s\";", app)) stamped = 1
if ($0 == " proxy_set_header X-Google-Auth-App \"\";") blanked = 1
}
END { exit bad }
' "$CONF" || fail "X-Google-Auth-App is not handled consistently across locations"
# Guard the guard: the awk above must actually see both kinds of location.
[[ "$(grep -c 'proxy_set_header X-Google-Auth-App "myapp";' "$CONF")" -eq 3 ]] ||
fail "expected the app header on the three auth-service locations"
echo "ok: app name stamped for the auth service, blanked for the app"
# --- write/remove behavior ---
[[ "$(fn-ga-write-conf "$APP")" == "changed" ]] || fail "first write should report changed"
[[ "$(fn-ga-write-conf "$APP")" == "unchanged" ]] || fail "second write should report unchanged"
@@ -100,6 +126,61 @@ EOF
else
fail "nginx -t rejected the generated config"
fi
# --- run it for real, and try to spoof the app name ---
# Per-app access lists are only as trustworthy as X-Google-Auth-App, so prove
# with a live nginx that a client-supplied value never reaches the auth
# service. Stand-ins for the two backends run inside the same nginx:
# :2999 pretends to be the auth service and reports the app name it saw,
# :8081 pretends to be the app and echoes what it was forwarded.
cat >"$WORK/nginx-runtime.conf" <<EOF
events {}
http {
access_log off;
upstream myapp-5000 {
server 127.0.0.1:8081;
}
server {
listen 8080 default_server;
include /work/google-auth.conf;
}
server {
listen 2999;
location ${GOOGLE_AUTH_ROUTE_PREFIX}/verify {
add_header X-Auth-Request-Email "app-seen=\$http_x_google_auth_app" always;
return 204;
}
location / { return 404; }
}
server {
listen 8081;
location / {
default_type text/plain;
return 200 "forwarded-email=\$http_x_forwarded_email\n";
}
}
}
EOF
runtime_out="$(docker run --rm -v "$WORK:/work:ro" nginx:alpine sh -c '
nginx -c /work/nginx-runtime.conf -g "daemon off;" &
i=0
while [ $i -lt 40 ]; do
wget -q -O /dev/null http://127.0.0.1:8081/ 2>/dev/null && break
i=$((i + 1)); sleep 0.1
done
echo "--- plain ---"
wget -q -O - http://127.0.0.1:8080/some/page 2>&1
echo "--- spoofed ---"
wget -q -O - --header "X-Google-Auth-App: spoofed-app" http://127.0.0.1:8080/some/page 2>&1
' 2>/dev/null)" || fail "could not run the live nginx spoofing check: $runtime_out"
if [[ "$(grep -c 'forwarded-email=app-seen=myapp' <<<"$runtime_out")" -ne 2 ]]; then
fail "nginx should have told the auth service the app name on both requests, got: $runtime_out"
fi
if grep -q "spoofed-app" <<<"$runtime_out"; then
fail "a client-supplied X-Google-Auth-App reached the auth service: $runtime_out"
fi
echo "ok: live nginx sends the real app name and discards a spoofed one"
else
echo "skip: docker unavailable, skipped real nginx validation"
fi