package web import ( "context" "errors" "fmt" "net/http" "sort" "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 AddMembership(ctx context.Context, userID string, scope store.Scope, scopeID, role, by string, at time.Time) error RemoveMembership(ctx context.Context, userID string, scope store.Scope, scopeID string, at time.Time) (int, error) AllMemberships(ctx context.Context) ([]store.Membership, 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 // Memberships are where the person is, by path; none is a person who uses // the gateway on their own. Memberships []store.Membership // Role is their role in the scope a list of members is drawn for. Role string } // scopeOption is somewhere a person can be put, for a chooser. type scopeOption struct { Value string // "project:proj-…" Label string // "Acme / ml / training" } // 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 // Members is each scope's own members, keyed "project:" and likewise // for teams and organizations; Keys is keyed by project id. Members map[string][]userRow // Scopes is every place a person can be added, organizations and teams // included. Scopes []scopeOption Keys map[string][]keyRow Projects []store.ProjectPath Orgs []store.Org Teams []store.TeamPath // Sel is the branch or person whose panel is open, "team:", so the // tree can mark it. Sel string // 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) { p.peopleStore = s 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 err := s.ApproveUser(ctx, id, project, admin.Email, time.Now()); err != nil { return "", err } if project == "" { return "approved, on their own for now", nil } 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}/join", p.adminAction(s, func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) { scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":") if !ok || scopeID == "" { return "", errors.New("choose where they go") } if err := s.AddMembership(ctx, id, store.Scope(scope), scopeID, store.RoleMember, admin.Email, time.Now()); err != nil { return "", err } return "added to " + scopeLabel(ctx, s, store.Scope(scope), scopeID), nil })) // Managing is a role on an organization membership: it lets the person see // that organization's page, and nothing else changes. mux.HandleFunc("POST /admin/users/{id}/role", p.adminAction(s, func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) { scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":") role := r.FormValue("role") if !ok || store.Scope(scope) != store.ScopeOrg || (role != store.RoleManager && role != store.RoleMember) { return "", errors.New("a manager is a role in an organization") } if err := s.AddMembership(ctx, id, store.ScopeOrg, scopeID, role, admin.Email, time.Now()); err != nil { return "", err } label := scopeLabel(ctx, s, store.ScopeOrg, scopeID) if role == store.RoleManager { return "now manages " + label, nil } return "no longer manages " + label, nil })) mux.HandleFunc("POST /admin/users/{id}/leave", p.adminAction(s, func(ctx context.Context, _ store.User, id string, r *http.Request) (string, error) { scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":") if !ok { return "", errors.New("nothing to leave") } label := scopeLabel(ctx, s, store.Scope(scope), scopeID) revoked, err := s.RemoveMembership(ctx, id, store.Scope(scope), scopeID, time.Now()) if err != nil { return "", err } msg := "removed from " + label if revoked > 0 { msg += "; " + plural(revoked, "key", "keys") + " they made there revoked" } return msg, 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 })) // The same as joining, from the scope's side: a form on a project, team or // organization that names the person. mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/add", p.tenancyAction(s, func(ctx context.Context, r *http.Request) (string, error) { user := r.FormValue("user") if user == "" { return "", errors.New("choose who to add") } scope, id := store.Scope(r.PathValue("kind")), r.PathValue("id") if err := s.AddMembership(ctx, user, scope, id, store.RoleMember, "", time.Now()); err != nil { return "", err } who := user if u, err := s.UserByID(ctx, user); err == nil { who = u.Email } return who + " added to " + scopeLabel(ctx, s, scope, id), 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 } // scopeLabel names an org, team or project by its path. func scopeLabel(ctx context.Context, s AccountAdmin, scope store.Scope, id string) string { switch scope { case store.ScopeProject: return projectPath(ctx, s, id) case store.ScopeTeam: if teams, err := s.AllTeams(ctx); err == nil { for _, t := range teams { if t.ID == id { return t.OrgName + " / " + t.Name } } } case store.ScopeOrg: if orgs, err := s.AllOrgs(ctx); err == nil { for _, o := range orgs { if o.ID == id { return o.Name } } } } 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, _ AccountAdmin, admin store.User, msg, errMsg, open string, ) { p.renderPeople(w, r, admin, peopleView{Tab: tabPeople, Msg: msg, Err: errMsg, Open: open}) } // buildUsers is the tree and its people, as the first tab shows them. func (p *AccountPages) buildUsers(r *http.Request, s AccountAdmin, admin store.User, msg, errMsg, open string) adminUsersData { ctx := r.Context() d := adminUsersData{ Nav: navFrom(r, "people"), Message: msg, Error: errMsg, Open: open, AdminID: admin.ID, Sel: r.FormValue("sel"), CSRF: csrfFor(r), Version: p.version, HasMirror: p.hasMirror, HasQuotas: p.hasQuotas, Now: time.Now().UTC().Format(time.RFC3339), Members: 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) memberships, err := s.AllMemberships(ctx) logErr("the memberships", err) byUser := map[string][]store.Membership{} for _, m := range memberships { byUser[m.UserID] = append(byUser[m.UserID], m) } users := map[string]store.User{} for _, u := range all { users[u.ID] = u if u.State == store.UserPending { d.Pending = append(d.Pending, u) continue } d.Accounts = append(d.Accounts, userRow{User: u, Memberships: byUser[u.ID]}) } for _, m := range memberships { if u, ok := users[m.UserID]; ok { k := string(m.Scope) + ":" + m.ScopeID d.Members[k] = append(d.Members[k], userRow{User: u, Memberships: byUser[u.ID], Role: m.Role}) } } 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) for _, o := range d.Orgs { d.Scopes = append(d.Scopes, scopeOption{"org:" + o.ID, o.Name}) } for _, t := range d.Teams { d.Scopes = append(d.Scopes, scopeOption{"team:" + t.ID, t.OrgName + " / " + t.Name}) } for _, pp := range d.Projects { d.Scopes = append(d.Scopes, scopeOption{"project:" + pp.ID, pp.Path}) } sort.Slice(d.Scopes, func(i, j int) bool { return d.Scopes[i].Label < d.Scopes[j].Label }) return 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" } }