Add explict allow deny commands.

This commit is contained in:
Greyson Parrelli
2026-08-06 10:45:45 -04:00
parent a3f0f8a1be
commit c15a1cc14c
15 changed files with 566 additions and 13 deletions
+57 -4
View File
@@ -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 <d>` | allow any verified `*@d` account (repeatable; replaces the stored list) | — |
| `--allow-email <e>` | allow a specific address, e.g. an outside collaborator (repeatable) | — |
| `--deny-email <e>` | 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 <dur>` | how long a sign-in lasts (`24h`, `72h`, `30m`, …) | `24h` |
| `--cookie-name <n>` | session cookie name | `_google_auth` |
| `--port <p>` | 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
+26 -1
View File
@@ -6,7 +6,11 @@ case "$1" in
help | google-auth:help)
help_content() {
cat <<help_content
google-auth:configure [options], Configure the shared Google OAuth service (run once)
google-auth:configure [options], Set Google credentials and who may sign in (re-run any time to change)
google-auth:allow [<domain|address>...], Let a domain or address sign in (no arguments lists the allow list)
google-auth:unallow <domain|address...>, Remove a domain or address from the allow list
google-auth:deny [<address>...], Block an address even if the allow list covers it (no arguments lists the deny list)
google-auth:undeny <address...>, Remove an address from the deny list
google-auth:enable <app>, Require Google sign-in for all requests to <app>
google-auth:disable <app>, Remove Google sign-in from <app>
google-auth:exclude <app> <pattern...>, 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 <id> Google OAuth client id'
echo ' --client-secret <secret> Google OAuth client secret'
echo ' --auth-host <host> the one host registered as a redirect URI with Google'
echo ' --allow-domain <domain> allow any verified account at <domain> (repeatable)'
echo ' --allow-email <address> allow one specific address (repeatable)'
echo ' --deny-email <address> block one address, even if a rule above allows it (repeatable)'
echo ' --clear-deny-emails empty the deny list'
echo ' --session-ttl <duration> how long a sign-in lasts (default 24h)'
echo ' --cookie-name <name> session cookie name (default _google_auth)'
echo ' --port <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
+62 -1
View File
@@ -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" <<EOF
@@ -434,6 +494,7 @@ GOOGLE_AUTH_COOKIE_SECRET=$(fn-ga-global-get cookie-secret)
GOOGLE_AUTH_AUTH_HOST=$(fn-ga-global-get auth-host)
GOOGLE_AUTH_ALLOWED_DOMAINS=$domains
GOOGLE_AUTH_ALLOWED_EMAILS=$emails
GOOGLE_AUTH_DENIED_EMAILS=$denied
GOOGLE_AUTH_COOKIE_NAME=$(fn-ga-global-get cookie-name _google_auth)
GOOGLE_AUTH_SESSION_TTL=$(fn-ga-global-get session-ttl 24h)
GOOGLE_AUTH_ALLOW_INSECURE=$(fn-ga-global-get allow-insecure false)
+9 -3
View File
@@ -33,12 +33,17 @@ type Config struct {
// has google-auth enabled).
AuthHost string
// AllowedDomains / AllowedEmails control who may sign in. An email is
// accepted if its domain is in AllowedDomains or the full address is in
// AllowedEmails.
// AllowedDomains / AllowedEmails are the allowlist: an email is accepted
// if its domain is in AllowedDomains or the full address is in
// AllowedEmails. Anything else is rejected.
AllowedDomains []string
AllowedEmails []string
// DeniedEmails is the denylist. It is checked before the allowlist and
// always wins, so a single address can be revoked without dropping the
// whole domain it belongs to.
DeniedEmails []string
CookieName string
SessionTTL time.Duration
ListenAddr string
@@ -79,6 +84,7 @@ func ConfigFromEnv() (Config, error) {
cfg.AllowedDomains = append(cfg.AllowedDomains, strings.TrimPrefix(d, "@"))
}
cfg.AllowedEmails = splitList(os.Getenv("GOOGLE_AUTH_ALLOWED_EMAILS"))
cfg.DeniedEmails = splitList(os.Getenv("GOOGLE_AUTH_DENIED_EMAILS"))
return cfg, cfg.validate()
}
+4
View File
@@ -47,6 +47,7 @@ func TestConfigFromEnv(t *testing.T) {
t.Setenv("GOOGLE_AUTH_AUTH_HOST", "HTTPS://Auth.Example.com/")
t.Setenv("GOOGLE_AUTH_ALLOWED_DOMAINS", "Signal.org, @example.com")
t.Setenv("GOOGLE_AUTH_ALLOWED_EMAILS", "Guest@Partner.com")
t.Setenv("GOOGLE_AUTH_DENIED_EMAILS", "Former@Signal.org example@example.com")
t.Setenv("GOOGLE_AUTH_SESSION_TTL", "48h")
cfg, err := ConfigFromEnv()
@@ -62,6 +63,9 @@ func TestConfigFromEnv(t *testing.T) {
if len(cfg.AllowedEmails) != 1 || cfg.AllowedEmails[0] != "guest@partner.com" {
t.Errorf("AllowedEmails = %v", cfg.AllowedEmails)
}
if len(cfg.DeniedEmails) != 2 || cfg.DeniedEmails[0] != "former@signal.org" || cfg.DeniedEmails[1] != "example@example.com" {
t.Errorf("DeniedEmails = %v", cfg.DeniedEmails)
}
if cfg.SessionTTL != 48*time.Hour {
t.Errorf("SessionTTL = %v", cfg.SessionTTL)
}
+62
View File
@@ -360,6 +360,68 @@ func TestEmailAllowed(t *testing.T) {
}
}
// The denylist outranks both an allowed domain and an explicitly allowed
// address.
func TestEmailDenied(t *testing.T) {
s := newTestServer(t, "http://unused.invalid")
s.cfg.AllowedEmails = []string{"guest@partner.com"}
s.cfg.DeniedEmails = []string{"former@signal.org", "guest@partner.com"}
cases := map[string]bool{
"greyson@signal.org": true,
"former@signal.org": false,
"FORMER@SIGNAL.ORG": false,
"guest@partner.com": false,
}
for email, want := range cases {
if got := s.emailAllowed(email); got != want {
t.Errorf("emailAllowed(%q) = %v, want %v", email, got, want)
}
}
}
func TestCallbackRejectsDeniedEmail(t *testing.T) {
google := fakeGoogle(t, goodClaims())
defer google.Close()
s := newTestServer(t, google.URL)
s.cfg.DeniedEmails = []string{"greyson@signal.org"}
state := mustState(t, s, appHost, "/")
r := httptest.NewRequest("GET",
"http://"+authHost+RoutePrefix+"/callback?code=good-code&state="+url.QueryEscape(state), nil)
w := do(s.Routes(), r)
if w.Code != http.StatusForbidden {
t.Fatalf("got %d, want 403", w.Code)
}
if !strings.Contains(w.Body.String(), "not allowed") {
t.Fatalf("body should explain denial: %s", w.Body.String())
}
}
// Denying an address invalidates the sessions it already holds, rather than
// waiting for them to expire.
func TestDenylistInvalidatesExistingSession(t *testing.T) {
s := newTestServer(t, "http://unused.invalid")
sess := sessionClaims{Email: "greyson@signal.org", User: "1", Host: appHost,
Exp: time.Now().Add(time.Hour).Unix()}
val, err := s.box.seal("session", sess)
if err != nil {
t.Fatal(err)
}
newVerify := func() *http.Request {
r := httptest.NewRequest("GET", "http://"+appHost+RoutePrefix+"/verify", nil)
r.AddCookie(&http.Cookie{Name: "_google_auth", Value: val})
return r
}
if w := do(s.Routes(), newVerify()); w.Code != http.StatusOK {
t.Fatalf("before denial: got %d, want 200", w.Code)
}
s.cfg.DeniedEmails = []string{"greyson@signal.org"}
if w := do(s.Routes(), newVerify()); w.Code != http.StatusUnauthorized {
t.Fatalf("after denial: got %d, want 401", w.Code)
}
}
func TestFlexClaims(t *testing.T) {
var tok idToken
payload := `{"aud":["a","b"],"email_verified":"true","iss":"accounts.google.com","exp":99}`
+11
View File
@@ -286,11 +286,22 @@ func (s *Server) sessionFromRequest(r *http.Request) (*sessionClaims, bool) {
if sess.Host != requestHost(r) {
return nil, false
}
// Re-check authorization on every request so allow/deny list changes take
// effect immediately instead of whenever existing sessions happen to
// expire.
if !s.emailAllowed(sess.Email) {
return nil, false
}
return &sess, true
}
// emailAllowed applies the denylist first (it always wins), then the
// allowlist: an address must match an allowed email or an allowed domain.
func (s *Server) emailAllowed(email string) bool {
email = strings.ToLower(email)
if slices.Contains(s.cfg.DeniedEmails, email) {
return false
}
if slices.Contains(s.cfg.AllowedEmails, email) {
return true
}
+6 -2
View File
@@ -24,16 +24,20 @@ run = 'test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)'
[tasks.shellcheck]
description = "Lint all plugin shell scripts"
run = "shellcheck -x functions commands install nginx-pre-reload core-post-deploy post-delete post-app-rename post-app-clone subcommands/* test/nginx-conf-test.sh"
run = "shellcheck -x functions commands install nginx-pre-reload core-post-deploy post-delete post-app-rename post-app-clone subcommands/* test/*.sh"
[tasks.test-nginx-conf]
description = "Generate an nginx config from a fake app and validate it with real nginx (docker)"
run = "test/nginx-conf-test.sh"
[tasks.test-access-lists]
description = "Exercise the allow/deny list commands against a fake dokku layout"
run = "test/access-list-test.sh"
[tasks.docker-build]
description = "Build the auth service docker image"
run = "docker build -t dokku-google-auth:latest ."
[tasks.check]
description = "Run everything CI would run"
depends = ["fmt-check", "vet", "test", "shellcheck", "test-nginx-conf"]
depends = ["fmt-check", "vet", "test", "shellcheck", "test-nginx-conf", "test-access-lists"]
+50
View File
@@ -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-allow() {
declare desc="allow a domain or address to sign in"
local cmd="google-auth:allow"
[[ "$1" == "$cmd" ]] && shift 1
if [[ $# -eq 0 ]]; then
dokku_log_info2 "google-auth allow list"
local listed found=false
while IFS= read -r listed; do
[[ -z "$listed" ]] && continue
dokku_log_verbose "$listed (domain)"
found=true
done < <(fn-ga-global-get-list allowed-domains)
while IFS= read -r listed; do
[[ -z "$listed" ]] && continue
dokku_log_verbose "$listed"
found=true
done < <(fn-ga-global-get-list allowed-emails)
[[ "$found" == "false" ]] && dokku_log_verbose "(empty — nobody can sign in)"
return 0
fi
local entry
for entry in "$@"; do
fn-ga-validate-list-entry "$entry" ||
dokku_log_fail "invalid entry '$entry' — use a domain (signal.org) or a full address (guest@partner.com), with no spaces or commas"
entry="${entry,,}"
if [[ "$(fn-ga-allow-entry-kind "$entry")" == "domain" ]]; then
entry="${entry#@}"
[[ "$entry" == *.* ]] || dokku_log_fail "'$entry' does not look like a domain"
fn-ga-global-list-add allowed-domains "$entry"
dokku_log_info1 "allowed domain: $entry (any verified account at $entry)"
else
fn-ga-global-list-add allowed-emails "$entry"
dokku_log_info1 "allowed: $entry"
if fn-ga-global-list-contains denied-emails "$entry"; then
dokku_log_warn "$entry is on the deny list, which wins — run: dokku google-auth:undeny $entry"
fi
fi
done
fn-ga-reload-service-config
}
cmd-google-auth-allow "$@"
+25 -2
View File
@@ -8,7 +8,8 @@ cmd-google-auth-configure() {
local cmd="google-auth:configure"
[[ "$1" == "$cmd" ]] && shift 1
local domains=() emails=() domains_given=false emails_given=false
local domains=() emails=() denied=()
local domains_given=false emails_given=false denied_given=false
local old_port
old_port="$(fn-ga-global-get port "$GOOGLE_AUTH_DEFAULT_PORT")"
@@ -44,6 +45,17 @@ cmd-google-auth-configure() {
emails+=("${2,,}")
shift 2
;;
--deny-email)
[[ -n "${2:-}" ]] || dokku_log_fail "--deny-email requires a value"
[[ "$2" == *@* ]] || dokku_log_fail "--deny-email takes a full address (got '$2'); denying a whole domain means removing its --allow-domain"
denied_given=true
denied+=("${2,,}")
shift 2
;;
--clear-deny-emails)
denied_given=true
shift 1
;;
--session-ttl)
[[ "${2:-}" =~ ^[0-9]+(h|m|s)$ ]] || dokku_log_fail "--session-ttl must look like 24h, 30m, or 3600s"
fn-ga-global-set session-ttl "$2"
@@ -75,9 +87,20 @@ cmd-google-auth-configure() {
esac
done
# Replace the allow lists only when new values were passed.
# Replace the allow/deny lists only when new values were passed.
[[ "$domains_given" == "true" ]] && fn-ga-global-set-list allowed-domains "${domains[@]}"
[[ "$emails_given" == "true" ]] && fn-ga-global-set-list allowed-emails "${emails[@]}"
[[ "$denied_given" == "true" ]] && fn-ga-global-set-list denied-emails "${denied[@]}"
# Deny wins over allow; surface the contradiction instead of silently
# ignoring the allow entry.
local addr
while IFS= read -r addr; do
[[ -z "$addr" ]] && continue
if fn-ga-global-get-list allowed-emails | grep -qxF "$addr"; then
dokku_log_warn "$addr is in both the allow and deny lists; it will be denied"
fi
done < <(fn-ga-global-get-list denied-emails)
# First run: generate the cookie secret automatically.
if [[ -z "$(fn-ga-global-get cookie-secret)" ]]; then
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -eo pipefail
[[ $DOKKU_TRACE ]] && set -x
source "$(dirname "$(dirname "${BASH_SOURCE[0]}")")/functions"
cmd-google-auth-deny() {
declare desc="block an address even if the allow list covers it"
local cmd="google-auth:deny"
[[ "$1" == "$cmd" ]] && shift 1
if [[ $# -eq 0 ]]; then
dokku_log_info2 "google-auth deny list"
local listed found=false
while IFS= read -r listed; do
[[ -z "$listed" ]] && continue
dokku_log_verbose "$listed"
found=true
done < <(fn-ga-global-get-list denied-emails)
[[ "$found" == "false" ]] && dokku_log_verbose "(empty)"
return 0
fi
local entry
for entry in "$@"; do
fn-ga-validate-list-entry "$entry" ||
dokku_log_fail "invalid entry '$entry' — use a full address (former@signal.org), with no spaces or commas"
entry="${entry,,}"
[[ "$entry" == *@* ]] ||
dokku_log_fail "google-auth:deny takes a full address, not a domain; to stop allowing all of '$entry', run: dokku google-auth:unallow $entry"
fn-ga-global-list-add denied-emails "$entry"
dokku_log_info1 "denied: $entry ($(fn-ga-global-list-count denied-emails) denied)"
done
dokku_log_verbose "denied users lose access on their next request; existing sessions are not honored"
fn-ga-reload-service-config
}
cmd-google-auth-deny "$@"
+1
View File
@@ -55,6 +55,7 @@ cmd-google-auth-report() {
dokku_log_verbose "Callback URL: https://$(fn-ga-global-get auth-host '<auth-host>')${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
+50
View File
@@ -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 <domain|address...> (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 <domain|address>"
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 "$@"
+32
View File
@@ -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 <address...> (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 "$@"
+133
View File
@@ -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 <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"
}
# --- 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"