==> read the decisions
ADR-0016: Accounts are invited; sessions are visible and revocable
Status: Accepted (2026-10-14). Replaces password seeding from sops. ADR-0005 (an opaque session for browsers, tokens for the rest) stands. Implemented 2026-10-15. Amended by ADR-0019 (2026-10-15): sign-in, invitations and resets move to the identity provider.
Context
Section titled “Context”A user's first password is a scrypt hash in the sops secret cs_user_passwords, applied by the
reconciler, which remembers which hash it seeded (seeded_hash) so that a password the user changes
survives redeploys. The operator generates a password, encrypts its hash into sops, and sends the
plaintext over chat. The same secret doubles as the "disable this user" switch -- a user missing from
it gets their login disabled -- so it cannot simply be removed. Meanwhile a user cannot see where
they are signed in, and a password change signs every other session out.
Decision
Section titled “Decision”- No passwords in sops. The secret,
seeded_hash, the seeding logic and the platform module'sapi.passwordsoption are removed. Existing password hashes are carried over by the cutover import. - A user is invited.
sudo cs-admin invite <user>(andcs-admin reset <user>) on the hub creates a one-time link,https://app.<domain>/invite#<token>:- the token is 32 random bytes; only its hash is stored, with the user, the purpose, an expiry (72 hours) and when it was used;
- it sits in the URL fragment, which a browser never sends to a server or in a
Referer, so it never reaches Caddy's logs or Loki; the page reads it and posts it once; - the page sets a password, consumes the invite and signs the user in. Single use: a link leaked after use is worthless, and one intercepted before use shows up as "already used".
- The CLI prints only to an interactive terminal. It refuses when its output is not a TTY, so it cannot be logged by a unit or captured by a pipe. Running it through an assistant whose transcript is stored is the same leak, and is not done.
- Disabling is explicit:
enable = falseon the user in the inventory, which drops them from the roster; the reconciler turns that into no login and every session, token and invite ended. - Sessions are visible: the profile lists each session's device (from the user agent recorded at sign-in), created and last seen, marks the current one, and signs out any one, or all others.
- Changing a password does not end other sessions. Ending them is now a visible, deliberate action; doing it implicitly surprised more than it protected.
- Passkeys are the next factor (backlog): an invite will offer registering one instead of a password.
Consequences
Section titled “Consequences”- Onboarding needs no secret on the operator's laptop and no sops change per user.
- The reconciler stops touching passwords.
- If a password is changed because someone else had it, their session survives until the user signs it out from the list; the list is what makes that possible at all.
Alternatives rejected
Section titled “Alternatives rejected”- A temporary password printed by the CLI. Same "where is it printed" question, and a leaked temporary password stays valid until changed; a used invite does not.
- A migration carrying a password hash. The hash would sit in git, open to offline guessing, and migrations run once, so resets would not fit.
- OAuth for onboarding. OAuth delegates access to a client; it defines no invitation flow.