What llmgw org, keys and users did against the store on a control plane can now be done remotely and by automation: the tree read, added to, renamed, moved and emptied; keys issued (the key in the answer, once), listed and revoked, with the scopes' lifetime limits; people put in and taken out of the tree, their keys in projects they no longer reach revoked with it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
656 lines
21 KiB
Go
656 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 {
|
|
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))
|
|
}
|
|
}
|