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
+82 -35
View File
@@ -51,6 +51,14 @@ API-key-protected endpoints, health checks) can be excluded per app.
and authenticated with a secret generated at configure time. Session
cookies are `Secure`, `HttpOnly`, `SameSite=Lax`, and pinned to the host
they were minted on.
- **Per-app access lists** work because every generated location tells the
service which app the request belongs to, via an `X-Google-Auth-App` header
that nginx sets itself — overwriting anything a client sent. The plugin's
`apps/` directory is bind-mounted into the container read-only, so the
service reads an app's lists on demand instead of needing a restart. The
destination app also rides through the OAuth `state` parameter, because
Google's callback lands on the auth host, which may belong to a different
app than the one being signed in to.
### Headers your apps receive
@@ -88,8 +96,12 @@ it into `/var/lib/dokku/plugins/available/<name>` and:
the app's current upstream (whose name changes if you remap ports).
- `post-delete`, `post-app-rename`, `post-app-clone` — keep plugin state in
sync with app lifecycle.
- **State** lives under `/var/lib/dokku/data/google-auth/` (global config +
one directory per enabled app). Secrets are `0600`.
- **State** lives under `/var/lib/dokku/data/google-auth/`: `global/` for the
shared config and `apps/<app>/` for each app's exclusions and access lists.
The data root is `0700` and secrets are `0600`. `apps/` is `0711` with
`0644` list files, because it is bind-mounted into the service container,
which runs as an unprivileged uid and has to read those lists — the `0700`
root still keeps other host users out, and `global/` is never mounted.
The nginx integration relies on a stable, documented dokku feature: the
generated vhost for every app contains
@@ -161,51 +173,78 @@ Other flags (all optional, all persisted):
| `--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) | — |
These flags **replace** the list they name, which suits initial setup. For
one-at-a-time changes afterwards, see the next section.
These flags set the **global** lists and **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:
Access lists exist in two scopes — one global default and an optional override
per app. Every command takes the scope as its first argument: an app name, or
`--global`.
```bash
dokku google-auth:allow greyson@signal.org alice@signal.org
dokku google-auth:allow --global signal.org # everyone at signal.org, by default
dokku google-auth:allow my-app ceo@signal.org # …but my-app is just this person
dokku google-auth:deny --global former@signal.org # nobody, anywhere
dokku google-auth:deny my-app bob@signal.org # bob, only on my-app
```
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:
The rules, in the order the auth service applies them:
1. **A deny match rejects the account.** The global and per-app deny lists are
*combined*, so a global denial cannot be lifted by an app.
2. **Otherwise the allow list decides**, and it is strict: an account matching
nothing is rejected. There is no "allow everyone" mode, and at least one
global allow entry is required.
3. **An app with its own allow entries uses only those**, ignoring the global
list entirely. An app with none inherits the global list.
That third rule is the useful one and the surprising one. It lets a single app
be narrowed to a few people, or opened to an outside collaborator who is not
in the global list at all:
| | global: `signal.org` | effect |
|---|---|---|
| `app-a` | no entries | anyone `@signal.org` |
| `app-b` | `ceo@signal.org` | **only** `ceo@signal.org` |
| `app-c` | `guest@partner.com` | **only** `guest@partner.com` — not `@signal.org` |
It also means the global list is a default, not a ceiling: an app can admit
someone it does not cover. `google-auth:allow` warns the first time an app
gains an entry, since that is the moment it stops inheriting.
All eight forms:
```bash
dokku google-auth:allow signal.org # everyone at signal.org…
dokku google-auth:deny former@signal.org # …except this account
```
dokku google-auth:allow --global # show the global allow list
dokku google-auth:allow my-app # show my-app's (or that it inherits)
dokku google-auth:allow --global signal.org # a domain — any verified account there
dokku google-auth:allow my-app guest@partner.com # one address
dokku google-auth:unallow my-app guest@partner.com # remove either kind
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
dokku google-auth:deny --global # show the global deny list
dokku google-auth:deny my-app # show my-app's, plus the global ones
dokku google-auth:undeny --global 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
expire, so denying (or unallowing) someone with a live session ends it — and a
session minted for one app is not accepted by an app whose list excludes them.
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.
Two guardrails: `unallow --global` refuses to remove the last global entry,
since an empty global allow list locks everyone out of every app that inherits
it (emptying an *app's* list is fine — it goes back to inheriting).
`dokku google-auth:report` shows the global lists and each app's, and states
whether an app inherits or overrides.
Global lists reach the auth service in its environment, so changing one
restarts the shared container. Per-app lists are read from disk on demand, so
changing one takes effect within a couple of seconds with no restart and no
interruption to other apps.
### 4. Protect apps
@@ -240,8 +279,9 @@ protect them yourself (API key, HMAC signature, etc.).
```bash
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:allow --global alice@signal.org # let someone in everywhere
dokku google-auth:allow my-app alice@signal.org # …or just on one app
dokku google-auth:deny --global 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
@@ -274,6 +314,11 @@ and sign out at `https://<any-app>/_google-auth/logout`.
stock nginx template.
- **Plugin updates:** `sudo dokku plugin:update google-auth` rebuilds the
service image and restarts the container automatically.
- **An app's own lists need its nginx config to be current**, since that is
what tells the service which app a request belongs to. The plugin rewrites
the config whenever you change an app's lists (and on every deploy), so this
is normally invisible — but an app that has not been deployed or touched
since an upgrade falls back to the global lists until then.
## Uninstall
@@ -293,7 +338,9 @@ shellcheck):
```bash
mise install # install pinned toolchain
mise run test # Go unit tests (full OAuth flow against a fake Google)
mise run test-nginx-conf # generate nginx config + validate with real nginx in docker
mise run test-nginx-conf # generate nginx config + validate with real nginx in docker
mise run test-access-lists # allow/deny commands against a fake dokku layout
mise run test-help # help output shapes + every subcommand documented
mise run shellcheck # lint all plugin scripts
mise run check # everything CI would run
mise run docker-build # build the service image locally
@@ -307,6 +354,6 @@ internal/authproxy/ OAuth flow, session crypto, HTTP handlers (+ tests)
functions shared bash for the plugin (config store, nginx generation)
subcommands/ dokku google-auth:* commands
install, nginx-pre-reload, core-post-deploy, post-* dokku lifecycle triggers
test/nginx-conf-test.sh bash integration test for the generated nginx config
test/ bash tests: nginx config generation, access lists, help output
Dockerfile multi-stage build → static binary in a scratch image
```