A path is still SQLite and a postgres:// URL is PostgreSQL, so every existing configuration keeps working untouched and moving is one line changed rather than a new setting to find. An address of the form @/path/to/file reads the real one out of that file, which is how a DSN carrying a password stays out of a configuration that gets copied, diffed and pasted into issues. The driver is pgx, which is pure Go. That is not a preference: the whole binary is built with CGO_ENABLED=0 so it runs on a node whose libc is older than the builder's, and a cgo driver would take that away from every deployment, including the ones still on SQLite. Tested against a real PostgreSQL, which found two things nothing else could. The schema_migrations table is created in Go rather than in a migration file, so it escaped the port and still said INTEGER — 64 bits in SQLite, 32 in PostgreSQL, and a millisecond timestamp does not fit, so the very first migration failed to record that it had run. And the memory migration uses an FTS5 virtual table with three triggers keeping it in step, which has no counterpart: PostgreSQL derives a tsvector from the row, so there is nothing to keep in step and no trigger to forget. That leaves exactly one query that is not the same sentence in both dialects — the note search, MATCH against @@ — and it is written out twice rather than hidden behind a shared spelling that would mean something subtly different in each. Everything else is one set of queries. The text search uses the 'simple' configuration rather than English. These are an agent's own notes, in whatever language it chose, and stemming English rules over Turkish would quietly stop finding things. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
268 lines
8.3 KiB
Go
268 lines
8.3 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.Open(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),
|
|
newUserAdminCmd(open, "promote", true, "Let an account see the cluster dashboard at /admin"),
|
|
newUserAdminCmd(open, "demote", false, "Take away access to the cluster dashboard"))
|
|
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\tADMIN\tPROJECT\tSIGNED UP\tLAST LOGIN")
|
|
now := time.Now()
|
|
for _, u := range users {
|
|
admin := "-"
|
|
if u.IsAdmin {
|
|
admin = "yes"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
u.ID, u.Email, u.Name, u.State, admin, 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 newUserAdminCmd(open userOpener, verb string, admin bool, short string) *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: verb + " <user-id>",
|
|
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()
|
|
|
|
if err := s.SetAdmin(cmd.Context(), args[0], admin); err != nil {
|
|
return err
|
|
}
|
|
u, err := s.UserByID(cmd.Context(), args[0])
|
|
if 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 <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
|
|
}
|