Files
llmgw/terraform-provider-llmgw/internal/fakeapi/tenancy.go
T
Ilker ManapandClaude Opus 5.5 15a209e652 Terraform: organizations, teams, projects, keys and memberships
llmgw_org, llmgw_team and llmgw_project rename and move in place;
llmgw_key holds the key in a sensitive secret and is made again when
revoked or expired outside Terraform; llmgw_membership puts a person in
the tree. Tried with OpenTofu against the fake admin API, drift and
import included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:42:07 +02:00

394 lines
11 KiB
Go

package fakeapi
import (
"cmp"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/terraform-provider-llmgw/internal/client"
)
// node is an organization, team or project; In is its parent's ID.
type node struct{ ID, Name, In string }
// tenancy is the tree, the keys and the people.
type tenancy struct {
levels map[string][]node
keys map[string]client.KeyView
users map[string]*client.UserView
seq int
}
// parent is the level a level's nodes are in, and the scope it is named by.
var parent = map[string]string{client.LevelTeam: client.LevelOrg, client.LevelProject: client.LevelTeam}
var scopeOf = map[string]string{client.LevelOrg: "org", client.LevelTeam: "team", client.LevelProject: "project"}
func (a *API) find(level, id string) (node, int) {
for i, n := range a.levels[level] {
if n.ID == id {
return n, i
}
}
return node{}, -1
}
// rebuild names every part of the tree as the real API does.
func (a *API) rebuild() {
a.scopes = map[string]string{}
for _, o := range a.levels[client.LevelOrg] {
a.scopes["org:"+o.ID] = o.Name
}
for _, t := range a.levels[client.LevelTeam] {
o, _ := a.find(client.LevelOrg, t.In)
a.scopes["team:"+t.ID] = o.Name + " / " + t.Name
}
for _, p := range a.levels[client.LevelProject] {
t, _ := a.find(client.LevelTeam, p.In)
o, _ := a.find(client.LevelOrg, t.In)
a.scopes["project:"+p.ID] = o.Name + "/" + t.Name + "/" + p.Name
}
}
// resolveAt reads a level's node by ID or path.
func (a *API) resolveAt(level, ref string) (string, error) {
key, err := a.resolve(scopeOf[level] + ":" + ref)
if err != nil {
return "", err
}
_, id, _ := strings.Cut(key, ":")
return id, nil
}
// RenameBehindTerraform renames a part of the tree as someone at a terminal
// would.
func (a *API) RenameBehindTerraform(level, id, name string) {
a.mu.Lock()
defer a.mu.Unlock()
if _, i := a.find(level, id); i >= 0 {
a.levels[level][i].Name = name
a.rebuild()
}
}
// RevokeBehindTerraform revokes a key as someone at a terminal would.
func (a *API) RevokeBehindTerraform(id string) {
a.mu.Lock()
defer a.mu.Unlock()
k := a.keys[id]
k.State = "revoked"
a.keys[id] = k
}
// Keys are the keys as the fake holds them.
func (a *API) Keys() map[string]client.KeyView {
a.mu.Lock()
defer a.mu.Unlock()
out := map[string]client.KeyView{}
for id, k := range a.keys {
out[id] = k
}
return out
}
// Memberships are where a person is, as the fake holds it.
func (a *API) Memberships(user string) []client.MembershipView {
a.mu.Lock()
defer a.mu.Unlock()
return slices.Clone(a.users[user].Memberships)
}
// Has says whether a part of the tree is there, by ID.
func (a *API) Has(level, id string) bool {
a.mu.Lock()
defer a.mu.Unlock()
_, i := a.find(level, id)
return i >= 0
}
func (a *API) tenancyRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/orgs", a.tree)
for _, level := range []string{client.LevelOrg, client.LevelTeam, client.LevelProject} {
mux.HandleFunc("POST /api/v1/"+level, a.create(level))
mux.HandleFunc("PATCH /api/v1/"+level+"/{id}", a.changeNode(level))
mux.HandleFunc("DELETE /api/v1/"+level+"/{id}", a.removeNode(level))
}
mux.HandleFunc("POST /api/v1/keys", a.createKey)
mux.HandleFunc("GET /api/v1/keys/{id}", a.getKey)
mux.HandleFunc("DELETE /api/v1/keys/{id}", a.revokeKey)
mux.HandleFunc("GET /api/v1/users/{id}", a.getUser)
mux.HandleFunc("POST /api/v1/users/{id}/memberships", a.membership(true))
mux.HandleFunc("DELETE /api/v1/users/{id}/memberships", a.membership(false))
}
func (a *API) tree(w http.ResponseWriter, _ *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
out := []client.OrgView{}
for _, o := range a.levels[client.LevelOrg] {
ov := client.OrgView{ID: o.ID, Name: o.Name, Teams: []client.TeamView{}}
for _, t := range a.levels[client.LevelTeam] {
if t.In != o.ID {
continue
}
tv := client.TeamView{ID: t.ID, Name: t.Name, Org: o.ID, Projects: []client.ProjectView{}}
for _, p := range a.levels[client.LevelProject] {
if p.In == t.ID {
tv.Projects = append(tv.Projects, client.ProjectView{ID: p.ID, Name: p.Name, Team: t.ID,
Path: a.scopes["project:"+p.ID]})
}
}
ov.Teams = append(ov.Teams, tv)
}
out = append(out, ov)
}
writeJSON(w, http.StatusOK, out)
}
func (a *API) taken(level, in, name, except string) bool {
for _, n := range a.levels[level] {
if n.In == in && n.Name == name && n.ID != except {
return true
}
}
return false
}
func (a *API) create(level string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req client.TenancyCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeError(w, http.StatusBadRequest, "a name is needed")
return
}
a.mu.Lock()
defer a.mu.Unlock()
n := node{Name: req.Name}
if p, ok := parent[level]; ok {
id, err := a.resolveAt(p, req.In)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
n.In = id
}
if a.taken(level, n.In, n.Name, "") {
writeError(w, http.StatusConflict, "there is already one named "+n.Name+" there")
return
}
a.seq++
n.ID = fmt.Sprintf("%s-%d", scopeOf[level], a.seq)
a.levels[level] = append(a.levels[level], n)
a.rebuild()
writeJSON(w, http.StatusCreated, client.TenancyItem{ID: n.ID, Name: n.Name, In: n.In})
}
}
func (a *API) changeNode(level string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req client.TenancyChangeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a.mu.Lock()
defer a.mu.Unlock()
id, err := a.resolveAt(level, r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
n, i := a.find(level, id)
out := client.TenancyItem{ID: id}
if req.In != "" {
p, ok := parent[level]
if !ok {
writeError(w, http.StatusBadRequest, "an organization is in nothing")
return
}
if n.In, err = a.resolveAt(p, req.In); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
out.In = n.In
}
if req.Name != "" {
n.Name, out.Name = req.Name, req.Name
}
if a.taken(level, n.In, n.Name, id) {
writeError(w, http.StatusConflict, "there is already one named "+n.Name+" there")
return
}
a.levels[level][i] = n
a.rebuild()
writeJSON(w, http.StatusOK, out)
}
}
func (a *API) removeNode(level string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
id, err := a.resolveAt(level, r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
for child, p := range parent {
if p != level {
continue
}
for _, c := range a.levels[child] {
if c.In == id {
writeError(w, http.StatusConflict, "not empty")
return
}
}
}
for _, k := range a.keys {
if k.Project == id {
writeError(w, http.StatusConflict, "not empty: it still has a key")
return
}
}
_, i := a.find(level, id)
a.levels[level] = slices.Delete(a.levels[level], i, i+1)
a.rebuild()
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
}
}
func (a *API) createKey(w http.ResponseWriter, r *http.Request) {
var req client.KeyCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeError(w, http.StatusBadRequest, "a key needs a name")
return
}
a.mu.Lock()
defer a.mu.Unlock()
k := client.KeyView{Name: req.Name, Band: req.Band, RoutingProfile: req.RoutingProfile, State: "active"}
if req.Project != "" {
id, err := a.resolveAt(client.LevelProject, req.Project)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
k.Project = id
}
if req.User != "" {
u, ok := a.user(req.User)
if !ok {
writeError(w, http.StatusNotFound, "no person "+req.User)
return
}
if k.Project != "" && !slices.ContainsFunc(u.Memberships, func(m client.MembershipView) bool {
return m.Target == "project:"+k.Project
}) {
writeError(w, http.StatusConflict, u.Email+" is not a member there")
return
}
k.User = u.ID
}
if req.ExpiresIn != "" {
d, err := time.ParseDuration(req.ExpiresIn)
if err != nil || d <= 0 {
writeError(w, http.StatusBadRequest, "expires_in is not a duration")
return
}
k.ExpiresAt = time.Now().Add(d).UTC().Truncate(time.Second)
}
a.seq++
k.ID = fmt.Sprintf("key-%d", a.seq)
secret := fmt.Sprintf("sk-llmgw-fake%08d", a.seq)
k.Prefix = secret[:12]
a.keys[k.ID] = k
writeJSON(w, http.StatusCreated, client.KeyCreated{ID: k.ID, Prefix: k.Prefix, Project: k.Project, User: k.User,
State: k.State, ExpiresAt: k.ExpiresAt, Secret: secret})
}
func (a *API) getKey(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
k, ok := a.keys[r.PathValue("id")]
if !ok {
writeError(w, http.StatusNotFound, "no key "+r.PathValue("id"))
return
}
writeJSON(w, http.StatusOK, k)
}
func (a *API) revokeKey(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
k, ok := a.keys[r.PathValue("id")]
if !ok {
writeError(w, http.StatusNotFound, "no key "+r.PathValue("id"))
return
}
k.State = "revoked"
a.keys[k.ID] = k
writeJSON(w, http.StatusOK, map[string]any{"id": k.ID, "revoked": true})
}
func (a *API) user(ref string) (*client.UserView, bool) {
if u, ok := a.users[ref]; ok {
return u, true
}
for _, u := range a.users {
if strings.EqualFold(u.Email, ref) {
return u, true
}
}
return nil, false
}
func (a *API) getUser(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
u, ok := a.user(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "no person "+r.PathValue("id"))
return
}
writeJSON(w, http.StatusOK, u)
}
func (a *API) membership(join bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req client.MembershipRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a.mu.Lock()
defer a.mu.Unlock()
u, ok := a.user(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "no person "+r.PathValue("id"))
return
}
key, err := a.resolve(req.Target)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
i := slices.IndexFunc(u.Memberships, func(m client.MembershipView) bool { return m.Target == key })
switch {
case join && i >= 0:
u.Memberships[i].Role = cmp.Or(req.Role, "member")
case join:
u.Memberships = append(u.Memberships, client.MembershipView{Target: key, Role: cmp.Or(req.Role, "member")})
case i < 0:
writeError(w, http.StatusNotFound, u.Email+" is not a member there")
return
default:
u.Memberships = slices.Delete(u.Memberships, i, i+1)
}
writeJSON(w, http.StatusOK, u)
}
}