CI / test (push) Successful in 1m9s
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>
187 lines
5.6 KiB
Go
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
|
|
}
|