Files
llmgw/internal/control/adminapi/guard.go
T
Ilker ManapandClaude Opus 5.5 a95c519bc5 The admin API described in OpenAPI 3.1: generated from a route table by docgen, served at /api/v1/openapi.json
adminapi.Routes lists every endpoint with its query parameters and Go values
of the bodies it reads and answers with; internal/docgen/openapi turns that
into docs/admin-api.openapi.json by reflection, the way encoding/json encodes
the types, with the fields' comments as descriptions. A test fails when an
endpoint is registered and not in the table, another when the committed
document is stale. The binary embeds it and serves it with the token.

Handlers that wrote map literals and decoded anonymous structs now use named
types, so the document can see them. GET /api/v1/models/{name} answers with a
model's whole definition as it is in force, which a tool managing it needs.
docs/admin-api.md: reaching the API, the token, what a request must carry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:47:59 +02:00

148 lines
4.7 KiB
Go

package adminapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
// GuardStore is where policy attachments are kept.
type GuardStore interface {
GuardAttachments(ctx context.Context) ([]guard.Attachment, error)
AttachGuard(ctx context.Context, a guard.Attachment, by string) error
DetachGuard(ctx context.Context, a guard.Attachment) (bool, error)
}
// GuardAdmin is what the guard endpoints need: the library from server.yaml,
// the store, and a way to have the gate read attachments again at once.
type GuardAdmin struct {
Library guard.Config
Store GuardStore
Invalidate func()
}
// SetGuard enables the guard endpoints.
func (a *API) SetGuard(g *GuardAdmin) { a.guard = g }
// GuardView is the library and where its policies are attached.
type GuardView struct {
Policies []string `json:"policies"`
Default []string `json:"default"`
Attachments []GuardAttachmentView `json:"attachments"`
}
// GuardAttachmentView is one attachment, with the name people know its
// scope by.
type GuardAttachmentView struct {
guard.Attachment
Name string `json:"name,omitempty"`
}
// guardTarget reads where a policy is to be attached.
func (a *API) guardTarget(ctx context.Context, target string) (guard.Attachment, error) {
kind, what, ok := strings.Cut(strings.TrimSpace(target), ":")
what = strings.TrimSpace(what)
if !ok || what == "" {
return guard.Attachment{}, fmt.Errorf("%q is not org:, team:, project:, key: or user: and what", target)
}
switch kind {
case "key", "user":
return guard.Attachment{Scope: kind, ScopeID: what}, nil
case "org", "team", "project":
if a.quotas == nil {
return guard.Attachment{Scope: kind, ScopeID: what}, nil
}
_, id, _, err := a.resolveScope(ctx, target)
if err != nil {
return guard.Attachment{}, err
}
return guard.Attachment{Scope: kind, ScopeID: id}, nil
}
return guard.Attachment{}, fmt.Errorf("%q: attach to org, team, project, key or user", kind)
}
// GuardAttachmentRequest attaches a policy to a part of the tenancy tree, a
// key or a person, or with DELETE takes it off again.
type GuardAttachmentRequest struct {
// Target is org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT (or the ID
// after the colon), key:KEY-ID or user:USER-ID.
Target string `json:"target"`
// Policy is a policy's name in server.yaml's guard.policies.
Policy string `json:"policy"`
}
func (a *API) registerGuard(mux Mux) {
mux.HandleFunc("GET /api/v1/guard", func(w http.ResponseWriter, r *http.Request) {
if a.guard == nil {
writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard: in server.yaml")
return
}
att, err := a.guard.Store.GuardAttachments(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
v := GuardView{Default: a.guard.Library.Default, Attachments: []GuardAttachmentView{}}
var names map[string]string
if a.quotas != nil {
names, _ = a.scopeNames(r.Context())
}
for _, at := range att {
v.Attachments = append(v.Attachments, GuardAttachmentView{Attachment: at, Name: names[at.Scope+":"+at.ScopeID]})
}
for name := range a.guard.Library.Policies {
v.Policies = append(v.Policies, name)
}
slices.Sort(v.Policies)
writeJSON(w, http.StatusOK, v)
})
change := func(attach bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if a.guard == nil {
writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard: in server.yaml")
return
}
var at GuardAttachmentRequest
if err := json.NewDecoder(r.Body).Decode(&at); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
att, err := a.guardTarget(r.Context(), at.Target)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
att.Policy = at.Policy
if _, ok := a.guard.Library.Policies[att.Policy]; !ok && attach {
writeError(w, http.StatusBadRequest, fmt.Sprintf("no policy %q in server.yaml's guard.policies", att.Policy))
return
}
changed := true
if attach {
err = a.guard.Store.AttachGuard(r.Context(), att, actorOf(r))
} else {
changed, err = a.guard.Store.DetachGuard(r.Context(), att)
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !changed {
writeError(w, http.StatusNotFound, "no such attachment")
return
}
if a.guard.Invalidate != nil {
a.guard.Invalidate()
}
writeJSON(w, http.StatusOK, att)
}
}
mux.HandleFunc("POST /api/v1/guard/attachments", change(true))
mux.HandleFunc("DELETE /api/v1/guard/attachments", change(false))
}