264 lines
14 KiB
Bash
Executable File
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"
|