A quota could be enforced and reported, and set nowhere but a shell. Nothing in the browser showed one at all — not what a team may spend, not what it has spent, not that it had no limit. In practice that means quotas nobody sets. /admin/quotas lists the whole tree, whether or not a group has a limit: the one without a quota is the one most likely to need one, so it cannot be the row the page leaves out. Spend over the last thirty days sits beside the budget, with the fraction used. Setting a quota here is a change of position. It was kept to the shell because raising a budget is a financial act and a stolen admin session should not perform one. That argument holds for exactly one row on this page: resolution takes the most restrictive value along the chain, so raising a team's or a project's limit cannot exceed what the organization allows. Only an organization's own limit is bounded by nothing. Every change is logged with who made it and what it was before. Per-key limits stay on the CLI. A key belongs to a person and is already bounded by its project; letting an admin session reach individual keys would put every caller's throughput one click away, and the page refuses a key scope rather than rendering it and failing later. A limit an ancestor already caps lower is marked as written-but-unreachable. Resolution means a project given more than its team is not a bigger budget, it is a number nobody will reach — and without saying so the page would show it as though it applied, with the first sign being a refusal that disagrees with the screen. The group is chosen, not typed. Ids became random in the previous commit so two organizations could both have a "platform" team, which also made them impossible to remember and a typo impossible to notice: it would write a quota onto nothing and report success. A blank field is no limit of that kind, not zero — zero is a limit of nothing, which refuses every request. Clearing removes the row rather than writing nulls across it: both resolve the same, only one tells a reader nobody has set a limit here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
255 lines
8.7 KiB
Go
255 lines
8.7 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"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)
|
|
}
|
|
|
|
type adminUsersData struct {
|
|
Pending []store.User
|
|
Active []store.User
|
|
Projects []store.ProjectPath
|
|
Orgs []store.Org
|
|
Teams []store.TeamPath
|
|
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 administration under /admin/users.
|
|
//
|
|
// What is here and what is not is the whole design. Approving and suspending
|
|
// 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.
|
|
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, "", "")
|
|
}))
|
|
|
|
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 " + 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
|
|
}))
|
|
|
|
// The tenancy tree. Creating a container is not granting access: nobody can
|
|
// use one until an account is approved into it, which is the step above and
|
|
// already lives here. What is still not here is limits — raising a budget
|
|
// is a financial act, and a stolen session must not be able to perform one.
|
|
mux.HandleFunc("POST /admin/tenancy/org", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (string, error) {
|
|
id, err := s.CreateOrg(ctx, r.FormValue("name"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "organization " + r.FormValue("name") + " created as " + id, 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")
|
|
}
|
|
id, err := s.CreateTeam(ctx, org, r.FormValue("name"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "team " + r.FormValue("name") + " created as " + id, 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
|
|
}
|
|
return "project " + r.FormValue("name") + " created as " + id, 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
|
|
}))
|
|
}
|
|
|
|
// 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 {
|
|
if errors.Is(err, store.ErrDuplicateName) {
|
|
err = errors.New("there is already one of those here")
|
|
}
|
|
p.log.Warn("a tenancy change was refused", "admin", admin.Email, "err", err)
|
|
p.renderUsers(w, r, s, "", err.Error())
|
|
return
|
|
}
|
|
p.log.Info("the tenancy tree was changed", "admin", admin.Email, "what", msg)
|
|
p.renderUsers(w, r, s, msg, "")
|
|
})
|
|
}
|
|
|
|
// 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, r, user)
|
|
}
|
|
}
|
|
|
|
// adminAction wraps a state change: admin session, CSRF, audit line, redirect.
|
|
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 {
|
|
// 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, "", err.Error())
|
|
return
|
|
}
|
|
p.log.Info("an account was changed",
|
|
"admin", admin.Email, "subject", subject, "what", msg)
|
|
p.renderUsers(w, r, s, subject+" "+msg, "")
|
|
})
|
|
}
|
|
|
|
func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request,
|
|
s AccountAdmin, msg, errMsg string,
|
|
) {
|
|
d := adminUsersData{
|
|
Message: msg, Error: errMsg,
|
|
CSRF: csrfFor(r),
|
|
Version: p.version,
|
|
HasMirror: p.hasMirror,
|
|
HasQuotas: p.hasQuotas,
|
|
Now: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
all, err := s.Users(r.Context(), "")
|
|
if err != nil {
|
|
p.log.Error("could not list accounts", "err", err)
|
|
d.Error = "could not read the accounts"
|
|
}
|
|
for _, u := range all {
|
|
if u.State == store.UserPending {
|
|
d.Pending = append(d.Pending, u)
|
|
} else {
|
|
d.Active = append(d.Active, u)
|
|
}
|
|
}
|
|
if d.Projects, err = s.AllProjects(r.Context()); err != nil {
|
|
p.log.Error("could not list projects", "err", err)
|
|
}
|
|
if d.Orgs, err = s.AllOrgs(r.Context()); err != nil {
|
|
p.log.Error("could not list organizations", "err", err)
|
|
}
|
|
if d.Teams, err = s.AllTeams(r.Context()); err != nil {
|
|
p.log.Error("could not list teams", "err", err)
|
|
}
|
|
render(w, "admin_users", d)
|
|
}
|