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>
297 lines
10 KiB
Go
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
|
|
}
|