Files
llmgw/internal/web/org_admin.go
T
Ilker ManapandClaude Opus 5.5 102ca9556f An organization's admin, and forms only for those who may send them
C1. A membership of an organization may be admin as well as member and
manager. An admin sees /org as a manager does and changes the
organization from it: its teams and projects, who of the approved
accounts is in them, its members' roles, and the keys spent in its
projects. Every id a form names is checked against the organization;
limits, accounts, the organization's name and the cluster stay with the
cluster's staff, and an admin cannot take their own role away.

A group from OIDC, SCIM or a directory no longer rewrites a membership
it names as a plain member at every sign-in, which took a manager's role
away (EnsureMembership).

Pages draw a form only for someone who may send it: Nav.May asks the
staff rule staffMay keeps, and a page whose forms a person may not send
gets no form token. A viewer sees the people, limits and mirror pages
without forms; an operator the mirror's.

Forms are compact: labels close to their fields, fields as wide as what
goes in them, smaller buttons, and the short forms on one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:38:06 +01:00

297 lines
10 KiB
Go

package web
import (
"context"
"errors"
"net/http"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// An organization's admin changes it from its own page.
//
// What they may change is what is theirs: the teams and projects in their
// organization, who of the cluster's people is in them and in what role, and
// the keys spent there. What stays with the cluster's staff is everything that
// reaches past the organization or into the cluster's capacity: its limits and
// budgets, approving or suspending an account, making anyone staff, and the
// organization's own name and existence. A person not yet approved is not
// added here either: approval says who may use the cluster at all.
// OrgEditor is what an organization's admin changes through.
type OrgEditor interface {
AdministersOrg(ctx context.Context, userID, orgID string) (bool, error)
UserByEmail(ctx context.Context, email string) (store.User, error)
CreateTeam(ctx context.Context, orgID, name string) (string, error)
CreateProject(ctx context.Context, teamID, name string) (string, error)
RenameTeam(ctx context.Context, id, name string) error
RenameProject(ctx context.Context, id, name 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)
AllKeys(ctx context.Context) ([]store.KeyRow, error)
RevokeKey(ctx context.Context, id string, at time.Time) error
}
func init() {
registerKeys(
"org.msg.team_created", "org.msg.project_created", "org.msg.renamed", "org.msg.removed",
"org.msg.added", "org.msg.taken_out", "org.msg.taken_out_keys", "org.msg.key_revoked",
"org.err.not_here", "org.err.no_account", "org.err.not_approved", "org.err.suspended",
"org.err.self_role", "org.err.self_leave", "org.err.name", "org.err.role",
)
}
// orgChange is one change an organization's admin makes, given the
// organization as it is.
type orgChange func(ctx context.Context, r *http.Request, by store.User, org store.TreeOrg) (notice, error)
// registerOrgAdmin mounts the changes, when the store makes them.
func (p *AccountPages) registerOrgAdmin(mux *http.ServeMux, s OrgViewer, e OrgEditor, priced Priced) {
act := func(do orgChange) http.HandlerFunc { return p.orgAction(s, e, priced, do) }
mux.HandleFunc("POST /org/{id}/teams", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) {
name, err := orgName(r)
if err != nil {
return notice{}, err
}
if _, err := e.CreateTeam(ctx, o.ID, name); err != nil {
return notice{}, err
}
return say("org.msg.team_created", name), nil
}))
mux.HandleFunc("POST /org/{id}/projects", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) {
team := r.FormValue("team")
if !orgHas(o, store.ScopeTeam, team) {
return notice{}, refuse("org.err.not_here")
}
name, err := orgName(r)
if err != nil {
return notice{}, err
}
if _, err := e.CreateProject(ctx, team, name); err != nil {
return notice{}, err
}
return say("org.msg.project_created", name), nil
}))
mux.HandleFunc("POST /org/{id}/{kind}/{sid}/rename", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) {
kind, sid := store.Scope(r.PathValue("kind")), r.PathValue("sid")
if (kind != store.ScopeTeam && kind != store.ScopeProject) || !orgHas(o, kind, sid) {
return notice{}, refuse("org.err.not_here")
}
name, err := orgName(r)
if err != nil {
return notice{}, err
}
if kind == store.ScopeTeam {
err = e.RenameTeam(ctx, sid, name)
} else {
err = e.RenameProject(ctx, sid, name)
}
if err != nil {
return notice{}, err
}
return say("org.msg.renamed", name), nil
}))
mux.HandleFunc("POST /org/{id}/{kind}/{sid}/delete", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) {
kind, sid := store.Scope(r.PathValue("kind")), r.PathValue("sid")
if (kind != store.ScopeTeam && kind != store.ScopeProject) || !orgHas(o, kind, sid) {
return notice{}, refuse("org.err.not_here")
}
name := orgPlaceName(o, kind, sid)
var err error
if kind == store.ScopeTeam {
err = e.DeleteTeam(ctx, sid)
} else {
err = e.DeleteProject(ctx, sid)
}
if err != nil {
return notice{}, err
}
return say("org.msg.removed", name), nil
}))
// A person is added by their address: the page does not list the whole
// cluster's accounts to someone who administers one organization.
mux.HandleFunc("POST /org/{id}/members", act(func(ctx context.Context, r *http.Request, by store.User, o store.TreeOrg) (notice, error) {
scope, sid, ok := strings.Cut(r.FormValue("where"), ":")
if !ok || !orgHas(o, store.Scope(scope), sid) {
return notice{}, refuse("org.err.not_here")
}
email := strings.TrimSpace(r.FormValue("email"))
u, err := e.UserByEmail(ctx, email)
if errors.Is(err, store.ErrNotFound) {
return notice{}, refuse("org.err.no_account", email)
}
if err != nil {
return notice{}, err
}
switch u.State {
case store.UserPending:
return notice{}, refuse("org.err.not_approved", u.Email)
case store.UserSuspended:
return notice{}, refuse("org.err.suspended", u.Email)
}
if err := e.AddMembership(ctx, u.ID, store.Scope(scope), sid, store.RoleMember, by.Email, time.Now()); err != nil {
return notice{}, err
}
return say("org.msg.added", u.Email, orgPlaceName(o, store.Scope(scope), sid)), nil
}))
mux.HandleFunc("POST /org/{id}/members/{uid}/role", act(func(ctx context.Context, r *http.Request, by store.User, o store.TreeOrg) (notice, error) {
uid, role := r.PathValue("uid"), r.FormValue("role")
if !slices.Contains(store.MembershipRoles, role) {
return notice{}, refuse("org.err.role")
}
if uid == by.ID && !by.IsAdmin {
// Giving up one's own admin role by a misclick would leave
// nobody able to give it back but the cluster's staff.
return notice{}, refuse("org.err.self_role")
}
if err := e.AddMembership(ctx, uid, store.ScopeOrg, o.ID, role, by.Email, time.Now()); err != nil {
return notice{}, err
}
return roleNotice(role, o.Name), nil
}))
mux.HandleFunc("POST /org/{id}/members/{uid}/leave", act(func(ctx context.Context, r *http.Request, by store.User, o store.TreeOrg) (notice, error) {
uid := r.PathValue("uid")
scope, sid, ok := strings.Cut(r.FormValue("where"), ":")
if !ok || !orgHas(o, store.Scope(scope), sid) {
return notice{}, refuse("org.err.not_here")
}
if uid == by.ID && store.Scope(scope) == store.ScopeOrg && !by.IsAdmin {
return notice{}, refuse("org.err.self_leave")
}
where := orgPlaceName(o, store.Scope(scope), sid)
revoked, err := e.RemoveMembership(ctx, uid, store.Scope(scope), sid, time.Now())
if err != nil {
return notice{}, err
}
if revoked > 0 {
return say("org.msg.taken_out_keys", where, revoked), nil
}
return say("org.msg.taken_out", where), nil
}))
mux.HandleFunc("POST /org/{id}/keys/{kid}/revoke", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) {
kid := r.PathValue("kid")
keys, err := e.AllKeys(ctx)
if err != nil {
return notice{}, err
}
i := slices.IndexFunc(keys, func(k store.KeyRow) bool { return k.ID == kid })
if i < 0 || !orgHas(o, store.ScopeProject, keys[i].ProjectID) {
return notice{}, refuse("org.err.not_here")
}
if err := e.RevokeKey(ctx, kid, time.Now()); err != nil {
return notice{}, err
}
return say("org.msg.key_revoked", keys[i].Prefix), nil
}))
}
// orgAction is the guard every change on an organization's page shares: a
// session, the organization's admin role (or the cluster's), the form's CSRF
// token, an audit record, and the page drawn again with what happened.
func (p *AccountPages) orgAction(s OrgViewer, e OrgEditor, priced Priced, do orgChange) http.HandlerFunc {
return p.orgGuard(func(w http.ResponseWriter, r *http.Request, user store.User) {
id := r.PathValue("id")
if !user.IsAdmin {
ok, err := e.AdministersOrg(r.Context(), user.ID, id)
if err != nil || !ok {
// 404, as for a page they may not see: a manager learns
// nothing from trying.
http.NotFound(w, r)
return
}
}
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
badForm(w, r)
return
}
p.audited(w, r, user, func(w http.ResponseWriter, r *http.Request) {
l := langOf(r)
org, ok := orgIn(r.Context(), s, id)
if !ok {
http.NotFound(w, r)
return
}
msg, err := do(r.Context(), r, user, org)
if err != nil {
err = plainError(err)
p.log.Warn("a change to an organization was refused", "by", user.Email, "org", org.Name,
"path", r.URL.Path, "err", err)
noteAudit(r, org.Name, "", err.Error())
p.renderOrg(w, r, s, user, id, priced, "", errorIn(l, err))
return
}
p.log.Info("an organization was changed", "by", user.Email, "org", org.Name,
"what", msg.in(english))
noteAudit(r, org.Name, msg.in(english), "")
p.renderOrg(w, r, s, user, id, priced, msg.in(l), "")
})
})
}
// orgIn finds an organization in the tree.
func orgIn(ctx context.Context, s OrgViewer, id string) (store.TreeOrg, bool) {
tree, err := s.Tree(ctx)
if err != nil {
return store.TreeOrg{}, false
}
for _, o := range tree {
if o.ID == id {
return o, true
}
}
return store.TreeOrg{}, false
}
// orgHas reports whether a place is the organization or lies in it. Every
// change checks it: an id in a form is the sender's to choose.
func orgHas(o store.TreeOrg, scope store.Scope, id string) bool {
return orgPlaceName(o, scope, id) != ""
}
// orgPlaceName is a place in the organization by its path, "" when it is not
// in it.
func orgPlaceName(o store.TreeOrg, scope store.Scope, id string) string {
if id == "" {
return ""
}
if scope == store.ScopeOrg {
if id == o.ID {
return o.Name
}
return ""
}
for _, t := range o.Teams {
if scope == store.ScopeTeam && t.ID == id {
return o.Name + " / " + t.Name
}
for _, pr := range t.Projects {
if scope == store.ScopeProject && pr.ID == id {
return o.Name + " / " + t.Name + " / " + pr.Name
}
}
}
return ""
}
func orgName(r *http.Request) (string, error) {
name := strings.TrimSpace(r.FormValue("name"))
if name == "" || strings.Contains(name, "/") {
return "", refuse("org.err.name")
}
return name, nil
}