Implemented admin pane + user permission
This commit is contained in:
+257
-6
@@ -2,6 +2,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,6 +26,7 @@ import (
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
@@ -97,6 +101,14 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder)
|
||||
// User groups defined in config (read-only; for the sharing UI)
|
||||
mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups)
|
||||
// Admin pane: runtime access management + server statistics. Every route is
|
||||
// gated by CanAdmin (the admins allowlist).
|
||||
mux.HandleFunc("GET "+prefix+"/admin/access", h.getAdminAccess)
|
||||
mux.HandleFunc("PUT "+prefix+"/admin/users/{user}", h.putAdminUser)
|
||||
mux.HandleFunc("POST "+prefix+"/admin/groups", h.createAdminGroup)
|
||||
mux.HandleFunc("PUT "+prefix+"/admin/groups/{name}", h.updateAdminGroup)
|
||||
mux.HandleFunc("DELETE "+prefix+"/admin/groups/{name}", h.deleteAdminGroup)
|
||||
mux.HandleFunc("GET "+prefix+"/admin/stats", h.getAdminStats)
|
||||
// Signal group tree
|
||||
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
|
||||
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
|
||||
@@ -106,6 +118,8 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
|
||||
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
|
||||
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
|
||||
// Single-shot debug trace of an unsaved synthetic graph (live editor overlay).
|
||||
mux.HandleFunc("POST "+prefix+"/synthetic/trace", h.traceSynthetic)
|
||||
// Synthetic signal git-style versioning (list / view / promote / fork).
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions", h.listSyntheticVersions)
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion)
|
||||
@@ -156,6 +170,7 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules/preview", h.previewConfigRule)
|
||||
mux.HandleFunc("GET "+prefix+"/config/rules/{id}", h.getConfigRule)
|
||||
mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule)
|
||||
mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule)
|
||||
@@ -183,6 +198,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
||||
"groups": groups,
|
||||
"canEditLogic": h.policy.CanEditLogic(user),
|
||||
"canViewAudit": h.policy.CanViewAudit(user),
|
||||
"canAdmin": h.policy.CanAdmin(user),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,23 +300,25 @@ func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm {
|
||||
return p
|
||||
}
|
||||
|
||||
// panelPerm returns the caller's effective permission on a panel. A request
|
||||
// with no resolved identity (no proxy header and no default_user) is a trusted
|
||||
// LAN deployment with no per-user enforcement, so it gets full write.
|
||||
// panelPerm returns the caller's effective permission on a panel. A request with
|
||||
// no resolved identity (no proxy header and no default_user) has no per-user ACL,
|
||||
// so it is governed purely by the global level: full write on an unconfigured
|
||||
// (open) policy, read-only once roles are configured.
|
||||
func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
|
||||
user := caller(r)
|
||||
if user == "" {
|
||||
return panelacl.PermWrite
|
||||
return h.capByGlobal("", panelacl.PermWrite)
|
||||
}
|
||||
return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user)))
|
||||
}
|
||||
|
||||
// folderPerm returns the caller's effective permission on a folder. As with
|
||||
// panelPerm, an unidentified caller is trusted with full write.
|
||||
// panelPerm, an unidentified caller is governed by the global level (full write
|
||||
// on an unconfigured/open policy, read-only once roles are configured).
|
||||
func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
|
||||
user := caller(r)
|
||||
if user == "" {
|
||||
return panelacl.PermWrite
|
||||
return h.capByGlobal("", panelacl.PermWrite)
|
||||
}
|
||||
return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
|
||||
}
|
||||
@@ -1114,6 +1132,209 @@ func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonOK(w, names)
|
||||
}
|
||||
|
||||
// ── /admin ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// requireAdmin writes a 403 and returns false unless the caller may use the
|
||||
// admin pane (CanAdmin). Anonymous/trusted-LAN callers and, when no admins
|
||||
// allowlist is configured, everyone, are permitted.
|
||||
func (h *Handler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if h.policy.CanAdmin(caller(r)) {
|
||||
return true
|
||||
}
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to administer this server")
|
||||
return false
|
||||
}
|
||||
|
||||
// getAdminAccess returns the full mutable access configuration (users, groups,
|
||||
// allowlists) for the admin pane.
|
||||
func (h *Handler) getAdminAccess(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
type adminUserReq struct {
|
||||
// Roles maps each group the user should belong to → their role token. The
|
||||
// user is removed from any group not listed. Unknown groups are created.
|
||||
Roles map[string]string `json:"roles"`
|
||||
}
|
||||
|
||||
// putAdminUser replaces a user's full set of (group → role) memberships.
|
||||
func (h *Handler) putAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
user := strings.TrimSpace(r.PathValue("user"))
|
||||
if user == "" {
|
||||
jsonError(w, http.StatusBadRequest, "empty user")
|
||||
return
|
||||
}
|
||||
var req adminUserReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
roles := make(map[string]access.Role, len(req.Roles))
|
||||
for g, token := range req.Roles {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
roles[g] = access.ParseRole(token)
|
||||
}
|
||||
}
|
||||
if err := h.policy.SetUserRoles(user, roles); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.user", user)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
type adminGroupReq struct {
|
||||
Name string `json:"name"` // for update: the (possibly new) group name
|
||||
Parent string `json:"parent"` // parent group for nesting ("" = root)
|
||||
Members map[string]string `json:"members"`
|
||||
}
|
||||
|
||||
// createAdminGroup creates an empty group with an optional parent.
|
||||
func (h *Handler) createAdminGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var req adminGroupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "empty group name")
|
||||
return
|
||||
}
|
||||
if err := h.policy.CreateGroup(req.Name, req.Parent); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.group.create", req.Name)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
// updateAdminGroup renames a group (when the body name differs from the path)
|
||||
// and replaces its parent and member roles.
|
||||
func (h *Handler) updateAdminGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
old := strings.TrimSpace(r.PathValue("name"))
|
||||
var req adminGroupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = old
|
||||
}
|
||||
if name != old {
|
||||
if err := h.policy.RenameGroup(old, name); err != nil {
|
||||
if errors.Is(err, access.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "group not found: "+old)
|
||||
return
|
||||
}
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
members := make(map[string]access.Role, len(req.Members))
|
||||
for u, token := range req.Members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
members[u] = access.ParseRole(token)
|
||||
}
|
||||
}
|
||||
if err := h.policy.SetGroup(name, req.Parent, members); err != nil {
|
||||
if errors.Is(err, access.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "group not found: "+name)
|
||||
return
|
||||
}
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.group.update", name)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
// deleteAdminGroup removes a group (members keep their other groups; children
|
||||
// are reparented). The built-in public group cannot be deleted.
|
||||
func (h *Handler) deleteAdminGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.PathValue("name"))
|
||||
if err := h.policy.DeleteGroup(name); err != nil {
|
||||
if errors.Is(err, access.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "group not found: "+name)
|
||||
return
|
||||
}
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.group.delete", name)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
// getAdminStats reports live server statistics: the in-process metrics counters,
|
||||
// observed signals and data sources from the broker, Go runtime stats, and the
|
||||
// system load average on Linux.
|
||||
func (h *Handler) getAdminStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
m := metrics.Snapshot()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
sources := h.broker.DataSources()
|
||||
dsNames := make([]string, len(sources))
|
||||
for i, ds := range sources {
|
||||
dsNames[i] = ds.Name()
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"uptimeSeconds": m.UptimeSeconds,
|
||||
"wsConnections": m.WsConnections,
|
||||
"observedSignals": h.broker.ActiveSubscriptions(),
|
||||
"msgIn": m.MsgIn,
|
||||
"msgOut": m.MsgOut,
|
||||
"writeOps": m.WriteOps,
|
||||
"historyReqs": m.HistoryReqs,
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"memAllocBytes": ms.Alloc,
|
||||
"memSysBytes": ms.Sys,
|
||||
"heapInuseBytes": ms.HeapInuse,
|
||||
"loadAvg": readLoadAvg(),
|
||||
"dataSources": dsNames,
|
||||
})
|
||||
}
|
||||
|
||||
// readLoadAvg returns the 1/5/15-minute system load averages from
|
||||
// /proc/loadavg, or nil on non-Linux systems or any read/parse error.
|
||||
func readLoadAvg() []float64 {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return nil
|
||||
}
|
||||
out := make([]float64, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
v, err := strconv.ParseFloat(fields[i], 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d").
|
||||
func genID(prefix string) string {
|
||||
var b [8]byte
|
||||
@@ -1221,6 +1442,36 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// traceSynthetic evaluates an unsaved synthetic graph once against the current
|
||||
// live value of each source signal and returns every node's computed value. It
|
||||
// persists nothing; it powers the editor's live/debug overlay.
|
||||
func (h *Handler) traceSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.synthetic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||
return
|
||||
}
|
||||
var def synthetic.SignalDef
|
||||
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
read := func(ds, name string) (any, error) {
|
||||
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.Data, nil
|
||||
}
|
||||
res, err := h.synthetic.Trace(def, read)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, res)
|
||||
}
|
||||
|
||||
func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) {
|
||||
if h.synthetic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||
|
||||
+147
-1
@@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api.New(brk, nil, store, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
|
||||
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() {
|
||||
@@ -452,6 +452,152 @@ func TestStorageValidateID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── /api/v1/admin ─────────────────────────────────────────────────────────────
|
||||
|
||||
// adminSetup builds a server whose mux injects the user named by the
|
||||
// "X-Test-User" header into the request context (mirroring the real access
|
||||
// middleware), so admin-gating can be exercised. The policy restricts admin to
|
||||
// the "ops" group, of which "alice" is a member.
|
||||
func adminSetup(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
brk := broker.New(ctx, log)
|
||||
ds := stub.New()
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatal("stub connect:", err)
|
||||
}
|
||||
brk.Register(ds)
|
||||
|
||||
dir := t.TempDir()
|
||||
store, _ := storage.New(dir)
|
||||
acl, _ := panelacl.New(dir)
|
||||
clStore, _ := controllogic.NewStore(dir)
|
||||
cfgStore, _ := confmgr.New(dir)
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
// "alice" is an admin (via the ops group); everyone else is a viewer.
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleAdmin}},
|
||||
})
|
||||
if err := policy.EnablePersistence(dir); err != nil {
|
||||
t.Fatal("EnablePersistence:", err)
|
||||
}
|
||||
|
||||
inner := http.NewServeMux()
|
||||
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", log).Register(inner, "/api/v1")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if u := r.Header.Get("X-Test-User"); u != "" {
|
||||
r = r.WithContext(access.WithUser(r.Context(), u))
|
||||
}
|
||||
inner.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() { srv.Close(); cancel() }
|
||||
}
|
||||
|
||||
func reqAs(t *testing.T, srv *httptest.Server, method, path, user string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, srv.URL+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatal("NewRequest:", err)
|
||||
}
|
||||
if user != "" {
|
||||
req.Header.Set("X-Test-User", user)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestAdminForbiddenForNonAdmin(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
// "bob" is only a viewer → 403 on every admin route.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/access", "bob", nil), http.StatusForbidden)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "bob", nil), http.StatusForbidden)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "bob",
|
||||
[]byte(`{"roles":{"public":"operator"}}`)), http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestAdminUserAndGroupMutations(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
// alice (admin) assigns carol an auditor role in a new "team" group.
|
||||
resp := reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "alice",
|
||||
[]byte(`{"roles":{"team":"auditor"}}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var snap access.AccessSnapshot
|
||||
readJSON(t, resp, &snap)
|
||||
var carol *access.UserInfo
|
||||
for i := range snap.Users {
|
||||
if snap.Users[i].Name == "carol" {
|
||||
carol = &snap.Users[i]
|
||||
}
|
||||
}
|
||||
if carol == nil {
|
||||
t.Fatal("carol missing from snapshot")
|
||||
}
|
||||
if carol.EffectiveRole != "auditor" || carol.Roles["team"] != "auditor" {
|
||||
t.Errorf("carol = %+v, want auditor in team", carol)
|
||||
}
|
||||
|
||||
// Create (with parent), rename, and delete a group.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodPost, "/api/v1/admin/groups", "alice",
|
||||
[]byte(`{"name":"eng","parent":"public"}`)), http.StatusCreated)
|
||||
resp = reqAs(t, srv, http.MethodPut, "/api/v1/admin/groups/eng", "alice",
|
||||
[]byte(`{"name":"engineering","members":{"carol":"admin"}}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
readJSON(t, resp, &snap)
|
||||
if !hasGroup(snap, "engineering") || hasGroup(snap, "eng") {
|
||||
t.Errorf("groups after rename = %+v", snap.Groups)
|
||||
}
|
||||
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/engineering", "alice", nil), http.StatusOK)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/nope", "alice", nil), http.StatusNotFound)
|
||||
// The built-in public group cannot be deleted.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/public", "alice", nil), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func hasGroup(s access.AccessSnapshot, name string) bool {
|
||||
for _, g := range s.Groups {
|
||||
if g.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAdminStats(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "alice", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var stats map[string]any
|
||||
readJSON(t, resp, &stats)
|
||||
for _, k := range []string{"uptimeSeconds", "wsConnections", "observedSignals", "goroutines", "dataSources"} {
|
||||
if _, ok := stats[k]; !ok {
|
||||
t.Errorf("stats missing key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure os is used (blank import guard) ─────────────────────────────────────
|
||||
|
||||
var _ = os.DevNull
|
||||
|
||||
@@ -765,3 +765,37 @@ func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
jsonOK(w, confmgr.EvaluateRule(body.Source, body.Values))
|
||||
}
|
||||
|
||||
// previewConfigRule evaluates a (possibly unsaved) CUE source against a live
|
||||
// snapshot of the bound set's target signals, without storing anything. Unlike
|
||||
// check (which uses sample/default values), preview reads the current hardware
|
||||
// values so the operator sees exactly what the rule would derive from the real
|
||||
// configuration. Body: {setId, source}. Returns the captured snapshot plus the
|
||||
// rule result (violations + transformed values).
|
||||
func (h *Handler) previewConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
SetID string `json:"setId"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
set, err := h.cfg.GetSet(body.SetID)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
results := h.readSetSignals(ctx, set)
|
||||
snap := confmgr.Snapshot(set, snapshotReader(results))
|
||||
res := confmgr.EvaluateRule(body.Source, snap.Values)
|
||||
jsonOK(w, struct {
|
||||
Snapshot confmgr.SnapshotResult `json:"snapshot"`
|
||||
Result confmgr.RuleResult `json:"result"`
|
||||
}{snap, res})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user