package web import ( "context" "errors" "fmt" "net/http" "strings" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) // AccountAdmin is the store half of account administration. type AccountAdmin interface { Users(ctx context.Context, state store.UserState) ([]store.User, error) UserByID(ctx context.Context, id string) (store.User, error) ApproveUser(ctx context.Context, id, projectID, by string, at time.Time) error SetUserState(ctx context.Context, id string, state store.UserState) error DeleteUserSessions(ctx context.Context, userID string) error AllProjects(ctx context.Context) ([]store.ProjectPath, error) // The tenancy tree an account is approved into. Creating it used to need a // shell — `llmgw keys init` — so the first thing an operator had to do // after installing was leave the interface they were already in. AllOrgs(ctx context.Context) ([]store.Org, error) AllTeams(ctx context.Context) ([]store.TeamPath, error) CreateOrg(ctx context.Context, name string) (string, error) CreateTeam(ctx context.Context, orgID, name string) (string, error) CreateProject(ctx context.Context, teamID, name string) (string, error) // And keeping it in shape afterwards, which used to have no interface at // all: nothing could be renamed, moved or removed, and an approved person // stayed in the project they were first put in. Tree(ctx context.Context) ([]store.TreeOrg, error) RenameOrg(ctx context.Context, id, name string) error RenameTeam(ctx context.Context, id, name string) error RenameProject(ctx context.Context, id, name string) error MoveTeam(ctx context.Context, teamID, orgID string) error MoveProject(ctx context.Context, projectID, teamID string) error DeleteOrg(ctx context.Context, id string) error DeleteTeam(ctx context.Context, id string) error DeleteProject(ctx context.Context, id string) error MoveUser(ctx context.Context, userID, projectID string) (int, error) DeleteUser(ctx context.Context, userID string, at time.Time) error AllKeys(ctx context.Context) ([]store.KeyRow, error) RevokeKey(ctx context.Context, id string, at time.Time) error } // userRow is an account as the page shows it: with where it is, not an id. type userRow struct { store.User ProjectPath string } // keyRow is a key as the page shows it. type keyRow struct { store.KeyRow State string } type adminUsersData struct { Nav Nav Pending []store.User Accounts []userRow Tree []store.TreeOrg // People and Keys are keyed by project id. People map[string][]userRow Keys map[string][]keyRow Projects []store.ProjectPath Orgs []store.Org Teams []store.TeamPath // Open is the project whose section is shown expanded: the one just acted // on, so the result of a change is in view without the reader hunting for // it. Scripted pages keep every open section open; this is for the rest. Open string AdminID string Message string Error string CSRF string Version string Now string // HasMirror and HasQuotas keep the nav from offering a page that was never // mounted. HasMirror bool HasQuotas bool } // RegisterAdminUsers mounts account and tenancy administration under // /admin/users. // // What is here and what is not is the whole design. Approving, suspending, // moving people and keeping the tree in shape are the work an operator does // constantly and should not need a terminal for. Making someone an admin, // resetting a password and issuing keys on another person's behalf are not // here: each of them turns a stolen admin session into more than it was — // more access, or somebody else's account — and each is rare enough that a // shell is no hardship. Revoking a key is here, because it only ever takes // access away. func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { mux.HandleFunc("GET /admin/users", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { p.renderUsers(w, r, s, admin, "", "", r.URL.Query().Get("open")) })) mux.HandleFunc("POST /admin/users/{id}/approve", p.adminAction(s, func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) { project := r.FormValue("project") if project == "" { return "", errors.New("choose a project for their keys") } if err := s.ApproveUser(ctx, id, project, admin.Email, time.Now()); err != nil { return "", err } return "approved into " + projectPath(ctx, s, project), nil })) mux.HandleFunc("POST /admin/users/{id}/suspend", p.adminAction(s, func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) { if id == admin.ID { // Locking yourself out is not a thing an interface should let // you do by misclicking. return "", errors.New("you cannot suspend your own account") } if err := s.SetUserState(ctx, id, store.UserSuspended); err != nil { return "", err } // Otherwise they stay signed in and suspension is advisory until // their session happens to expire. if err := s.DeleteUserSessions(ctx, id); err != nil { return "", err } return "suspended, and signed out everywhere", nil })) mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s, func(ctx context.Context, _ store.User, id string, _ *http.Request) (string, error) { if err := s.SetUserState(ctx, id, store.UserActive); err != nil { return "", err } return "reinstated", nil })) mux.HandleFunc("POST /admin/users/{id}/move", p.adminAction(s, func(ctx context.Context, _ store.User, id string, r *http.Request) (string, error) { project := r.FormValue("project") if project == "" { return "", errors.New("choose the project they go to") } moved, err := s.MoveUser(ctx, id, project) if err != nil { return "", err } return fmt.Sprintf("moved to %s, with %s", projectPath(ctx, s, project), plural(moved, "key", "keys")), nil })) mux.HandleFunc("POST /admin/users/{id}/delete", p.adminAction(s, func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) { if id == admin.ID { return "", errors.New("you cannot remove your own account") } if err := s.DeleteUser(ctx, id, time.Now()); err != nil { return "", err } return "removed; their keys are revoked", nil })) // The tenancy tree. Creating a container is not granting access: nobody can // use one until an account is approved into it. mux.HandleFunc("POST /admin/tenancy/org", p.tenancyAction(s, func(ctx context.Context, r *http.Request) (string, error) { _, err := s.CreateOrg(ctx, r.FormValue("name")) if err != nil { return "", err } return "organization " + r.FormValue("name") + " created", nil })) mux.HandleFunc("POST /admin/tenancy/team", p.tenancyAction(s, func(ctx context.Context, r *http.Request) (string, error) { org := r.FormValue("org") if org == "" { return "", errors.New("choose the organization this team belongs to") } _, err := s.CreateTeam(ctx, org, r.FormValue("name")) if err != nil { return "", err } return "team " + r.FormValue("name") + " created", nil })) mux.HandleFunc("POST /admin/tenancy/project", p.tenancyAction(s, func(ctx context.Context, r *http.Request) (string, error) { team := r.FormValue("team") if team == "" { return "", errors.New("choose the team this project belongs to") } id, err := s.CreateProject(ctx, team, r.FormValue("name")) if err != nil { return "", err } r.Form.Set("open", id) return "project " + r.FormValue("name") + " created", nil })) mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/rename", p.tenancyAction(s, func(ctx context.Context, r *http.Request) (string, error) { id, name := r.PathValue("id"), strings.TrimSpace(r.FormValue("name")) var err error switch r.PathValue("kind") { case "org": err = s.RenameOrg(ctx, id, name) case "team": err = s.RenameTeam(ctx, id, name) case "project": err = s.RenameProject(ctx, id, name) default: return "", errors.New("nothing of that kind") } if err != nil { return "", err } return "renamed to " + name, nil })) mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/move", p.tenancyAction(s, func(ctx context.Context, r *http.Request) (string, error) { id, to := r.PathValue("id"), r.FormValue("to") if to == "" { return "", errors.New("choose where it goes") } switch r.PathValue("kind") { case "team": if err := s.MoveTeam(ctx, id, to); err != nil { return "", err } return "team moved", nil case "project": if err := s.MoveProject(ctx, id, to); err != nil { return "", err } return "project moved to " + projectPath(ctx, s, id), nil } return "", errors.New("only a team or a project moves") })) mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/delete", p.tenancyAction(s, func(ctx context.Context, r *http.Request) (string, error) { id := r.PathValue("id") var err error switch r.PathValue("kind") { case "org": err = s.DeleteOrg(ctx, id) case "team": err = s.DeleteTeam(ctx, id) case "project": err = s.DeleteProject(ctx, id) default: return "", errors.New("nothing of that kind") } if err != nil { return "", err } r.Form.Del("open") return "removed", nil })) mux.HandleFunc("POST /admin/keys/{id}/revoke", p.tenancyAction(s, func(ctx context.Context, r *http.Request) (string, error) { if err := s.RevokeKey(ctx, r.PathValue("id"), time.Now()); err != nil { return "", err } return "key revoked; a running server stops accepting it when its cache expires", nil })) } func projectPath(ctx context.Context, s AccountAdmin, id string) string { all, err := s.AllProjects(ctx) if err == nil { for _, p := range all { if p.ID == id { return p.Path } } } return id } // plainError says what a store error means to the person who caused it. func plainError(err error) error { switch { case errors.Is(err, store.ErrDuplicateName): return errors.New("there is already one of those here") case errors.Is(err, store.ErrNotEmpty): return fmt.Errorf("not removed: %s", strings.TrimPrefix(err.Error(), store.ErrNotEmpty.Error()+": ")) case errors.Is(err, store.ErrNotFound): return errors.New("that is no longer there; the page was out of date") } return err } // tenancyAction wraps a change to the tree: admin session, CSRF, audit, render. // // Its own wrapper rather than adminAction because that one is about a subject // account and looks its email up to name it in the log. There is no subject // here, and passing an empty id through it would write audit lines about // nobody. func (p *AccountPages) tenancyAction(s AccountAdmin, do func(ctx context.Context, r *http.Request) (string, error), ) http.HandlerFunc { return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { http.Error(w, "bad request", http.StatusBadRequest) return } msg, err := do(r.Context(), r) if err != nil { err = plainError(err) p.log.Warn("a tenancy change was refused", "admin", admin.Email, "path", r.URL.Path, "err", err) p.renderUsers(w, r, s, admin, "", err.Error(), r.FormValue("open")) return } p.log.Info("the tenancy tree was changed", "admin", admin.Email, "path", r.URL.Path, "what", msg) p.renderUsers(w, r, s, admin, msg, "", r.FormValue("open")) }) } // adminOnly is the guard every page under /admin shares. func (p *AccountPages) adminOnly( next func(w http.ResponseWriter, r *http.Request, admin store.User), ) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { noStore(w) _, user, ok := p.session(r) if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if !user.IsAdmin { // 404 rather than 403: a 403 confirms the page exists. http.NotFound(w, r) return } next(w, p.withNav(r, user), user) } } // adminAction wraps a change to one account: admin session, CSRF, audit line, // render. func (p *AccountPages) adminAction(s AccountAdmin, do func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error), ) http.HandlerFunc { return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { sess, _, _ := p.session(r) if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { http.Error(w, "bad request", http.StatusBadRequest) return } id := r.PathValue("id") subject := id if u, err := s.UserByID(r.Context(), id); err == nil { subject = u.Email } msg, err := do(r.Context(), admin, id, r) if err != nil { err = plainError(err) // Logged at warn whether it worked or not: a refused administrative // action is as worth knowing about as one that went through. p.log.Warn("an administrative action was refused", "admin", admin.Email, "subject", subject, "err", err) p.renderUsers(w, r, s, admin, "", subject+": "+err.Error(), r.FormValue("open")) return } p.log.Info("an account was changed", "admin", admin.Email, "subject", subject, "what", msg) p.renderUsers(w, r, s, admin, subject+" "+msg, "", r.FormValue("open")) }) } func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request, s AccountAdmin, admin store.User, msg, errMsg, open string, ) { ctx := r.Context() d := adminUsersData{ Nav: navFrom(r, "people"), Message: msg, Error: errMsg, Open: open, AdminID: admin.ID, CSRF: csrfFor(r), Version: p.version, HasMirror: p.hasMirror, HasQuotas: p.hasQuotas, Now: time.Now().UTC().Format(time.RFC3339), People: map[string][]userRow{}, Keys: map[string][]keyRow{}, } var err error logErr := func(what string, err error) { if err != nil { p.log.Error("could not read "+what, "err", err) if d.Error == "" { d.Error = "could not read " + what } } } d.Projects, err = s.AllProjects(ctx) logErr("the projects", err) paths := make(map[string]string, len(d.Projects)) for _, pp := range d.Projects { paths[pp.ID] = pp.Path } all, err := s.Users(ctx, "") logErr("the accounts", err) for _, u := range all { if u.State == store.UserPending && u.ProjectID == "" { d.Pending = append(d.Pending, u) continue } row := userRow{User: u, ProjectPath: paths[u.ProjectID]} d.Accounts = append(d.Accounts, row) d.People[u.ProjectID] = append(d.People[u.ProjectID], row) } keys, err := s.AllKeys(ctx) logErr("the keys", err) now := time.Now() for _, k := range keys { d.Keys[k.ProjectID] = append(d.Keys[k.ProjectID], keyRow{KeyRow: k, State: keyState(k.Key, now)}) } d.Tree, err = s.Tree(ctx) logErr("the tree", err) d.Orgs, err = s.AllOrgs(ctx) logErr("the organizations", err) d.Teams, err = s.AllTeams(ctx) logErr("the teams", err) render(w, "admin_users", d) } func keyState(k store.Key, now time.Time) string { switch { case !k.RevokedAt.IsZero(): return "revoked" case !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt): return "expired" default: return "active" } }