docs: sort the top-level directories into five sections by what the reader wants
Every docs directory moves under 1-getting-started, 2-handbook, 4-implementation or 5-project; the options reference (section 3) comes from the website and has no directory here. trust-boundary stays whole under the handbook for now. web-ui, swarm and tools split between handbook and implementation, and the command-line references move to 2-handbook/reference. Links, path references in code and nix, and the docs index follow the moves; no page content changes.
This commit is contained in:
parent
53ccbcb4b3
commit
6739bb903d
1 changed files with 736 additions and 701 deletions
|
|
@ -1,324 +0,0 @@
|
|||
# Swarm SSO
|
||||
|
||||
The swarm runs one authelia, and it's two things at once: the **session
|
||||
provider** every protected vhost checks (`auth_request`), and — once an
|
||||
operator declares any client — an **OIDC provider** issuing tokens to
|
||||
relying parties: the forge and the matrix homeserver.
|
||||
|
||||
The second role derives rather than switches:
|
||||
`services.hyperhive.swarm.authelia.oidc.clients` being non-empty turns it
|
||||
on. authelia refuses to start with a provider that has no clients, so a
|
||||
separate `enable` would be a second fact free to disagree with the first.
|
||||
|
||||
## Getting in the first time
|
||||
|
||||
authelia binds no TCP port: its listeners are unix sockets only the gateway can reach. The **gateway** on the host running it
|
||||
publishes it as `auth.<swarm.domain>` — vhost, dnsmasq record and TLS
|
||||
name all follow `deploy.authelia`, so there is nothing to turn on
|
||||
separately. (Details, including why a client hive must not declare that
|
||||
vhost: [`../networking/gateway.md`](../networking/gateway.md).)
|
||||
|
||||
**Nobody can log in until the first user exists.** authelia exits at
|
||||
startup on an empty user set:
|
||||
|
||||
```
|
||||
error reading the authentication database: could not validate the schema:
|
||||
users: non zero value required
|
||||
```
|
||||
|
||||
While the set is empty this module writes one placeholder subject,
|
||||
`swarm.placeholder`, that authelia accepts and that can't log in: it's
|
||||
`disabled: true`, and its password digest has an all-zero key that no
|
||||
known password hashes to. authelia starts and serves its login page, and
|
||||
refuses every login. The placeholder stays in `users.yml` once real
|
||||
users exist, shows up in `swarmctl user list`, and `swarmctl user add`
|
||||
refuses its name.
|
||||
|
||||
⚠️ The step below is **required to finish the install**, not an
|
||||
optional first-login convenience.
|
||||
|
||||
Add the first subject on the host running authelia:
|
||||
|
||||
```console
|
||||
# swarmctl user add mara --display-name Mara --email mara@example.com --group admins
|
||||
added mara to /var/lib/authelia-swarm/users.yml
|
||||
password: <generated>
|
||||
this password is stored nowhere — record it now
|
||||
```
|
||||
|
||||
`swarmctl` generates, hashes, and prints the password once, keeping only
|
||||
the hash. It reads and writes authelia's `users.yml` directly — it's
|
||||
the one user store, shared with `swarm-authelia-bridge`, which creates
|
||||
agent identities in the same file. No restart: authelia watches it. Full
|
||||
reference: [`../tools/swarmctl-cli.md`](../tools/swarmctl-cli.md).
|
||||
|
||||
You can edit `users.yml` by hand, and `swarmctl` will read what you
|
||||
wrote. ⚠️ It rewrites the whole file on every change, so **comments and
|
||||
formatting don't survive**; values and unrecognised keys do.
|
||||
|
||||
This step stays manual on purpose. Bootstrapping an identity provider
|
||||
non-interactively means a secret arriving from somewhere — a file, an
|
||||
env var, a nix expression — and every one of those is worse than an
|
||||
operator typing one command once.
|
||||
|
||||
### Changing a subject afterwards
|
||||
|
||||
`user add` only ever adds: on a name that already exists it refuses,
|
||||
rather than resurfacing as a second account or a silent overwrite.
|
||||
Editing an existing subject is `user update`, and the flags compose, so
|
||||
one call can change multiple things:
|
||||
|
||||
```console
|
||||
# swarmctl user update mara --add-group admins --email mara@example.com
|
||||
added to group "admins"
|
||||
email: unset -> "mara@example.com"
|
||||
mara is now in groups: admins
|
||||
```
|
||||
|
||||
Two behaviours worth knowing before you rely on them:
|
||||
|
||||
- **`--remove-group` fails if the user isn't in that group.** Every
|
||||
other flag is idempotent — setting what's already set is fine, so a
|
||||
"make these four things true" call doesn't break when one of them
|
||||
already was. Revocation is the exception on purpose: a typo'd group
|
||||
name that reported success would leave an account holding access you
|
||||
believe you took away, and that's the one outcome nobody re-checks.
|
||||
- **`swarmctl` prints the resulting group list** because group names have no
|
||||
registry anywhere. A misspelled `--add-group` creates a real group that
|
||||
no access-control rule mentions, so the user gains nothing and no error
|
||||
is possible — reading the line back is the only check there is.
|
||||
|
||||
Passwords are deliberately out of scope here: regenerating a credential
|
||||
is a different intent from editing an attribute, and folding them means
|
||||
an attribute edit can invalidate a login by accident. Resetting one is a
|
||||
separate verb, `user reset-password`, which generates and hashes a new
|
||||
password the same way `user add` does and refuses on an unknown user:
|
||||
|
||||
```console
|
||||
# swarmctl user reset-password mara
|
||||
reset password for mara in /var/lib/authelia-swarm/users.yml
|
||||
password: <generated>
|
||||
this password is stored nowhere — record it now
|
||||
```
|
||||
|
||||
Authelia's file store has no SMTP notifier, so this is the only way a
|
||||
human account gets a new password once an operator forgets the old one.
|
||||
|
||||
## What secrets exist, and where each one lives
|
||||
|
||||
[`credentials-reference.md`](credentials-reference.md) tabulates every secret in the swarm, with its
|
||||
generator and its path, in one place — including authelia's own keys
|
||||
(session, JWT, storage-encryption, OIDC HMAC, OIDC issuer) and the two
|
||||
halves of each client secret. That page's two rules — a secret is always
|
||||
a path, never a value, and the generator and the reader typically live in
|
||||
different containers — are why the client secret's plaintext half needs
|
||||
the delivery step below and the rest of authelia's keys don't.
|
||||
|
||||
## Getting the plaintext to the relying party
|
||||
|
||||
Three cases, and they're genuinely different mechanisms rather than one
|
||||
mechanism with flags.
|
||||
|
||||
### 1. All-local — one host runs both
|
||||
|
||||
Nothing to configure at all. authelia mints each client's secret at its
|
||||
first boot, and the mint in its container creates it in the swarm
|
||||
secret store at `swarm/services/<client id>/oidc/client`, keeping only the digest on disk. A host-side unit
|
||||
then reads it from the store and writes it into the service's container:
|
||||
`swarm-bao-forge-oidc` for the forge, under the forge publisher's store
|
||||
leaf, into `/run/hive-forge-oidc/<client id>.secret` on the host, which
|
||||
`forgejo-sso-source` loads as a credential; `swarm-bao-matrix-oidc` for matrix, under
|
||||
matrix-ctl's. A unit that can't read the secret yet fails and retries
|
||||
every 30 seconds for a day, so there is no start order to get right. The
|
||||
service's own module contributes its client entry — callback URL
|
||||
included — to authelia's client list.
|
||||
|
||||
Each relying service's own glue module builds its callback once, and
|
||||
both authelia's client list and the service's own OIDC client config
|
||||
read that same value, so the redirect URI authelia accepts and the one
|
||||
the service actually sends can't drift apart. A mismatch there is a
|
||||
rejected login with no error text worth reading.
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
⚠️ nixos-container refuses to start a container whose bind source is
|
||||
missing, and this secret doesn't exist until authelia's first boot has run
|
||||
and the mint has stored it. The forge therefore binds the **directory**
|
||||
`/run/hive-forge-oidc`, which a tmpfiles rule creates at boot, never the
|
||||
file. The directory is tmpfs, so the secret is gone after a reboot until
|
||||
the fetch runs again; forgejo keeps it in its login-source row, so logins
|
||||
still work meanwhile. Matrix's secret is written into the container's state
|
||||
directory instead.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
### 2. Swarm-managed services
|
||||
|
||||
The controller side owns provisioning: `swarmctl` writes both halves, the
|
||||
same way it already owns authelia's user store (`users.yml`, read and
|
||||
written in place).
|
||||
|
||||
### 3. A hive elsewhere
|
||||
|
||||
The forge's host needn't be the store's or authelia's host, and the
|
||||
operator places no secret file: `swarm-bao-forge-oidc` runs on the
|
||||
forge's host as in the all-local case and fetches the secret over the
|
||||
store's address. What that host needs is the forge publisher's store
|
||||
leaf, issued from the store's CA and copied over from the store's host:
|
||||
|
||||
```nix
|
||||
services.hyperhive = {
|
||||
swarm.authelia.url = "https://auth.example.com";
|
||||
deploy.bao = {
|
||||
forgePublisherClientCertFile = "/var/lib/swarm-bao-pki/forge-publisher.pem";
|
||||
forgePublisherClientKeyFile = "/var/lib/swarm-bao-pki/forge-publisher-key.pem";
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
**A forge host without that leaf still evaluates.** `swarm-bao-forge-oidc`
|
||||
renders and fails at start, naming both options, and the forge has no
|
||||
login source until an operator places the leaf. Where the store runs on
|
||||
the same host, `glue-bao-tls.nix` defaults both options to the leaf it
|
||||
mints.
|
||||
|
||||
## Where each relying party differs
|
||||
|
||||
The registration half is identical; what each service does with the
|
||||
result isn't.
|
||||
|
||||
| | forge | matrix |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| how it learns the config | a oneshot calls `forgejo admin auth`, writing a login-source row into its database | tuwunel reads a `[[global.identity_provider]]` entry from its config file |
|
||||
| how it reads the secret | a path inside its container | the same path, handed on by `LoadCredential` |
|
||||
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts |
|
||||
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
|
||||
|
||||
Four consequences worth stating plainly:
|
||||
|
||||
- **A person's first forge login creates their forge account**, named
|
||||
after authelia's `preferred_username` (`[oauth2_client]` in the forge
|
||||
module), provided the subject has an email. It starts as an ordinary user; `swarmctl forge make-admin
|
||||
<name>` makes it a site admin. A name that already has a local forge
|
||||
account doesn't get it handed over: forgejo asks for that account's
|
||||
own password first (`ACCOUNT_LINKING = login`). Agents, `core` and
|
||||
`swarm-controller` all have local accounts, and `swarmctl user add`
|
||||
refuses none of those names.
|
||||
|
||||
- **A member of `admins` becomes a matrix homeserver admin** once their
|
||||
first matrix login has created the account: `swarm-controller` promotes
|
||||
them within five minutes, and never demotes. See
|
||||
[`../integrations/matrix.md`](../integrations/matrix.md#operators-are-homeserver-admins).
|
||||
|
||||
- **tuwunel re-reads its secret file on every OAuth exchange**, not only
|
||||
at startup, and its own sandboxing hides most paths from it. It gets the
|
||||
file through `LoadCredential` for the same reason the appservice
|
||||
registration does — that keeps `DynamicUser` and `PrivateUsers` intact,
|
||||
with no host-side ownership arrangement to maintain.
|
||||
- **Matrix SSO lives inside the homeserver.** The client-server API is
|
||||
spoken by non-browser clients holding matrix access tokens — every
|
||||
agent's own daemon — as well as by federation, so the homeserver
|
||||
serves `/_matrix/` directly and authenticates it itself. The
|
||||
forward-auth vhosts protect browser surfaces; this isn't one of them.
|
||||
|
||||
## Machine clients
|
||||
|
||||
Everything above is a person in a browser. A swarm also has callers that
|
||||
hold no session and follow no redirect: the telemetry collector, the
|
||||
queue's auth-callout responder, and each hive.
|
||||
|
||||
**One client per hive, not one per service.** A hive's identity belongs to
|
||||
the directory rather than to whichever service happens to consume it, so a
|
||||
hive holds a single OIDC client — `<hiveClientPrefix><hive>` — and mints a
|
||||
different token per service from it. The alternative, letting each
|
||||
consuming subsystem declare its own list, collides on the same client id
|
||||
the moment a second consumer appears.
|
||||
|
||||
**The audience is that client id.** A swarm service that has to tell hives
|
||||
apart needs one name both sides already agree on, and the client id is
|
||||
already that name. A parallel per-hive naming scheme would be a second
|
||||
thing to keep in step, and it drifts silently — a mismatch presents as a
|
||||
valid token refused at the target, which reads like a broken credential
|
||||
rather than a broken name.
|
||||
|
||||
**Authelia signs tokens (`RS256`), not opaque ones**, because a resource server
|
||||
that can't call the provider back is a real case here: the telemetry
|
||||
collector verifies offline against `/jwks.json`, and an opaque token gives
|
||||
it nothing to verify. The queue's responder introspects instead — a
|
||||
different question asked of the same token, and the reason both
|
||||
`/api/oidc/introspection` and `/jwks.json` have to stay reachable.
|
||||
|
||||
### Machine callers must fail closed
|
||||
|
||||
⚠️ **An error page that answers `200` is a security bug, not a cosmetic
|
||||
one.** The browser surface intercepts upstream errors and serves a
|
||||
friendly "SSO is unavailable" page; that page is a file, so it returns
|
||||
`200`. Any machine caller routed through it receives a success carrying
|
||||
HTML instead of the failure that actually happened:
|
||||
|
||||
- `/api/authz/auth-request` — nginx `auth_request` treats **any 2xx as
|
||||
success**, so a down provider means _access granted_
|
||||
- `/api/oidc/introspection` — a token check that answers `200`
|
||||
- `/api/oidc/token`, `/.well-known/openid-configuration` — a client
|
||||
parsing an error page as its JSON document
|
||||
|
||||
The gateway routes authelia's `/api/` and `/.well-known/` prefixes, and
|
||||
the bridge's `/bridge/`, **without** error interception. The split is by
|
||||
_audience_, not by an enumerated path list: a human gets the page, every
|
||||
machine caller gets the status.
|
||||
Enumerating endpoints individually would leave the next one added
|
||||
silently intercepted.
|
||||
|
||||
The same shape bites any machine route behind a browser-shaped gate: the
|
||||
caller follows a `302` to a login page, the login page answers `200`,
|
||||
and the caller reports success while nothing happened. Log ingest hit exactly this
|
||||
and lost eleven hours of delivery in silence.
|
||||
|
||||
**Checking it, if you change this routing.** Point the vhost at a dead
|
||||
upstream and compare three requests, not one:
|
||||
|
||||
1. through `/` — must still serve the friendly page
|
||||
2. through `/api/` — must deny
|
||||
3. a direct dial to authelia — must match what (2) did
|
||||
|
||||
All three matter. A change that silently deleted the browser page would
|
||||
pass a deny-only check, and one that quietly stopped denying would pass a
|
||||
page-only check. The person who introduced the split verified it this way.
|
||||
|
||||
## Operator CLI login
|
||||
|
||||
`swarmctl login` logs an operator in as their own account, from any
|
||||
machine, through the public `swarmctl` client authelia always registers: no
|
||||
secret, authorization code with PKCE, a loopback redirect, and a refresh
|
||||
token. Its access token carries `authelia.bearer.authz` with the audience
|
||||
`https://<swarm>/api/`, so the gateway admits it at the controller's API and
|
||||
nowhere else, under the same access rule as a browser session there.
|
||||
Revocation is per account: once authelia stops accepting the account, the
|
||||
gateway refuses its token too. See [`swarmctl/README.md`](../../swarmctl/README.md#remote-use-swarmctl-login).
|
||||
|
||||
## What this doesn't do
|
||||
|
||||
- **It doesn't disable local login — except on Grafana.** Forgejo and
|
||||
tuwunel keep their password databases and gain a second door: an identity
|
||||
provider that can take a service offline when it hiccups is worse than one
|
||||
with two ways in, and making authelia the only path there is a separate,
|
||||
reversible switch (tuwunel's `login_with_password`, forgejo's own setting).
|
||||
⚠️ **Grafana is the exception, and not a reversible one.** Its module sets
|
||||
`auth.disable_login_form` unconditionally: its vhost is on the public
|
||||
gateway, so a password box there would be a way in whatever the
|
||||
deployment. The form is all this hides. Grafana's API still takes HTTP
|
||||
basic auth for the built-in `admin` account, whose password is a random
|
||||
one minted into the swarm secret store, not the upstream `admin`. A
|
||||
dead provider locks out the SSO accounts but not that one: an operator
|
||||
reads the password from `swarm/services/<grafana client id>/admin/password`
|
||||
in the store (or, as root on the Grafana host, from
|
||||
`/run/swarm-grafana-secrets/admin-password`)
|
||||
and calls the API with it. See [`services.md`](services.md#metrics-victoriametrics--grafana)
|
||||
and [`credentials.md`](credentials.md). SSO logins are the everyday way in, which
|
||||
is why `swarm.grafana.oidc.role` defaults to `Admin`.
|
||||
The three secrets Grafana reads live on the host's tmpfs, so after a reboot
|
||||
Grafana stays down (no login, no dashboards) until the secret store
|
||||
answers; the delivery units retry and start it with no manual step.
|
||||
- **It doesn't provision users.** `swarmctl` creates and destroys agents
|
||||
continuously today, so the subject set belongs to a program rather
|
||||
than to a config file.
|
||||
Loading…
Reference in a new issue