package cli import ( "errors" "fmt" "net/url" "strings" "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 sf storeFlags 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.", } sf.bind(cmd) open := func(cmd *cobra.Command) (*store.DB, error) { return sf.open(cmd) } 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), newUserResetLinkCmd(open, &sf), newUserJoinCmd(open), newUserLeaveCmd(open), newUserDeleteCmd(open), newUserForgetCmd(open), newUserAdminCmd(open, "promote", true, "Let an account see the cluster dashboard at /admin"), newUserAdminCmd(open, "demote", false, "Take away access to the cluster dashboard"), newUserRoleCmd(open)) return cmd } type userOpener func(*cobra.Command) (*store.DB, 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 } all, err := s.AllMemberships(cmd.Context()) if err != nil { return err } where := map[string][]string{} for _, m := range all { where[m.UserID] = append(where[m.UserID], m.Path) } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) fmt.Fprintln(w, "ID\tEMAIL\tSTATE\tADMIN\tMEMBER OF\tSIGNED UP\tLAST LOGIN") now := time.Now() for _, u := range users { admin := "-" if u.IsAdmin { admin = "yes" } member := "on their own" if ms := where[u.ID]; len(ms) > 0 { member = strings.Join(ms, "; ") } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", u.ID, u.Email, u.State, admin, member, 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 ", Short: "Let an account sign in and issue its own keys", Long: "A project is optional. Approved into nothing, a person uses the gateway on\n" + "their own — personal keys and chat within their personal limits — and can\n" + "be added to organizations, teams and projects with 'llmgw users join'.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { s, err := open(cmd) if err != nil { return err } defer s.Close() u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } var p store.ProjectPath if project != "" { if p, err = resolveProject(cmd.Context(), s, project); err != nil { return err } } if err := s.ApproveUser(cmd.Context(), u.ID, p.ID, by, time.Now()); err != nil { return fmt.Errorf("approving %s: %w", u.Email, err) } if p.ID == "" { fmt.Fprintf(cmd.OutOrStdout(), "%s approved, on their own for now\n", u.Email) } else { fmt.Fprintf(cmd.OutOrStdout(), "%s approved into %s\n", u.Email, p.Path) } cmd.PrintErrln("They can sign in and create their own keys from /profile.") return nil }, } cmd.Flags().StringVar(&project, "project", "", "a project to put them in (optional): id, path (Acme/platform/gateway) or unique name") cmd.Flags().StringVar(&by, "by", "", "who approved, for the record") return cmd } func newUserStateCmd(open userOpener, verb string, state store.UserState, short string) *cobra.Command { return &cobra.Command{ Use: verb + " ", 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() u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } if err := s.SetUserState(cmd.Context(), u.ID, 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(), u.ID); 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", u.Email, state) return nil }, } } func newUserRoleCmd(open userOpener) *cobra.Command { return &cobra.Command{ Use: "role ", Short: "Give an account a part in running the gateway, from the pages under /admin", Long: "viewer looks at every administration page and changes nothing. operator\n" + "also runs the cluster from them: models through the mirror, a node's\n" + "engines. billing sees usage and quotas. admin may do anything. none takes\n" + "the role away. Every change made from those pages is in `llmgw ctl audit`.", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { role := args[1] if role == "none" { role = "" } s, err := open(cmd) if err != nil { return err } defer s.Close() u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } if err := s.SetStaffRole(cmd.Context(), u.ID, role); err != nil { return err } if role == "" { fmt.Fprintf(cmd.OutOrStdout(), "%s has no part in running the gateway now\n", u.Email) } else { fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", u.Email, role) } return nil }, } } func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *cobra.Command { return &cobra.Command{ Use: verb + " ", Short: short, Long: "The dashboard is read-only, so this grants looking and nothing else.\n" + "Everything that changes the cluster or issues a credential stays here on\n" + "the CLI, where it needs a shell rather than a session — which keeps the\n" + "worst case of a stolen admin cookie to \"saw the cluster\".", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { s, err := open(cmd) if err != nil { return err } defer s.Close() u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } if err := s.SetAdmin(cmd.Context(), u.ID, admin); err != nil { return err } if admin { fmt.Fprintf(cmd.OutOrStdout(), "%s can now see /admin\n", u.Email) } else { fmt.Fprintf(cmd.OutOrStdout(), "%s can no longer see /admin\n", u.Email) cmd.PrintErrln("Their session stays open; the page is read-only. " + "Suspend the account to close it.") } return nil }, } } func newUserResetPasswordCmd(open userOpener) *cobra.Command { var password string cmd := &cobra.Command{ Use: "reset-password ", Short: "Set a new password for someone who lost theirs", Long: "People reset a forgotten password themselves at /forgot when the cluster\n" + "has mail settings (mail.smtp); without them, 'llmgw users reset-link' makes\n" + "a link to hand them. Or set one here, tell them out of band, and have them\n" + "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 } u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } if err := s.SetPassword(cmd.Context(), u.ID, hash); err != nil { return err } if err := s.DeleteUserSessions(cmd.Context(), u.ID); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "password set for %s\n", u.Email) 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 ", 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() var p store.ProjectPath if project != "" { if p, err = resolveProject(cmd.Context(), s, project); err != nil { return err } } 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, p.ID, "cli", time.Now()); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "%s\n", id) if p.ID == "" { cmd.PrintErrf("%s created and approved, on their own for now\n", args[0]) } else { cmd.PrintErrf("%s created and approved into %s\n", args[0], p.Path) } return nil }, } cmd.Flags().StringVar(&project, "project", "", "a project to put them in (optional): id, path or unique name") cmd.Flags().StringVar(&password, "password", "", "initial password") return cmd } // scopeFlags names one org, team or project by whichever flag is given. type scopeFlags struct{ org, team, project string } func (f *scopeFlags) bind(cmd *cobra.Command) { cmd.Flags().StringVar(&f.org, "org", "", "an organization: id or name") cmd.Flags().StringVar(&f.team, "team", "", "a team: id, path (Acme/ml) or unique name") cmd.Flags().StringVar(&f.project, "project", "", "a project: id, path (Acme/ml/training) or unique name") } func (f *scopeFlags) resolve(cmd *cobra.Command, s *store.DB) (store.Scope, string, string, error) { n := 0 for _, v := range []string{f.org, f.team, f.project} { if v != "" { n++ } } if n != 1 { return "", "", "", fmt.Errorf("give exactly one of --org, --team or --project") } ctx := cmd.Context() switch { case f.org != "": o, err := resolveOrg(ctx, s, f.org) return store.ScopeOrg, o.ID, o.Name, err case f.team != "": t, err := resolveTeam(ctx, s, f.team) return store.ScopeTeam, t.ID, t.OrgName + " / " + t.Name, err default: p, err := resolveProject(ctx, s, f.project) return store.ScopeProject, p.ID, p.Path, err } } func newUserJoinCmd(open userOpener) *cobra.Command { var where scopeFlags var role, by string cmd := &cobra.Command{ Use: "join --org|--team|--project ", Short: "Make a person a member of an organization, team or project", Long: "A person can be a member of any number of them. A member of a team or\n" + "organization may use every project in it.", Example: " llmgw users join ayse@example.com --team Acme/ml\n" + " llmgw users join ayse@example.com --project Scania/research/vision\n" + " llmgw users join lars@example.com --org Scania --role admin", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { s, err := open(cmd) if err != nil { return err } defer s.Close() u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } scope, id, label, err := where.resolve(cmd, s) if err != nil { return err } if err := s.AddMembership(cmd.Context(), u.ID, scope, id, role, by, time.Now()); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "%s is a %s of %s\n", u.Email, role, label) return nil }, } where.bind(cmd) cmd.Flags().StringVar(&role, "role", store.RoleMember, "member; with --org also manager, who sees the organization's page, or admin, who also changes its teams, projects, people and keys") cmd.Flags().StringVar(&by, "by", "", "who added them, for the record") return cmd } func newUserLeaveCmd(open userOpener) *cobra.Command { var where scopeFlags cmd := &cobra.Command{ Use: "leave --org|--team|--project ", Short: "Take a person out of an organization, team or project", Long: "Keys they made in a project they can no longer use are revoked; a project\n" + "they still reach through another membership keeps them. Their personal\n" + "keys are not touched.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { s, err := open(cmd) if err != nil { return err } defer s.Close() u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } scope, id, label, err := where.resolve(cmd, s) if err != nil { return err } revoked, err := s.RemoveMembership(cmd.Context(), u.ID, scope, id, time.Now()) if errors.Is(err, store.ErrNotFound) { return fmt.Errorf("%s is not a member of %s itself", u.Email, label) } if err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "%s is no longer in %s (%s revoked)\n", u.Email, label, countOf(revoked, "key")) return nil }, } where.bind(cmd) return cmd } func newUserDeleteCmd(open userOpener) *cobra.Command { var yes bool cmd := &cobra.Command{ Use: "delete ", Short: "Remove an account for good", Long: "Signs them out, revokes every key they made, and removes the account. The\n" + "keys are kept, revoked and ownerless, because usage history refers to them.\n" + "To stop an account working and keep it, use suspend.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { s, err := open(cmd) if err != nil { return err } defer s.Close() u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } if !yes { return fmt.Errorf("this removes %s and revokes their keys; run again with --yes", u.Email) } if err := s.DeleteUser(cmd.Context(), u.ID, time.Now()); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "%s removed\n", u.Email) return nil }, } cmd.Flags().BoolVar(&yes, "yes", false, "confirm") return cmd } func newUserForgetCmd(open userOpener) *cobra.Command { var yes bool cmd := &cobra.Command{ Use: "forget ", Short: "Erase a person: their account, what was kept of their requests, and their name", Long: "For a request to be forgotten (GDPR article 17). Beyond delete:\n\n" + " - their usage — every request record and every sum — is kept, so the\n" + " totals stay true, under a random pseudonym that links to nothing;\n" + " - their requests kept by `keys requests`, and their personal files and\n" + " batches, are deleted;\n" + " - their name and email are taken out of the audit log;\n" + " - then the account goes, as delete does.\n\n" + "Conversations and agent notes belong to projects, not people, and are\n" + "left: a project forgets them by its retention. Backups taken before\n" + "still hold everything until they age out.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { s, err := open(cmd) if err != nil { return err } defer s.Close() u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } if !yes { return fmt.Errorf("this erases %s and cannot be undone; run again with --yes", u.Email) } f, err := s.ForgetUser(cmd.Context(), u.ID, time.Now()) if err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "%s forgotten. Their usage (%d requests, %d sums) is now %s; "+ "%d kept requests, %d files and %d batches deleted; %d audit lines no longer name them.\n", u.Email, f.UsageRows, f.RollupRows, f.Pseudonym, f.Requests, f.Files, f.Batches, f.AuditEvents) return nil }, } cmd.Flags().BoolVar(&yes, "yes", false, "confirm") return cmd } func newUserResetLinkCmd(open userOpener, sf *storeFlags) *cobra.Command { var base string cmd := &cobra.Command{ Use: "reset-link ", Short: "Make a one-time link for someone to set a forgotten password", Long: "For a gateway that sends no mail, or a person whose mail does not arrive:\n" + "the link is printed, to hand them however suits. It works once, for an\n" + "hour, and a newer one replaces it. Setting the password closes every\n" + "session they have open. The address it points at is mail.base_url from\n" + "the cluster's settings, or --base-url.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if base == "" { if cfg, err := loadEffectiveConfig(sf.config); err == nil { base = cfg.Mail.BaseURL } } if base == "" { return errors.New("give --base-url, the address people reach this gateway at, or set mail.base_url " + "(llmgw ctl config set mail.base_url https://…)") } s, err := open(cmd) if err != nil { return err } defer s.Close() u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } if u.PasswordHash == "" { return fmt.Errorf("%s signs in through single sign-on: there is no password to set", u.Email) } token, err := s.CreatePasswordReset(cmd.Context(), u.ID, account.ResetLinkTTL) if err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "%s/reset?token=%s\n", strings.TrimRight(base, "/"), url.QueryEscape(token)) cmd.PrintErrf("For %s: works once, for %s. Hand it to them alone; it opens their account.\n", u.Email, account.ResetLinkTTL) return nil }, } cmd.Flags().StringVar(&base, "base-url", "", "the address people reach this gateway at (default: mail.base_url)") return cmd }