Implemented admin pane + user permission

This commit is contained in:
Martino Ferrari
2026-06-22 17:49:14 +02:00
parent 73fcbe7b28
commit ac24011487
67 changed files with 5925 additions and 411 deletions
+257 -6
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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})
}