Files
dokkku-google-auth/test/access-list-test.sh
T
2026-08-06 22:52:15 -04:00

324 lines
17 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"
[[ "$(stat -c '%a' "$ENV_FILE")" == "600" ]] || fail "the env file holds the client secret and must stay 0600"
# Writing the env file tightens the umask to protect that secret. It must not
# tighten anything else: google-auth:enable writes the env file (starting the
# service) and then creates the app's config directory, which the unprivileged
# service has to be able to traverse.
fn-ga-app-set-enabled umask-app true
[[ "$(stat -c '%a' "$DATA/apps/umask-app")" == "711" ]] ||
fail "a directory created after the env file must still be traversable by the container uid"
fn-ga-exclude-add umask-app /api/hook
[[ "$(stat -c '%a' "$DATA/apps/umask-app")" == "711" ]] ||
fail "adding an exclusion must leave the app directory traversable"
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 the bind mount, the env var naming it, and a binary that reads both.
# Every combination that is missing one still looks healthy from outside while
# quietly falling back to the global lists, so none of them may satisfy the
# check.
mkdir -p "$WORK/bin-docker"
cat >"$WORK/bin-docker/docker" <<'EOF'
#!/bin/sh
# Stands in for the docker CLI, replaying canned inspections.
case "$1 $2" in
"container inspect") cat "$DOCKER_INSPECT_FIXTURE" ;;
"image inspect") printf '%s\n' "$DOCKER_IMAGE_FIXTURE" ;;
esac
exit 0
EOF
cat >"$WORK/bin-docker/curl" <<'EOF'
#!/bin/sh
# Stands in for the healthz probe against the running service. An empty
# fixture means the service did not answer.
[ -s "$CURL_BODY_FIXTURE" ] || exit 7
cat "$CURL_BODY_FIXTURE"
EOF
chmod +x "$WORK/bin-docker/docker" "$WORK/bin-docker/curl"
# Runs the check against one canned healthz body and one canned container
# inspection, in a subshell so the stubs and fixtures do not leak into the rest
# of the file.
# shellcheck disable=SC2030,SC2031 # the subshell is what keeps the stubs local
reads_app_lists() (
local healthz="$1"
shift
export DOCKER_INSPECT_FIXTURE="$WORK/inspect-fixture"
export CURL_BODY_FIXTURE="$WORK/healthz-fixture"
export DOCKER_IMAGE_FIXTURE=""
printf '%s\n' "$@" >"$DOCKER_INSPECT_FIXTURE"
printf '%s' "$healthz" >"$CURL_BODY_FIXTURE"
PATH="$WORK/bin-docker:$PATH"
fn-ga-service-reads-app-lists
)
CURRENT_HEALTHZ='{"ok":true,"app_config_dir":"/data/apps"}'
# The reply from a build made before per-app lists existed.
LEGACY_HEALTHZ='ok'
reads_app_lists "$CURRENT_HEALTHZ" "mount=/data/apps" "env=GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" "env=GOOGLE_AUTH_CLIENT_ID=x" ||
fail "a container with the mount, the env var, and a current binary should read per-app lists"
# The case the plugin used to miss entirely: docker metadata is perfect because
# the current shell created the container, but the image it started is old, so
# the binary inside ignores the mount and every per-app list with it.
reads_app_lists "$LEGACY_HEALTHZ" "mount=/data/apps" "env=GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" "env=GOOGLE_AUTH_CLIENT_ID=x" &&
fail "a container running a pre-per-app-lists binary must not count, however well configured"
reads_app_lists '{"ok":true,"app_config_dir":"/somewhere/else"}' "mount=/data/apps" "env=GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" &&
fail "a service reading some other directory is not reading the mounted one"
reads_app_lists "$CURRENT_HEALTHZ" "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 "$CURRENT_HEALTHZ" "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 "$CURRENT_HEALTHZ" "" && fail "a container with neither should not count"
# Unreachable service: with no answer to go on, an image that cannot be shown
# to match the plugin's source is assumed stale rather than assumed good.
reads_app_lists "" "mount=/data/apps" "env=GOOGLE_AUTH_APP_CONFIG_DIR=/data/apps" &&
fail "an unanswering service with an unverifiable image should not count"
echo "ok: per-app list readiness check"
# --- the built image has to match the source the plugin is running from ---
# The shell half of the plugin upgrades as soon as the files change; the binary
# only upgrades when the image is rebuilt. Reusing an image just because one
# exists is what lets a per-app list be configured, reported, and ignored.
# shellcheck disable=SC2030,SC2031 # the subshell is what keeps the stubs local
image_current() (
export DOCKER_IMAGE_FIXTURE="$1"
PATH="$WORK/bin-docker:$PATH"
fn-ga-image-current
)
SOURCE_HASH="$(fn-ga-source-hash)"
[[ -n "$SOURCE_HASH" ]] || fail "source hash should not be empty"
[[ "$SOURCE_HASH" == "$(fn-ga-source-hash)" ]] || fail "source hash should be stable across calls"
image_current "$SOURCE_HASH" || fail "an image labelled with the current source hash is current"
image_current "0000000000000000" && fail "an image built from other source is not current"
image_current "<no value>" && fail "an unlabelled image (built before this check) is not current"
image_current "" && fail "an image with no label value is not current"
echo "ok: service image tracks the plugin source"
# --- 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"