Files
dokkku-google-auth/test/access-list-test.sh
T

264 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Exercises the allow/deny list helpers and the google-auth:allow / :unallow /
# :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, 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
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.
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 <needle> <subcommand> [args...] — asserts the subcommand's
# output (stdout + stderr) contains <needle>. 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"
}
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"
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, 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"
# --- 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"
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"
# --- 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 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: 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
# 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")"
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 --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 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-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-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"
# --- 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="$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"
# --- a running service only counts if it can really read per-app lists ---
# It takes both the bind mount and the env var naming it. A container recreated
# from a service.env written before per-app lists existed has the mount but not
# the variable, ignores every per-app list, and still looks healthy — so the
# check must not be satisfied by the mount alone.
mkdir -p "$WORK/bin-docker"
cat >"$WORK/bin-docker/docker" <<'EOF'
#!/bin/sh
# Stands in for `docker container inspect`, replaying a canned inspection.
if [ "$1" = "container" ] && [ "$2" = "inspect" ]; then
cat "$DOCKER_INSPECT_FIXTURE"
exit 0
fi
exit 0
EOF
chmod +x "$WORK/bin-docker/docker"
# Runs the check against one canned inspection, in a subshell so the stub and
# its fixture do not leak into the rest of the file.
reads_app_lists() (
export DOCKER_INSPECT_FIXTURE="$WORK/inspect-fixture"
printf '%s\n' "$@" >"$DOCKER_INSPECT_FIXTURE"
PATH="$WORK/bin-docker:$PATH"
fn-ga-service-reads-app-lists
)
reads_app_lists "mount=/data/apps" "env=GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" "env=GOOGLE_AUTH_CLIENT_ID=x" ||
fail "a container with both the mount and the env var should read per-app lists"
reads_app_lists "mount=/data/apps" "env=GOOGLE_AUTH_CLIENT_ID=x" &&
fail "a container with the mount but no GOOGLE_AUTH_APP_CONFIG_DIR ignores per-app lists"
reads_app_lists "env=GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" &&
fail "a container with the env var but no mount has nothing to read"
reads_app_lists "" && fail "a container with neither should not count"
echo "ok: per-app list readiness check"
# --- 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"