Skip to content
sign in

==> 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.

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.

  • No passwords in sops. The secret, seeded_hash, the seeding logic and the platform module's api.passwords option are removed. Existing password hashes are carried over by the cutover import.
  • A user is invited. sudo cs-admin invite <user> (and cs-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 = false on 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.
  • 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.
  • 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.