Files
llmgw/internal/cli/users.go
T
Ilker ManapandClaude Opus 5 79e459d9ff
CI / test (push) Failing after 1m11s
Let people change their own password, and operators reset a lost one
The account system shipped without either, which made the first forgotten
password an unanswerable support request.

Changing it requires the current one. A session is what an attacker gets from
a borrowed laptop or a stolen cookie; the password is what they do not have,
and without the check the first thing they would do is take the account.

Every session is closed, including the browser doing the changing — leaving
the others open would mean a password change does nothing about the reason
someone is changing it. A new session is then opened for the browser in front
of us, so the person who just proved their password is not thrown back to the
sign-in page. That response also carries a new CSRF cookie, so the forms it
renders take the new token rather than the one the request arrived with; there
is a test that posts a form from that exact page.

Guessing the current password is bounded the same way sign-in is, because
someone holding a stolen session should not get unlimited attempts at the
password that would let them keep it.

There is no self-service reset: this gateway sends no email, and a reset link
with nowhere to send it is not a reset. `llmgw users reset-password` is the
operator's answer, and it closes their sessions too — if someone lost their
password, the reason might be that somebody else has it.

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

227 lines
6.9 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), newUserResetPasswordCmd(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 newUserResetPasswordCmd(open userOpener) *cobra.Command {
var password string
cmd := &cobra.Command{
Use: "reset-password <user-id>",
Short: "Set a new password for someone who lost theirs",
Long: "There is no self-service reset, because this gateway sends no email and\n" +
"a reset link with nowhere to send it is not a reset. Set one here, tell\n" +
"them out of band, and have them change it from their profile.\n\n" +
"Every session they have open is closed: if they lost the password, the\n" +
"reason might be that somebody else has it.",
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
}
if err := s.SetPassword(cmd.Context(), args[0], hash); err != nil {
return err
}
if err := s.DeleteUserSessions(cmd.Context(), args[0]); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "password set for %s\n", args[0])
cmd.PrintErrln("Their sessions were closed. Their API keys still work.")
return nil
},
}
cmd.Flags().StringVar(&password, "password", "", "the new password")
return cmd
}
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
}