Everything in server.yaml that is not one machine's own is now a versioned document in the store (cluster_settings), the same on every control plane. The first start against a store imports the file's cluster keys, with the baseline guard policy when there is none; after that those keys in a file are ignored, with a warning. Each control plane polls the document's version and its own server.yaml's hash. A change is validated and applied: guard live (Gate.Replace), anything else by a graceful restart that systemd brings back, standbys first and the leader 30s later. A version that does not load on a machine is not applied; at start the newest one that loads is used, so a bad edit cannot keep every control plane down. llmgw ctl config show/get/set/unset/edit/history/rollback, the admin API under /api/v1/settings, and the console's Settings screen, which layers the store's document over the machine's file, write it. Commands beside the control plane that read cluster keys (init, node add, server add, backup, upgrade) read the layered configuration; init --bundle writes offline to the store once it holds the settings. A standby that becomes the leader re-reads models, engine choices and facility holds from the store. Retires appendGuardIfMissing: the import does it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
320 lines
10 KiB
Go
320 lines
10 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// 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 in force (the
|
|
// cluster's settings), the store, and a way to have the gate read
|
|
// attachments again at once.
|
|
type GuardAdmin struct {
|
|
Library func() 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"`
|
|
// Details says what each policy does, in the order of Policies.
|
|
Details []GuardPolicyView `json:"details"`
|
|
}
|
|
|
|
// GuardPolicyView is what one policy does, in brief.
|
|
type GuardPolicyView struct {
|
|
Name string `json:"name"`
|
|
// Mode is enforce, shadow or off; Fail is open or closed.
|
|
Mode string `json:"mode"`
|
|
Fail string `json:"fail"`
|
|
NoStore bool `json:"no_store,omitempty"`
|
|
// Judge is the judge model, when one reads the text.
|
|
Judge string `json:"judge,omitempty"`
|
|
// Checks are what it looks for, one line each: "input: pii email → redact".
|
|
Checks []string `json:"checks"`
|
|
}
|
|
|
|
// GuardHistory is what the guardrails did, from the usage record; the store
|
|
// has it, and a store that does not leaves the events endpoint unanswered.
|
|
type GuardHistory interface {
|
|
GuardEvents(ctx context.Context, since time.Time, limit int) ([]store.GuardEvent, error)
|
|
GuardCounts(ctx context.Context, since time.Time) ([]store.GuardCount, error)
|
|
}
|
|
|
|
// GuardEventsView is what the guardrails did since a moment: totals, and the
|
|
// newest requests.
|
|
type GuardEventsView struct {
|
|
Since time.Time `json:"since"`
|
|
Counts []GuardCountView `json:"counts"`
|
|
Events []GuardEventView `json:"events"`
|
|
}
|
|
|
|
// GuardCountView is how many requests one policy did one thing to.
|
|
type GuardCountView struct {
|
|
Project string `json:"project,omitempty"`
|
|
Action string `json:"action"`
|
|
Policy string `json:"policy"`
|
|
Category string `json:"category,omitempty"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
// GuardEventView is one request the guardrails did something to.
|
|
type GuardEventView struct {
|
|
At time.Time `json:"at"`
|
|
RequestID string `json:"request_id"`
|
|
Project string `json:"project,omitempty"`
|
|
Model string `json:"model"`
|
|
Status int `json:"status"`
|
|
Action string `json:"action"`
|
|
Policy string `json:"policy"`
|
|
Category string `json:"category,omitempty"`
|
|
GuardMs int64 `json:"guard_ms"`
|
|
}
|
|
|
|
// policyView sums one policy up.
|
|
func policyView(name string, p guard.Policy) GuardPolicyView {
|
|
v := GuardPolicyView{Name: name, Mode: or(p.Mode, "enforce"), Fail: or(p.Fail, "open"), NoStore: p.NoStore, Checks: []string{}}
|
|
rules := func(dir string, r guard.Rules) {
|
|
for _, kind := range slices.Sorted(maps.Keys(r.PII)) {
|
|
v.Checks = append(v.Checks, fmt.Sprintf("%s: pii %s → %s", dir, kind, r.PII[kind]))
|
|
}
|
|
if r.Injection != "" {
|
|
v.Checks = append(v.Checks, fmt.Sprintf("%s: prompt injection → %s", dir, r.Injection))
|
|
}
|
|
for _, d := range r.Deny {
|
|
v.Checks = append(v.Checks, fmt.Sprintf("%s: pattern %s → %s", dir, d.Name, or(d.Action, "block")))
|
|
}
|
|
if r.MaxChars > 0 {
|
|
v.Checks = append(v.Checks, fmt.Sprintf("%s: at most %d characters", dir, r.MaxChars))
|
|
}
|
|
}
|
|
rules("input", p.Input)
|
|
rules("output", p.Output)
|
|
if j := p.Judge; j != nil {
|
|
v.Judge = j.Model
|
|
var dirs []string
|
|
if j.Input != "" {
|
|
dirs = append(dirs, "input → "+j.Input)
|
|
}
|
|
if j.Output != "" {
|
|
dirs = append(dirs, "output → "+j.Output)
|
|
}
|
|
line := "judge " + j.Model + ": " + strings.Join(dirs, ", ")
|
|
if len(j.Categories) > 0 {
|
|
line += " (" + strings.Join(j.Categories, " ") + ")"
|
|
}
|
|
v.Checks = append(v.Checks, line)
|
|
}
|
|
if len(p.Tools.Allow) > 0 {
|
|
v.Checks = append(v.Checks, "tools: only "+strings.Join(p.Tools.Allow, ", "))
|
|
}
|
|
if len(p.Tools.Deny) > 0 {
|
|
v.Checks = append(v.Checks, "tools: never "+strings.Join(p.Tools.Deny, ", "))
|
|
}
|
|
if p.MaxOutputTokens > 0 {
|
|
v.Checks = append(v.Checks, fmt.Sprintf("answers of at most %d tokens", p.MaxOutputTokens))
|
|
}
|
|
if p.SystemPrompt != "" {
|
|
v.Checks = append(v.Checks, "a system prompt of its own")
|
|
}
|
|
return v
|
|
}
|
|
|
|
func or(s, dflt string) string {
|
|
if s == "" {
|
|
return dflt
|
|
}
|
|
return s
|
|
}
|
|
|
|
// GuardAttachmentView is one attachment, with the name people know its
|
|
// scope by.
|
|
type GuardAttachmentView struct {
|
|
guard.Attachment
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// guardHistory is where what the guardrails did is read, when there is one.
|
|
func (a *API) guardHistory() (GuardHistory, bool) {
|
|
if a.guard == nil {
|
|
return nil, false
|
|
}
|
|
h, ok := a.guard.Store.(GuardHistory)
|
|
return h, ok
|
|
}
|
|
|
|
// 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 the cluster's settings (llmgw ctl config edit)")
|
|
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)
|
|
for _, name := range v.Policies {
|
|
v.Details = append(v.Details, policyView(name, a.guard.Library().Policies[name]))
|
|
}
|
|
writeJSON(w, http.StatusOK, v)
|
|
})
|
|
mux.HandleFunc("GET /api/v1/guard/events", func(w http.ResponseWriter, r *http.Request) {
|
|
h, ok := a.guardHistory()
|
|
if !ok {
|
|
writeError(w, http.StatusNotImplemented, "no guard policies, or no store to record what they did")
|
|
return
|
|
}
|
|
since := 24 * time.Hour
|
|
if q := r.URL.Query().Get("since"); q != "" {
|
|
d, err := parseSince(q)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
since = d
|
|
}
|
|
from := time.Now().Add(-since)
|
|
counts, err := h.GuardCounts(r.Context(), from)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
events, err := h.GuardEvents(r.Context(), from, 100)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
var names map[string]string
|
|
if a.quotas != nil {
|
|
names, _ = a.scopeNames(r.Context())
|
|
}
|
|
project := func(id string) string {
|
|
if n := names["project:"+id]; n != "" {
|
|
return n
|
|
}
|
|
return id
|
|
}
|
|
v := GuardEventsView{Since: from, Counts: []GuardCountView{}, Events: []GuardEventView{}}
|
|
for _, c := range counts {
|
|
v.Counts = append(v.Counts, GuardCountView{Project: project(c.ProjectID), Action: c.Action,
|
|
Policy: c.Policy, Category: c.Category, Count: c.Count})
|
|
}
|
|
slices.SortStableFunc(v.Counts, func(a, b GuardCountView) int { return int(b.Count - a.Count) })
|
|
for _, e := range events {
|
|
v.Events = append(v.Events, GuardEventView{At: e.At, RequestID: e.RequestID, Project: project(e.ProjectID),
|
|
Model: e.Model, Status: e.Status, Action: e.Action, Policy: e.Policy, Category: e.Category, GuardMs: e.GuardMs})
|
|
}
|
|
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 the cluster's settings (llmgw ctl config edit)")
|
|
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 the cluster'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))
|
|
}
|