Files
llmgw/internal/cli/users.go
T
Ilker ManapandClaude Opus 5 216fc9bb03 Let people sign up, be approved, and issue their own keys
Every key needed an operator with a shell. This adds the login system that
made that necessary, which is the thing I argued against building until it was
asked for — so it is built as if it will be attacked, because a public login
form is.

Signing up produces an account that can do nothing. Approval is what makes it
work and stays on the CLI: having a shell on the control plane is what makes
someone an administrator, and that is a smaller thing to get right than roles
and an admin UI that mints credentials.

The details that matter:

Passwords are argon2id, where a slow hash is correct — the input is chosen by
a human and has a dictionary, which is the opposite of an API key. Both kinds
of authentication now live in this codebase and they are in separate packages
so neither set of assumptions leaks into the other. Length is the only rule:
composition rules push people towards Password1! and cost more entropy than
they buy.

Signup never says whether an address already has an account, and sign-in
answers the same way for a wrong password and a missing account — verifying
against a dummy hash when there is no user, so a stopwatch cannot enumerate
accounts. Account state is checked after the password, not before, so guessing
an address reveals nothing about it. Guessing is bounded per address rather
than per IP: an attacker has many addresses to come from and one to guess
against, and limiting by IP locks out an office behind one NAT.

Sessions are server-side, so signing out and suspending take effect
immediately rather than whenever a token expires; suspension is checked in the
same query that resolves the session, because a second round trip is a check
somebody eventually forgets. Every state-changing form carries a CSRF token
verified against a hash in the session — a cookie authenticates the browser,
not the page that asked.

Key ownership is enforced in the UPDATE rather than by reading first, so a
guessed key id from another account changes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:32:35 +02:00

187 lines
5.6 KiB
Go

package cli
import (
"fmt"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func newUsersCmd() *cobra.Command {
var dbPath string
cmd := &cobra.Command{
Use: "users",
Short: "Approve and manage the people who sign up",
Long: "Signing up creates an account that cannot do anything. Approval is what\n" +
"makes it work, and it happens here rather than in a browser: approving is\n" +
"the step that decides who gets to spend the cluster, and it needs a shell\n" +
"on this machine rather than a session cookie.",
}
cmd.PersistentFlags().StringVar(&dbPath, "store", "/var/lib/llmgw/llmgw.db", "path to the store")
open := func(cmd *cobra.Command) (*store.SQLite, error) {
return store.OpenSQLite(cmd.Context(), dbPath)
}
cmd.AddCommand(newUserListCmd(open), newUserApproveCmd(open),
newUserStateCmd(open, "suspend", store.UserSuspended,
"Stop an account working, keeping it and its history"),
newUserStateCmd(open, "reinstate", store.UserActive,
"Let a suspended account work again"),
newUserCreateCmd(open))
return cmd
}
type userOpener func(*cobra.Command) (*store.SQLite, error)
func newUserListCmd(open userOpener) *cobra.Command {
var pending bool
cmd := &cobra.Command{
Use: "list",
Short: "List accounts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
state := store.UserState("")
if pending {
state = store.UserPending
}
users, err := s.Users(cmd.Context(), state)
if err != nil {
return err
}
if len(users) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "no accounts")
return nil
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tEMAIL\tNAME\tSTATE\tPROJECT\tSIGNED UP\tLAST LOGIN")
now := time.Now()
for _, u := range users {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
u.ID, u.Email, u.Name, u.State, dash(u.ProjectID),
ago(u.CreatedAt, now), ago(u.LastLoginAt, now))
}
return w.Flush()
},
}
cmd.Flags().BoolVar(&pending, "pending", false, "only accounts waiting for approval")
return cmd
}
func newUserApproveCmd(open userOpener) *cobra.Command {
var project, by string
cmd := &cobra.Command{
Use: "approve <user-id>",
Short: "Let an account sign in and issue its own keys",
Long: "The project is required: it decides which limits this person's keys\n" +
"inherit. An account approved into no project would pass every check and\n" +
"then fail at the first key it tried to make.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
if err := s.ApproveUser(cmd.Context(), args[0], project, by, time.Now()); err != nil {
return fmt.Errorf("approving %s: %w", args[0], err)
}
u, err := s.UserByID(cmd.Context(), args[0])
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s approved into project %s\n", u.Email, project)
cmd.PrintErrln("They can sign in and create their own keys from /profile.")
return nil
},
}
cmd.Flags().StringVar(&project, "project", "default", "project the person's keys belong to")
cmd.Flags().StringVar(&by, "by", "", "who approved, for the record")
_ = cmd.MarkFlagRequired("project")
return cmd
}
func newUserStateCmd(open userOpener, verb string, state store.UserState, short string) *cobra.Command {
return &cobra.Command{
Use: verb + " <user-id>",
Short: short,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
if err := s.SetUserState(cmd.Context(), args[0], state); err != nil {
return err
}
if state == store.UserSuspended {
// Otherwise the person stays signed in and suspension is
// advisory until their session happens to expire.
if err := s.DeleteUserSessions(cmd.Context(), args[0]); err != nil {
return err
}
cmd.PrintErrln("Their sessions were closed. Their API keys still work — revoke those separately.")
}
fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", args[0], state)
return nil
},
}
}
func newUserCreateCmd(open userOpener) *cobra.Command {
var project, password string
cmd := &cobra.Command{
Use: "create <email>",
Short: "Create an account directly, already approved",
Long: "For when signup is closed, or for the first account on a new deployment.\n" +
"The password is set here and should be changed by its owner.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if password == "" {
return fmt.Errorf("--password is required")
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
hash, err := account.HashPassword(password)
if err != nil {
return err
}
id := randomID("usr")
u := store.User{
ID: id, Email: args[0], Name: args[0],
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
}
if err := s.CreateUser(cmd.Context(), u); err != nil {
return err
}
if err := s.ApproveUser(cmd.Context(), id, project, "cli", time.Now()); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s\n", id)
cmd.PrintErrf("%s created and approved into project %s\n", args[0], project)
return nil
},
}
cmd.Flags().StringVar(&project, "project", "default", "project the person's keys belong to")
cmd.Flags().StringVar(&password, "password", "", "initial password")
return cmd
}