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 && role != store.RoleOrgAdmin { writeError(w, http.StatusBadRequest, fmt.Sprintf("role %q: member, manager or admin", role)) return } if role != store.RoleMember && scope != store.ScopeOrg { writeError(w, http.StatusBadRequest, fmt.Sprintf("%s is a role on an organization", 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)) } }