Files
2026-10-09 06:46:12 +02:00

659 lines
21 KiB
Go

package adminapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// The tenancy tree, keys and people's memberships, administered over the
// API as `llmgw org`, `keys` and `users` do against the store: so that
// they can be run from elsewhere, and kept in Terraform.
// TenancyStore is the part of the store the tenancy endpoints need.
type TenancyStore interface {
Tree(ctx context.Context) ([]store.TreeOrg, 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)
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
AllKeys(ctx context.Context) ([]store.KeyRow, error)
KeyByID(ctx context.Context, id string) (store.Key, error)
CreateKey(ctx context.Context, k store.Key) error
CreateUserKey(ctx context.Context, userID string, k store.Key) error
RevokeKey(ctx context.Context, id string, at time.Time) error
Users(ctx context.Context, state store.UserState) ([]store.User, error)
UserByID(ctx context.Context, id string) (store.User, error)
UserByEmail(ctx context.Context, email string) (store.User, error)
MembershipsOf(ctx context.Context, userID string) ([]store.Membership, 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)
}
// SetTenancy enables the tenancy endpoints. The quota store must be set
// too: names are resolved through it.
func (a *API) SetTenancy(t TenancyStore) { a.tenancy = t }
// OrgView is an organization with what is in it.
type OrgView struct {
ID string `json:"id"`
Name string `json:"name"`
Members int `json:"members"`
Teams []TeamView `json:"teams"`
}
// TeamView is a team with its projects.
type TeamView struct {
ID string `json:"id"`
Name string `json:"name"`
Org string `json:"org"`
Members int `json:"members"`
Projects []ProjectView `json:"projects"`
}
// ProjectView is a project, with how many people and keys are in it.
type ProjectView struct {
ID string `json:"id"`
Name string `json:"name"`
Team string `json:"team"`
// Path is ORG/TEAM/PROJECT by name.
Path string `json:"path"`
Users int `json:"users"`
Keys int `json:"keys"`
ActiveKeys int `json:"active_keys"`
}
// TenancyCreateRequest adds an organization, a team or a project.
type TenancyCreateRequest struct {
Name string `json:"name"`
// In is the organization a team goes in, or the team a project goes in
// (ORG/TEAM), by name or ID; nothing for an organization.
In string `json:"in,omitempty"`
}
// TenancyChangeRequest renames an organization, team or project, or moves
// a team to another organization or a project to another team.
type TenancyChangeRequest struct {
Name string `json:"name,omitempty"`
// In is where it moves to, by name or ID; not for an organization.
In string `json:"in,omitempty"`
}
// TenancyItem is an organization, team or project added or changed.
type TenancyItem struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
// In is the organization a team is in, or the team a project is in, by
// ID.
In string `json:"in,omitempty"`
}
// KeyView is a key as listed: never the key itself, which is not kept.
type KeyView struct {
ID string `json:"id"`
Name string `json:"name"`
// Prefix is the key's first characters, to tell it apart.
Prefix string `json:"prefix"`
Project string `json:"project,omitempty"`
// ProjectPath is ORG/TEAM/PROJECT, or empty for a personal key.
ProjectPath string `json:"project_path,omitempty"`
User string `json:"user,omitempty"`
UserEmail string `json:"user_email,omitempty"`
Band string `json:"band,omitempty"`
RoutingProfile string `json:"routing_profile,omitempty"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at,omitzero"`
RevokedAt time.Time `json:"revoked_at,omitzero"`
LastUsedAt time.Time `json:"last_used_at,omitzero"`
}
// KeyCreateRequest issues a key: a project's, a person's in a project they
// are a member of, or a person's own with no project.
type KeyCreateRequest struct {
Name string `json:"name"`
// Project is ORG/TEAM/PROJECT or its ID.
Project string `json:"project,omitempty"`
// User is the person it belongs to, by ID or email.
User string `json:"user,omitempty"`
Band string `json:"band,omitempty"`
RoutingProfile string `json:"routing_profile,omitempty"`
// ExpiresIn is its lifetime, as 720h; none never expires, unless a
// scope above it limits how long keys live.
ExpiresIn string `json:"expires_in,omitempty"`
}
// KeyCreated is a new key, with the key itself: this answer is the only
// time it is given.
type KeyCreated struct {
KeyView
Secret string `json:"secret"`
}
// UserView is a person, with where they are a member.
type UserView struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
State string `json:"state"`
// Role is the person's part in running the gateway, if any: viewer,
// operator, billing or admin.
Role string `json:"role,omitempty"`
Memberships []MembershipView `json:"memberships"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt time.Time `json:"last_login_at,omitzero"`
}
// MembershipView is one place a person belongs.
type MembershipView struct {
Target string `json:"target"`
Path string `json:"path,omitempty"`
Role string `json:"role"`
At time.Time `json:"at"`
By string `json:"by,omitempty"`
}
// MembershipRequest puts a person in an organization, team or project, or
// with DELETE takes them out.
type MembershipRequest struct {
// Target is org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or the
// ID after the colon.
Target string `json:"target"`
// Role is member or manager; member when absent. Ignored by DELETE.
Role string `json:"role,omitempty"`
}
func (a *API) registerTenancy(mux Mux) {
mux.HandleFunc("GET /api/v1/orgs", a.tenancyTree)
for _, level := range []store.Scope{store.ScopeOrg, store.ScopeTeam, store.ScopeProject} {
path := "/api/v1/" + string(level) + "s"
mux.HandleFunc("POST "+path, a.tenancyCreate(level))
mux.HandleFunc("PATCH "+path+"/{id}", a.tenancyChange(level))
mux.HandleFunc("DELETE "+path+"/{id}", a.tenancyDelete(level))
}
mux.HandleFunc("GET /api/v1/keys", a.keyList)
mux.HandleFunc("POST /api/v1/keys", a.keyCreate)
mux.HandleFunc("GET /api/v1/keys/{id}", a.keyGet)
mux.HandleFunc("DELETE /api/v1/keys/{id}", a.keyRevoke)
mux.HandleFunc("GET /api/v1/users", a.userList)
mux.HandleFunc("GET /api/v1/users/{id}", a.userGet)
mux.HandleFunc("POST /api/v1/users/{id}/memberships", a.membership(true))
mux.HandleFunc("DELETE /api/v1/users/{id}/memberships", a.membership(false))
}
// tenancyReady answers for a control plane without the store, and says
// whether it did.
func (a *API) tenancyReady(w http.ResponseWriter) bool {
if a.tenancy == nil || a.quotas == nil {
writeError(w, http.StatusNotImplemented, "organizations, keys and people need the store, which this control plane runs without")
return false
}
return true
}
// tenancyStatus is the status a store error answers with.
func tenancyStatus(err error) int {
switch {
case errors.Is(err, store.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, store.ErrDuplicateName), errors.Is(err, store.ErrNotEmpty):
return http.StatusConflict
}
return http.StatusBadRequest
}
func (a *API) tenancyTree(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
tree, err := a.tenancy.Tree(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := []OrgView{}
for _, o := range tree {
ov := OrgView{ID: o.ID, Name: o.Name, Members: o.Members, Teams: []TeamView{}}
for _, t := range o.Teams {
tv := TeamView{ID: t.ID, Name: t.Name, Org: o.ID, Members: t.Members, Projects: []ProjectView{}}
for _, p := range t.Projects {
tv.Projects = append(tv.Projects, ProjectView{ID: p.ID, Name: p.Name, Team: t.ID,
Path: o.Name + "/" + t.Name + "/" + p.Name, Users: p.Users, Keys: p.Keys, ActiveKeys: p.ActiveKeys})
}
ov.Teams = append(ov.Teams, tv)
}
out = append(out, ov)
}
writeJSON(w, http.StatusOK, out)
}
// parentOf is the level something at level sits in.
func parentOf(level store.Scope) store.Scope {
if level == store.ScopeProject {
return store.ScopeTeam
}
return store.ScopeOrg
}
// levelNoun is a level as a sentence says it.
func levelNoun(level store.Scope) string {
switch level {
case store.ScopeOrg:
return "an organization"
case store.ScopeTeam:
return "a team"
}
return "a project"
}
func (a *API) tenancyCreate(level store.Scope) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
var in TenancyCreateRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
writeError(w, http.StatusBadRequest, "a name is needed")
return
}
ctx := r.Context()
var id, parent string
var err error
if level != store.ScopeOrg {
if in.In == "" {
writeError(w, http.StatusBadRequest, fmt.Sprintf("%s goes in %s: give in", levelNoun(level), levelNoun(parentOf(level))))
return
}
if _, parent, _, err = a.resolveScope(ctx, string(parentOf(level))+":"+in.In); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
}
switch level {
case store.ScopeOrg:
id, err = a.tenancy.CreateOrg(ctx, in.Name)
case store.ScopeTeam:
id, err = a.tenancy.CreateTeam(ctx, parent, in.Name)
default:
id, err = a.tenancy.CreateProject(ctx, parent, in.Name)
}
if errors.Is(err, store.ErrDuplicateName) {
writeError(w, http.StatusConflict, fmt.Sprintf("there is already %s named %q there", levelNoun(level), in.Name))
return
}
if err != nil {
writeError(w, tenancyStatus(err), err.Error())
return
}
writeJSON(w, http.StatusCreated, TenancyItem{ID: id, Name: in.Name, In: parent})
}
}
func (a *API) tenancyChange(level store.Scope) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
var in TenancyChangeRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
_, id, _, err := a.resolveScope(ctx, string(level)+":"+r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if in.Name == "" && in.In == "" {
writeError(w, http.StatusBadRequest, "nothing to change: give name, in or both")
return
}
out := TenancyItem{ID: id}
if in.In != "" {
if level == store.ScopeOrg {
writeError(w, http.StatusBadRequest, "an organization is in nothing; only a team or a project moves")
return
}
_, to, _, err := a.resolveScope(ctx, string(parentOf(level))+":"+in.In)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
if level == store.ScopeTeam {
err = a.tenancy.MoveTeam(ctx, id, to)
} else {
err = a.tenancy.MoveProject(ctx, id, to)
}
if err != nil {
writeError(w, tenancyStatus(err), err.Error())
return
}
out.In = to
}
if name := strings.TrimSpace(in.Name); name != "" {
switch level {
case store.ScopeOrg:
err = a.tenancy.RenameOrg(ctx, id, name)
case store.ScopeTeam:
err = a.tenancy.RenameTeam(ctx, id, name)
default:
err = a.tenancy.RenameProject(ctx, id, name)
}
if err != nil {
writeError(w, tenancyStatus(err), err.Error())
return
}
out.Name = name
}
writeJSON(w, http.StatusOK, out)
}
}
func (a *API) tenancyDelete(level store.Scope) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
ctx := r.Context()
_, id, _, err := a.resolveScope(ctx, string(level)+":"+r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
switch level {
case store.ScopeOrg:
err = a.tenancy.DeleteOrg(ctx, id)
case store.ScopeTeam:
err = a.tenancy.DeleteTeam(ctx, id)
default:
err = a.tenancy.DeleteProject(ctx, id)
}
if err != nil {
// Only what is empty goes: the store says what is still in it.
writeError(w, tenancyStatus(err), err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
}
}
// keyState is a key's state at an instant.
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"
}
return "active"
}
func keyView(k store.Key, path, email string, now time.Time) KeyView {
// The store says "Acme / ml / search"; a path given to the API is
// written Acme/ml/search, and is answered the same way.
path = strings.ReplaceAll(path, " / ", "/")
return KeyView{ID: k.ID, Name: k.Name, Prefix: k.Prefix, Project: k.ProjectID, ProjectPath: path,
User: k.UserID, UserEmail: email, Band: k.Band, RoutingProfile: k.RoutingProfile, State: keyState(k, now),
CreatedAt: k.CreatedAt, ExpiresAt: k.ExpiresAt, RevokedAt: k.RevokedAt, LastUsedAt: k.LastUsedAt}
}
func (a *API) keyList(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
project := ""
if p := r.URL.Query().Get("project"); p != "" {
var err error
if _, project, _, err = a.resolveScope(r.Context(), "project:"+p); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
}
rows, err := a.tenancy.AllKeys(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
now := time.Now()
out := []KeyView{}
for _, k := range rows {
if project == "" || k.ProjectID == project {
out = append(out, keyView(k.Key, k.ProjectPath, k.OwnerEmail, now))
}
}
writeJSON(w, http.StatusOK, out)
}
func (a *API) keyGet(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
rows, err := a.tenancy.AllKeys(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, k := range rows {
if k.ID == r.PathValue("id") {
writeJSON(w, http.StatusOK, keyView(k.Key, k.ProjectPath, k.OwnerEmail, time.Now()))
return
}
}
writeError(w, http.StatusNotFound, "no key "+r.PathValue("id"))
}
// findUser reads a person by ID or email.
func (a *API) findUser(ctx context.Context, ref string) (store.User, error) {
ref = strings.TrimSpace(ref)
if u, err := a.tenancy.UserByID(ctx, ref); err == nil {
return u, nil
}
if strings.Contains(ref, "@") {
if u, err := a.tenancy.UserByEmail(ctx, ref); err == nil {
return u, nil
}
}
return store.User{}, fmt.Errorf("%w: no person %q", store.ErrNotFound, ref)
}
func (a *API) keyCreate(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
var in KeyCreateRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
if strings.TrimSpace(in.Name) == "" {
writeError(w, http.StatusBadRequest, "a key needs a name")
return
}
if in.Project == "" && in.User == "" {
writeError(w, http.StatusBadRequest, "a key belongs to a project, a person, or a person in a project: give project, user or both")
return
}
var owner store.User
var err error
if in.User != "" {
if owner, err = a.findUser(ctx, in.User); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
}
project, path := "", ""
if in.Project != "" {
if _, project, path, err = a.resolveScope(ctx, "project:"+in.Project); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
}
var expires time.Duration
if in.ExpiresIn != "" {
if expires, err = time.ParseDuration(in.ExpiresIn); err != nil || expires <= 0 {
writeError(w, http.StatusBadRequest, fmt.Sprintf("expires_in %q is not a positive duration such as 720h", in.ExpiresIn))
return
}
}
secret, k, err := store.NewKey(store.NewID("key-"), strings.TrimSpace(in.Name), project)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
k.CreatedAt = time.Now()
k.Band, k.RoutingProfile = in.Band, in.RoutingProfile
if expires > 0 {
k.ExpiresAt = k.CreatedAt.Add(expires)
}
if owner.ID != "" {
err = a.tenancy.CreateUserKey(ctx, owner.ID, k)
} else {
err = a.tenancy.CreateKey(ctx, k)
}
switch {
case errors.Is(err, store.ErrNotMember):
writeError(w, http.StatusConflict, fmt.Sprintf("%s is not a member of %s; add the membership first", owner.Email, path))
return
case errors.Is(err, store.ErrKeyTTL):
writeError(w, http.StatusBadRequest, err.Error())
return
case err != nil:
writeError(w, http.StatusInternalServerError, err.Error())
return
}
k.UserID = owner.ID
writeJSON(w, http.StatusCreated, KeyCreated{KeyView: keyView(k, path, owner.Email, time.Now()), Secret: secret})
}
func (a *API) keyRevoke(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
id := r.PathValue("id")
if _, err := a.tenancy.KeyByID(r.Context(), id); err != nil {
writeError(w, tenancyStatus(err), err.Error())
return
}
if err := a.tenancy.RevokeKey(r.Context(), id, time.Now()); err != nil {
writeError(w, tenancyStatus(err), err.Error())
return
}
// A key held in a gateway's cache keeps working until auth.cache_ttl.
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
}
func (a *API) userView(ctx context.Context, u store.User) UserView {
role := u.Role
if u.IsAdmin {
role = "admin"
}
v := UserView{ID: u.ID, Email: u.Email, Name: u.Name, State: string(u.State), Role: role,
Memberships: []MembershipView{}, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt}
if ms, err := a.tenancy.MembershipsOf(ctx, u.ID); err == nil {
for _, m := range ms {
v.Memberships = append(v.Memberships, MembershipView{Target: string(m.Scope) + ":" + m.ScopeID,
Path: m.Path, Role: m.Role, At: m.AddedAt, By: m.AddedBy})
}
}
return v
}
func (a *API) userList(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
us, err := a.tenancy.Users(r.Context(), store.UserState(r.URL.Query().Get("state")))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := []UserView{}
for _, u := range us {
out = append(out, a.userView(r.Context(), u))
}
writeJSON(w, http.StatusOK, out)
}
func (a *API) userGet(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
u, err := a.findUser(r.Context(), r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, a.userView(r.Context(), u))
}
func (a *API) membership(add bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.tenancyReady(w) {
return
}
var in MembershipRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
u, err := a.findUser(ctx, r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
scope, id, _, err := a.resolveScope(ctx, in.Target)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if add {
role := in.Role
if role == "" {
role = store.RoleMember
}
if role != store.RoleMember && role != store.RoleManager {
writeError(w, http.StatusBadRequest, fmt.Sprintf("role %q: member or manager", role))
return
}
if err := a.tenancy.AddMembership(ctx, u.ID, scope, id, role, actorOf(r), time.Now()); err != nil {
writeError(w, tenancyStatus(err), err.Error())
return
}
} else {
// The person's keys in projects they no longer reach are
// revoked with it, by the store.
if _, err := a.tenancy.RemoveMembership(ctx, u.ID, scope, id, time.Now()); err != nil {
if errors.Is(err, store.ErrNotFound) {
err = errors.New(u.Email + " is not a member there")
}
writeError(w, http.StatusNotFound, err.Error())
return
}
}
writeJSON(w, http.StatusOK, a.userView(ctx, u))
}
}