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")
|
||||
|
||||
Reference in New Issue
Block a user