Major changes: logic add to panel, local variables, panel histor, users management...

This commit is contained in:
Martino Ferrari
2026-06-18 17:37:04 +02:00
parent 71430bc3b0
commit aba394b84d
54 changed files with 6104 additions and 1166 deletions
+150
View File
@@ -0,0 +1,150 @@
// Package access implements uopi's global user-access policy: every user is
// trusted (full write) by default, while a configured blacklist can downgrade
// specific users to read-only or no access. It also resolves the per-request
// user identity and the user→group memberships defined in config.
//
// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
// layered on top of this in a later phase.
package access
import (
"context"
"sort"
"strings"
)
// Level is a global access level. Higher levels include lower ones.
type Level int
const (
// LevelNone denies all access.
LevelNone Level = iota
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
LevelRead
// LevelWrite permits full access. This is the default for any user not blacklisted.
LevelWrite
)
// String renders the level using the same tokens accepted by ParseLevel and
// surfaced to the frontend via /api/v1/me.
func (l Level) String() string {
switch l {
case LevelNone:
return "none"
case LevelRead:
return "readonly"
default:
return "write"
}
}
// ParseLevel maps a config string to a Level. Unknown values restrict to
// read-only, since the only reason to list a user is to limit them.
func ParseLevel(s string) Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "noaccess", "none", "no":
return LevelNone
case "readonly", "read", "ro":
return LevelRead
case "write", "readwrite", "rw", "full":
return LevelWrite
default:
return LevelRead
}
}
// Policy holds the resolved global access configuration. It is immutable after
// construction and safe for concurrent use.
type Policy struct {
defaultUser string
blacklist map[string]Level // user → downgraded level
userGroups map[string][]string // user → groups they belong to
groupNames []string // all configured group names (sorted)
}
// New builds a Policy. blacklist maps a username to a config level string;
// groups maps a group name to its member usernames.
func New(defaultUser string, blacklist map[string]string, groups map[string][]string) *Policy {
p := &Policy{
defaultUser: strings.TrimSpace(defaultUser),
blacklist: make(map[string]Level),
userGroups: make(map[string][]string),
}
for user, lvl := range blacklist {
u := strings.TrimSpace(user)
if u == "" {
continue
}
p.blacklist[u] = ParseLevel(lvl)
}
for g, members := range groups {
g = strings.TrimSpace(g)
if g == "" {
continue
}
p.groupNames = append(p.groupNames, g)
for _, m := range members {
m = strings.TrimSpace(m)
if m == "" {
continue
}
p.userGroups[m] = append(p.userGroups[m], g)
}
}
sort.Strings(p.groupNames)
return p
}
// GroupNames returns a copy of every configured user-group name, sorted.
func (p *Policy) GroupNames() []string {
out := make([]string, len(p.groupNames))
copy(out, p.groupNames)
return out
}
// ResolveUser trims the proxy-provided header value and falls back to the
// configured default_user when it is empty (e.g. unproxied/dev deployments).
func (p *Policy) ResolveUser(headerValue string) string {
u := strings.TrimSpace(headerValue)
if u == "" {
return p.defaultUser
}
return u
}
// Level returns the global access level for a user. Users that are neither
// blacklisted nor anonymous get full write access.
func (p *Policy) Level(user string) Level {
user = strings.TrimSpace(user)
if user == "" {
// No identity at all (no proxy header, no default_user): trusted LAN.
return LevelWrite
}
if lvl, ok := p.blacklist[user]; ok {
return lvl
}
return LevelWrite
}
// GroupsOf returns a copy of the groups a user belongs to.
func (p *Policy) GroupsOf(user string) []string {
src := p.userGroups[strings.TrimSpace(user)]
out := make([]string, len(src))
copy(out, src)
return out
}
// ── request-scoped user identity ────────────────────────────────────────────
type ctxKey struct{}
// WithUser returns a copy of ctx carrying the resolved end-user identity.
func WithUser(ctx context.Context, user string) context.Context {
return context.WithValue(ctx, ctxKey{}, user)
}
// UserFrom returns the identity stored by WithUser, or "" if none.
func UserFrom(ctx context.Context) string {
u, _ := ctx.Value(ctxKey{}).(string)
return u
}
+559 -13
View File
@@ -2,17 +2,22 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
@@ -21,17 +26,21 @@ type Handler struct {
broker *broker.Broker
synthetic *synthetic.Synthetic // nil if not enabled
store *storage.Store
policy *access.Policy
acl *panelacl.Store
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
return &Handler{
broker: b,
synthetic: synth,
store: store,
policy: policy,
acl: acl,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
log: log,
@@ -41,6 +50,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cha
// Register mounts all API routes onto mux under the given prefix.
// Requires Go 1.22+ for method+path routing.
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/me", h.getMe)
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
@@ -48,10 +58,26 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/archiver/search", h.archiverSearch)
mux.HandleFunc("GET "+prefix+"/interfaces", h.listInterfaces)
mux.HandleFunc("POST "+prefix+"/interfaces", h.createInterface)
mux.HandleFunc("POST "+prefix+"/interfaces/reorder", h.reorderInterfaces)
mux.HandleFunc("GET "+prefix+"/interfaces/{id}", h.getInterface)
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}", h.updateInterface)
mux.HandleFunc("DELETE "+prefix+"/interfaces/{id}", h.deleteInterface)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/clone", h.cloneInterface)
mux.HandleFunc("GET "+prefix+"/interfaces/{id}/versions", h.listVersions)
mux.HandleFunc("GET "+prefix+"/interfaces/{id}/versions/{version}", h.getVersion)
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}/versions/{version}/tag", h.setVersionTag)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/versions/{version}/promote", h.promoteVersion)
mux.HandleFunc("POST "+prefix+"/interfaces/{id}/versions/{version}/fork", h.forkVersion)
// Per-panel ownership / sharing (Phase 2)
mux.HandleFunc("GET "+prefix+"/interfaces/{id}/acl", h.getPanelACL)
mux.HandleFunc("PUT "+prefix+"/interfaces/{id}/acl", h.putPanelACL)
// Panel folders / hierarchy (Phase 3)
mux.HandleFunc("GET "+prefix+"/folders", h.listFolders)
mux.HandleFunc("POST "+prefix+"/folders", h.createFolder)
mux.HandleFunc("PUT "+prefix+"/folders/{id}", h.updateFolder)
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)
// Signal group tree
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
@@ -63,6 +89,67 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
}
// ── /me ─────────────────────────────────────────────────────────────────────
// getMe reports the caller's resolved identity, global access level, and group
// memberships. The frontend uses this to tailor the UI (hide edit affordances
// for read-only users, block no-access users). Always reachable, even for
// no-access users, so the UI can render an appropriate blocked state.
func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
user := access.UserFrom(r.Context())
groups := h.policy.GroupsOf(user)
if groups == nil {
groups = []string{}
}
jsonOK(w, map[string]any{
"user": user,
"level": h.policy.Level(user).String(),
"groups": groups,
})
}
// ── access helpers ────────────────────────────────────────────────────────────
// caller returns the resolved end-user identity for the request (set by the
// access middleware).
func caller(r *http.Request) string { return access.UserFrom(r.Context()) }
// capByGlobal lowers a per-panel/folder permission to what the user's global
// access level allows: read-only users can never exceed read, no-access users
// get nothing.
func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm {
switch h.policy.Level(user) {
case access.LevelNone:
return panelacl.PermNone
case access.LevelRead:
if p > panelacl.PermRead {
return panelacl.PermRead
}
}
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.
func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
user := caller(r)
if user == "" {
return 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.
func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
user := caller(r)
if user == "" {
return panelacl.PermWrite
}
return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
}
// ── /datasources ──────────────────────────────────────────────────────────────
type dsInfo struct {
@@ -101,6 +188,22 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
return
}
// Synthetic signals carry per-caller visibility rules, so build their list
// from the definitions (filtered) rather than the generic metadata path.
if dsName == "synthetic" && h.synthetic != nil {
user := caller(r)
panel := r.URL.Query().Get("panel")
metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
return synVisible(d, user, panel)
})
out := make([]signalInfo, len(metas))
for i, m := range metas {
out[i] = metaToSignalInfo(m)
}
jsonOK(w, out)
return
}
ds, ok := h.broker.Source(dsName)
if !ok {
jsonError(w, http.StatusNotFound, "unknown data source: "+dsName)
@@ -121,6 +224,20 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
jsonOK(w, out)
}
// synVisible reports whether a synthetic signal should be listed for the given
// caller while editing the given panel. An empty Visibility is treated as
// "global" so legacy definitions remain visible everywhere.
func synVisible(d synthetic.SignalDef, user, panel string) bool {
switch d.Visibility {
case "user":
return user != "" && d.Owner == user
case "panel":
return panel != "" && d.Panel == panel
default: // "global" or legacy empty
return true
}
}
// ── /signals/search ───────────────────────────────────────────────────────────
func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
@@ -139,11 +256,22 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
sources = h.broker.DataSources()
}
user := caller(r)
panel := r.URL.Query().Get("panel")
var out []signalInfo
for _, ds := range sources {
metas, err := ds.ListSignals(r.Context())
if err != nil {
continue
var metas []datasource.Metadata
if ds.Name() == "synthetic" && h.synthetic != nil {
metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
return synVisible(d, user, panel)
})
} else {
var err error
metas, err = ds.ListSignals(r.Context())
if err != nil {
continue
}
}
for _, m := range metas {
if q == "" || strings.Contains(strings.ToLower(m.Name), q) || strings.Contains(strings.ToLower(m.Description), q) {
@@ -163,21 +291,27 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
// channelFinder proxies a query to the EPICS Channel Finder service when one
// is configured, returning a flat list of PV names. Returns 501 otherwise.
func (h *Handler) channelFinder(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
// Empty-query availability probe (used by the signal tree to decide whether
// to surface Channel Finder UI). Always respond 200 so an unconfigured CFS
// is not logged as a failed request in the browser console.
if q == "" && (r.URL.RawQuery == "" || r.URL.RawQuery == "q=") {
jsonOK(w, map[string]bool{"available": h.channelFinderURL != ""})
return
}
if h.channelFinderURL == "" {
jsonError(w, http.StatusNotImplemented, "Channel Finder not configured (set channel_finder_url in config)")
return
}
q := r.URL.Query().Get("q")
cfURL := strings.TrimRight(h.channelFinderURL, "/") + "/resources/channels"
if q != "" {
cfURL += "?~name=*" + q + "*"
} else if r.URL.RawQuery != "" && r.URL.RawQuery != "q=" {
cfURL += "?" + r.URL.RawQuery
} else {
// Empty query check: return empty results to signal CFS is configured.
jsonOK(w, []struct{}{})
return
// Structured query (e.g. ~name=, ~tag=, property filters) passed through.
cfURL += "?" + r.URL.RawQuery
}
resp, err := http.Get(cfURL) //nolint:noctx
@@ -281,6 +415,16 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
// ── /interfaces ───────────────────────────────────────────────────────────────
// interfaceListItem enriches the stored metadata with the panel's owner,
// containing folder, and the caller's effective permission, so the frontend can
// render sharing affordances and filter the visible set.
type interfaceListItem struct {
storage.InterfaceMeta
Owner string `json:"owner,omitempty"`
Folder string `json:"folder,omitempty"`
Perm string `json:"perm"`
}
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
list, err := h.store.List()
if err != nil {
@@ -288,7 +432,21 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, list)
out := make([]interfaceListItem, 0, len(list))
for _, m := range list {
perm := h.panelPerm(r, m.ID)
if perm < panelacl.PermRead {
continue // hide panels the caller cannot see
}
item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()}
if acl := h.acl.GetPanel(m.ID); acl != nil {
item.Owner = acl.Owner
item.Folder = acl.Folder
}
out = append(out, item)
}
h.log.Info("list interfaces", "count", len(out))
jsonOK(w, out)
}
func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
@@ -297,12 +455,20 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
id, err := h.store.Create(body)
id, err := h.store.Create(body, r.URL.Query().Get("tag"))
if err != nil {
h.log.Error("create interface", "err", err)
jsonError(w, http.StatusBadRequest, err.Error())
return
}
// Record ownership so the new panel defaults to private (owner-only). Skip
// for unidentified callers (trusted LAN): an owner-less record would make the
// panel invisible to everyone.
if user := caller(r); user != "" {
if err := h.acl.CreatePanel(id, user); err != nil {
h.log.Error("record panel ownership", "id", id, "err", err)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
@@ -310,6 +476,10 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
data, err := h.store.Get(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
@@ -325,12 +495,16 @@ func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
if err := h.store.Update(id, body); err != nil {
if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
@@ -342,8 +516,133 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) listVersions(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
versions, err := h.store.Versions(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
h.log.Error("list versions", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
jsonOK(w, versions)
}
func (h *Handler) getVersion(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
data, err := h.store.GetVersion(id, version)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "version not found")
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write(data)
}
func (h *Handler) setVersionTag(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
if err := h.store.SetVersionTag(id, version, r.URL.Query().Get("tag")); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "version not found")
} else {
h.log.Error("set version tag", "id", id, "version", version, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) promoteVersion(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
if err := h.store.Promote(id, version); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "version not found")
} else {
h.log.Error("promote version", "id", id, "version", version, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) forkVersion(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
version, err := strconv.Atoi(r.PathValue("version"))
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
return
}
newID, err := h.store.Fork(id, version)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "version not found")
} else {
h.log.Error("fork version", "id", id, "version", version, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
// The fork is a brand-new panel owned by the caller.
if user := caller(r); user != "" {
if err := h.acl.CreatePanel(newID, user); err != nil {
h.log.Error("record fork ownership", "id", newID, "err", err)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": newID})
}
func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this panel")
return
}
if err := h.store.Delete(id); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
@@ -352,11 +651,18 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
}
return
}
if err := h.acl.DeletePanel(id); err != nil {
h.log.Error("delete panel ACL", "id", id, "err", err)
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
newID, err := h.store.Clone(id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
@@ -367,11 +673,242 @@ func (h *Handler) cloneInterface(w http.ResponseWriter, r *http.Request) {
}
return
}
// The clone is a brand-new panel owned by the caller.
if user := caller(r); user != "" {
if err := h.acl.CreatePanel(newID, user); err != nil {
h.log.Error("record clone ownership", "id", newID, "err", err)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": newID})
}
// ── /interfaces/{id}/acl (Phase 2) ──────────────────────────────────────────
// panelACLRequest is the body accepted by putPanelACL: the panel's folder,
// public level, and explicit user/group grants. Owner is immutable here.
type panelACLRequest struct {
Folder string `json:"folder"`
Public string `json:"public"`
Grants []panelacl.Grant `json:"grants"`
}
// panelACLResponse is the sharing record returned to the frontend, enriched
// with the caller's own effective permission.
type panelACLResponse struct {
Owner string `json:"owner"`
Folder string `json:"folder"`
Public string `json:"public"`
Grants []panelacl.Grant `json:"grants"`
Perm string `json:"perm"`
}
// getPanelACL returns the sharing settings for a panel. Any user who can see
// the panel (read) may inspect them.
func (h *Handler) getPanelACL(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
perm := h.panelPerm(r, id)
if perm < panelacl.PermRead {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
return
}
resp := panelACLResponse{Grants: []panelacl.Grant{}, Perm: perm.String()}
if acl := h.acl.GetPanel(id); acl != nil {
resp.Owner = acl.Owner
resp.Folder = acl.Folder
resp.Public = acl.Public
if acl.Grants != nil {
resp.Grants = acl.Grants
}
}
jsonOK(w, resp)
}
// putPanelACL replaces a panel's sharing settings. Only the panel owner may do
// this (an unmanaged legacy panel is adopted by the first writer to set an ACL).
func (h *Handler) putPanelACL(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
user := caller(r)
// The panel must exist in storage.
if _, err := h.store.Get(id); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
} else {
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
// Read-only / no-access users can never change sharing.
if h.policy.Level(user) < access.LevelWrite {
jsonError(w, http.StatusForbidden, "you do not have write access")
return
}
owner := user
if acl := h.acl.GetPanel(id); acl != nil && acl.Owner != "" {
if acl.Owner != user {
jsonError(w, http.StatusForbidden, "only the owner can change sharing")
return
}
owner = acl.Owner
}
var req panelACLRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
// A referenced folder must exist.
if req.Folder != "" {
if _, err := h.acl.GetFolder(req.Folder); err != nil {
jsonError(w, http.StatusBadRequest, "unknown folder: "+req.Folder)
return
}
}
if err := h.acl.SetPanel(id, owner, req.Folder, req.Public, req.Grants); err != nil {
h.log.Error("set panel ACL", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── /folders (Phase 3) ──────────────────────────────────────────────────────
// folderItem is a folder enriched with the caller's effective permission.
type folderItem struct {
panelacl.Folder
Perm string `json:"perm"`
}
// folderRequest is the body accepted by create/updateFolder.
type folderRequest struct {
Name string `json:"name"`
Parent string `json:"parent"`
Public string `json:"public"`
Grants []panelacl.Grant `json:"grants"`
}
// listFolders returns every folder the caller can at least read.
func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request) {
folders := h.acl.Folders()
out := make([]folderItem, 0, len(folders))
for id, f := range folders {
perm := h.folderPerm(r, id)
if perm < panelacl.PermRead {
continue
}
if f.Grants == nil {
f.Grants = []panelacl.Grant{}
}
out = append(out, folderItem{Folder: f, Perm: perm.String()})
}
jsonOK(w, out)
}
// createFolder creates a new folder owned by the caller. Creating a subfolder
// requires write access on the parent.
func (h *Handler) createFolder(w http.ResponseWriter, r *http.Request) {
user := caller(r)
if h.policy.Level(user) < access.LevelWrite {
jsonError(w, http.StatusForbidden, "you do not have write access")
return
}
var req folderRequest
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, "folder name is required")
return
}
if req.Parent != "" && h.folderPerm(r, req.Parent) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to the parent folder")
return
}
f, err := h.acl.CreateFolder(genID("fld"), req.Name, req.Parent, user)
if err != nil {
if errors.Is(err, panelacl.ErrNotFound) {
jsonError(w, http.StatusBadRequest, "unknown parent folder: "+req.Parent)
} else {
h.log.Error("create folder", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(f)
}
// updateFolder replaces a folder's mutable fields. Requires write access on the
// folder (owners always have it).
func (h *Handler) updateFolder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.folderPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
return
}
var req folderRequest
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, "folder name is required")
return
}
if err := h.acl.UpdateFolder(id, req.Name, req.Parent, req.Public, req.Grants); err != nil {
switch {
case errors.Is(err, panelacl.ErrNotFound):
jsonError(w, http.StatusNotFound, "folder not found: "+id)
default:
jsonError(w, http.StatusBadRequest, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// deleteFolder removes a folder, reparenting its contents. Requires write
// access on the folder.
func (h *Handler) deleteFolder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.folderPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
return
}
if err := h.acl.DeleteFolder(id); err != nil {
if errors.Is(err, panelacl.ErrNotFound) {
jsonError(w, http.StatusNotFound, "folder not found: "+id)
} else {
h.log.Error("delete folder", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── /usergroups ─────────────────────────────────────────────────────────────
// listUserGroups returns the names of the user-groups defined in config, for
// populating the sharing UI's group picker.
func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) {
names := h.policy.GroupNames()
if names == nil {
names = []string{}
}
jsonOK(w, names)
}
// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d").
func genID(prefix string) string {
var b [8]byte
_, _ = rand.Read(b[:])
return prefix + "-" + hex.EncodeToString(b[:])
}
// ── /synthetic ────────────────────────────────────────────────────────────────
func (h *Handler) listSynthetic(w http.ResponseWriter, _ *http.Request) {
@@ -392,6 +929,11 @@ func (h *Handler) createSynthetic(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
// Stamp ownership from the trusted identity; clients cannot spoof it.
def.Owner = caller(r)
if def.Visibility == "" {
def.Visibility = "panel"
}
if err := h.synthetic.AddSignal(def); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusConflict, err.Error())
@@ -435,6 +977,10 @@ func (h *Handler) updateSynthetic(w http.ResponseWriter, r *http.Request) {
}
// Ensure the name in the URL matches the body (or fill it in).
def.Name = name
// Preserve the original owner across edits; never trust a client-supplied one.
if existing, err := h.synthetic.GetSignal(name); err == nil {
def.Owner = existing.Owner
}
if err := h.synthetic.UpdateSignal(def); err != nil {
if errors.Is(err, datasource.ErrNotFound) {
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
+9 -3
View File
@@ -12,9 +12,11 @@ import (
"strings"
"testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
@@ -38,9 +40,13 @@ func setup(t *testing.T) (*httptest.Server, func()) {
if err != nil {
t.Fatal("storage.New:", err)
}
acl, err := panelacl.New(dir)
if err != nil {
t.Fatal("panelacl.New:", err)
}
mux := http.NewServeMux()
api.New(brk, nil, store, "", "", log).Register(mux, "/api/v1")
api.New(brk, nil, store, access.New("", nil, nil), acl, "", "", log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {
@@ -396,7 +402,7 @@ func TestStorageValidateID(t *testing.T) {
}
// Create a real interface first to get a valid ID.
id, err := store.Create([]byte(sampleXML))
id, err := store.Create([]byte(sampleXML), "")
if err != nil {
t.Fatal("create:", err)
}
@@ -416,7 +422,7 @@ func TestStorageValidateID(t *testing.T) {
if _, err := store.Get(bad); err == nil {
t.Errorf("Get(%q) should have failed, got nil error", bad)
}
if err := store.Update(bad, []byte(sampleXML)); err == nil {
if err := store.Update(bad, []byte(sampleXML), ""); err == nil {
t.Errorf("Update(%q) should have failed", bad)
}
if err := store.Delete(bad); err == nil {
+41 -3
View File
@@ -12,12 +12,44 @@ import (
type Config struct {
Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"`
// Groups are named sets of users, referenced by panel sharing rules.
Groups []GroupDef `toml:"groups"`
}
// GroupDef is a named set of users defined as [[groups]] in the config file.
type GroupDef struct {
Name string `toml:"name"`
Members []string `toml:"members"`
}
// BlacklistEntry downgrades a specific user's global access level. Levels:
// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted
// with full access.
type BlacklistEntry struct {
User string `toml:"user"`
Level string `toml:"level"`
}
type ServerConfig struct {
Listen string `toml:"listen"`
StorageDir string `toml:"storage_dir"`
MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited
Listen string `toml:"listen"`
StorageDir string `toml:"storage_dir"`
MaxUpdateRateHz float64 `toml:"max_update_rate_hz"` // 0 = unlimited
// TrustedUserHeader is the HTTP header from which the end-user identity is
// read on each WebSocket connection (set by a trusted reverse proxy doing
// authentication). The identity is used for EPICS writes so each session
// can act as a different user. Empty disables it (writes use the server
// identity). MUST only be enabled when a proxy strips any client-supplied
// value, otherwise the header can be spoofed.
TrustedUserHeader string `toml:"trusted_user_header"`
// DefaultUser is the identity used when the trusted user header is absent or
// empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous.
DefaultUser string `toml:"default_user"`
// Blacklist downgrades specific users' global access level. Everyone not
// listed is trusted with full write access.
Blacklist []BlacklistEntry `toml:"blacklist"`
}
type DatasourceConfig struct {
@@ -93,6 +125,12 @@ func applyEnv(cfg *Config) {
cfg.Server.MaxUpdateRateHz = hz
}
}
if v := env("UOPI_SERVER_TRUSTED_USER_HEADER"); v != "" {
cfg.Server.TrustedUserHeader = v
}
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
cfg.Server.DefaultUser = v
}
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v
}
+5
View File
@@ -508,6 +508,11 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
// Write puts a new value onto a CA channel.
// If the signal is not currently subscribed (e.g. a button in oneshot mode),
// a temporary CA channel is created, used for the put, then torn down.
//
// NOTE: per-session end-user identity (datasource.WithUser) is NOT honoured in
// the CGo/libca build: libca uses a single process-wide CA context whose client
// name is fixed at startup. Writes therefore use the server identity here. Use
// the default pure-Go build for per-user write attribution.
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
e.attachCAContext()
+85 -2
View File
@@ -39,8 +39,29 @@ type EPICS struct {
mu sync.RWMutex
metadata map[string]datasource.Metadata
// Per-user CA clients for writes. EPICS Channel Access carries the client
// username at the circuit (TCP) level, so attributing a write to a specific
// end-user requires a dedicated client whose ClientName is that user. These
// are created lazily on first write by each user and evicted when idle.
ctx context.Context // datasource lifetime; governs per-user clients
baseCfg ca.Config // template config for per-user clients
selfName string // the server's own CA client name
userMu sync.Mutex
userClients map[string]*userClient
}
// userClient is a per-end-user CA client and its last-use time for idle eviction.
type userClient struct {
cli *ca.Client
lastUsed time.Time
}
// userClientIdleTTL is how long an idle per-user CA client is kept before being
// closed. Writes are bursty, so a few minutes avoids reconnecting on every put
// without holding circuits open indefinitely.
const userClientIdleTTL = 10 * time.Minute
// New creates a new EPICS Channel Access data source.
//
// caAddrList is the EPICS_CA_ADDR_LIST value (space-separated addresses).
@@ -79,7 +100,12 @@ func (e *EPICS) Connect(ctx context.Context) error {
return fmt.Errorf("epics: %w", err)
}
e.client = cli
slog.Info("epics: CA client started", "addrs", cfg.AddrList)
e.ctx = ctx
e.baseCfg = cfg
e.selfName = cfg.ClientName
e.userClients = make(map[string]*userClient)
go e.evictIdleUserClients(ctx)
slog.Info("epics: CA client started", "addrs", cfg.AddrList, "client_name", cfg.ClientName)
// Perform background sync from Channel Finder if configured.
if e.cfURL != "" && e.autoSyncFilter != "" {
@@ -344,13 +370,70 @@ func (e *EPICS) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
// Write puts a new value onto the named CA channel.
// value must be one of: float64, float32, int64, int32, int, int16, string, bool.
//
// When the request context carries an end-user identity (see datasource.WithUser)
// that differs from the server's own CA client name, the put is performed on a
// dedicated CA client whose ClientName is that user, so the IOC's access-security
// rules see the actual end-user rather than the server process.
func (e *EPICS) Write(ctx context.Context, signal string, value any) error {
if err := e.client.Put(ctx, signal, value); err != nil {
cli := e.client
if user, ok := datasource.UserFrom(ctx); ok && user != e.selfName {
uc, err := e.clientForUser(user)
if err != nil {
return fmt.Errorf("epics: write %q as %q: %w", signal, user, err)
}
cli = uc
}
if err := cli.Put(ctx, signal, value); err != nil {
return fmt.Errorf("epics: write %q: %w", signal, err)
}
return nil
}
// clientForUser returns a CA client whose ClientName is user, creating and
// caching one on first use. The client is bound to the datasource lifetime ctx,
// not the per-request ctx, so it survives across writes until evicted when idle.
func (e *EPICS) clientForUser(user string) (*ca.Client, error) {
e.userMu.Lock()
defer e.userMu.Unlock()
if uc, ok := e.userClients[user]; ok {
uc.lastUsed = time.Now()
return uc.cli, nil
}
cfg := e.baseCfg
cfg.ClientName = user
cli, err := ca.NewClient(e.ctx, cfg)
if err != nil {
return nil, err
}
e.userClients[user] = &userClient{cli: cli, lastUsed: time.Now()}
slog.Info("epics: created per-user CA client", "user", user)
return cli, nil
}
// evictIdleUserClients closes per-user CA clients that have not been used within
// userClientIdleTTL. It runs until ctx is cancelled.
func (e *EPICS) evictIdleUserClients(ctx context.Context) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
e.userMu.Lock()
for user, uc := range e.userClients {
if now.Sub(uc.lastUsed) > userClientIdleTTL {
uc.cli.Close()
delete(e.userClients, user)
slog.Info("epics: evicted idle per-user CA client", "user", user)
}
}
e.userMu.Unlock()
}
}
}
// -------------------------------------------------------------------------- //
// History //
// -------------------------------------------------------------------------- //
@@ -10,6 +10,16 @@ type SignalDef struct {
Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Meta MetaOverride `json:"meta"` // optional metadata overrides
// Visibility controls who sees this signal in the signal tree:
// "global" — listed in every panel's edit mode
// "user" — listed in every panel owned by Owner
// "panel" — listed only when editing the bound Panel
// An empty value is treated as "global" for backward compatibility with
// definitions created before this field existed.
Visibility string `json:"visibility,omitempty"`
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
}
// InputRef names one upstream signal used as input to the pipeline.
@@ -83,6 +83,9 @@ func buildNode(d NodeDef) (dsp.Node, error) {
case "derivative":
return &dsp.DerivativeNode{}, nil
case "integrate":
return &dsp.IntegrateNode{}, nil
case "clamp":
return &dsp.ClampNode{
Min: floatParam(p, "min"),
@@ -85,6 +85,22 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
return out, nil
}
// FilteredMetadata returns metadata for every defined synthetic signal for
// which keep returns true. It lets the API layer apply per-caller visibility
// rules (which depend on SignalDef fields not present in datasource.Metadata).
func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Metadata {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]datasource.Metadata, 0, len(s.signals))
for _, st := range s.signals {
if keep(st.def) {
out = append(out, defToMetadata(st.def))
}
}
return out
}
// GetMetadata returns metadata for a single named signal.
func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
s.mu.RLock()
+27
View File
@@ -0,0 +1,27 @@
package datasource
import "context"
// userKey is the unexported context key under which the end-user identity is
// stored. Using a private type prevents collisions with other packages.
type userKey struct{}
// WithUser returns a copy of ctx carrying the end-user identity associated with
// the request (e.g. the authenticated web client). Data sources may use this to
// attribute operations to the actual user rather than the server process — for
// example EPICS Channel Access access-security rules match on the client
// username. An empty user is treated as "no client identity".
func WithUser(ctx context.Context, user string) context.Context {
if user == "" {
return ctx
}
return context.WithValue(ctx, userKey{}, user)
}
// UserFrom returns the end-user identity stored in ctx by WithUser. The boolean
// is false when no (non-empty) identity is present, in which case callers
// should fall back to the server's own identity.
func UserFrom(ctx context.Context) (string, bool) {
u, ok := ctx.Value(userKey{}).(string)
return u, ok && u != ""
}
+28
View File
@@ -204,6 +204,34 @@ func (n *DerivativeNode) Process(inputs []float64, state map[string]any) (float6
return 0, nil
}
// ── IntegrateNode ─────────────────────────────────────────────────────────────
// IntegrateNode accumulates the time-integral of input[0] using the trapezoidal
// rule, where dt is in seconds. On the first call it returns 0 (no interval yet).
type IntegrateNode struct{}
func (n *IntegrateNode) Type() string { return "integrate" }
func (n *IntegrateNode) Process(inputs []float64, state map[string]any) (float64, error) {
if len(inputs) == 0 {
return 0, errors.New("integrate: no inputs")
}
now := time.Now()
acc, _ := state["acc"].(float64)
if prevVal, ok := state["prev_val"].(float64); ok {
if prevTime, ok := state["prev_time"].(time.Time); ok {
dt := now.Sub(prevTime).Seconds()
if dt < 0 {
dt = 0
}
acc += (inputs[0] + prevVal) * 0.5 * dt
}
}
state["acc"] = acc
state["prev_val"] = inputs[0]
state["prev_time"] = now
return acc, nil
}
// ── ClampNode ─────────────────────────────────────────────────────────────────
// ClampNode clamps the output to [Min, Max].
+417
View File
@@ -0,0 +1,417 @@
// Package panelacl implements per-panel ownership and sharing (Phase 2) plus a
// nested folder hierarchy for organising panels (Phase 3). It persists a single
// sidecar index, {storageDir}/acl.json, alongside the XML interface files.
//
// The effective permission a user has on a panel is the maximum of: ownership
// (always write), the panel's public level, any direct user grant, any grant to
// a user-group the user belongs to, and the permissions inherited from the
// folder chain the panel lives in. Callers are expected to further cap this by
// the user's global access level (see internal/access).
//
// Panels that have no record in the index are treated as fully open
// (PermWrite). This preserves access to interfaces created before ACLs existed;
// panels created through the ACL-aware API are recorded as private to their
// owner.
package panelacl
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
)
// ErrNotFound is returned when a folder does not exist.
var ErrNotFound = errors.New("not found")
// Perm is an effective access level on a panel or folder.
type Perm int
const (
// PermNone denies access.
PermNone Perm = iota
// PermRead permits viewing only.
PermRead
// PermWrite permits viewing and modification.
PermWrite
)
// String renders a Perm as the token used in the JSON index and HTTP API.
func (p Perm) String() string {
switch p {
case PermRead:
return "read"
case PermWrite:
return "write"
default:
return "none"
}
}
// parsePerm maps a config/API token to a Perm. Unknown/empty → PermNone.
func parsePerm(s string) Perm {
switch s {
case "read":
return PermRead
case "write", "readwrite", "rw":
return PermWrite
default:
return PermNone
}
}
// Grant shares a panel or folder with a single user or a named user-group.
type Grant struct {
Kind string `json:"kind"` // "user" | "group"
Name string `json:"name"` // username or user-group name
Perm string `json:"perm"` // "read" | "write"
}
// PanelACL is the ownership + sharing record for one panel.
type PanelACL struct {
Owner string `json:"owner"`
Folder string `json:"folder,omitempty"` // folder id the panel belongs to ("" = root)
Public string `json:"public,omitempty"` // "" | "read" | "write"
Grants []Grant `json:"grants,omitempty"`
Order float64 `json:"order,omitempty"` // sort position within its folder
}
// Folder is a node in the panel-organisation hierarchy. Permissions set on a
// folder are inherited by every panel and subfolder beneath it.
type Folder struct {
ID string `json:"id"`
Name string `json:"name"`
Parent string `json:"parent,omitempty"` // parent folder id ("" = root)
Owner string `json:"owner"`
Public string `json:"public,omitempty"`
Grants []Grant `json:"grants,omitempty"`
}
type index struct {
Panels map[string]*PanelACL `json:"panels"`
Folders map[string]*Folder `json:"folders"`
}
// Store persists and resolves the panel ACL index. It is safe for concurrent
// use.
type Store struct {
path string
mu sync.RWMutex
idx index
}
// New opens (loading if present) the acl.json index in storageDir.
func New(storageDir string) (*Store, error) {
s := &Store{
path: filepath.Join(storageDir, "acl.json"),
idx: index{Panels: map[string]*PanelACL{}, Folders: map[string]*Folder{}},
}
data, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, &s.idx); err != nil {
return nil, err
}
if s.idx.Panels == nil {
s.idx.Panels = map[string]*PanelACL{}
}
if s.idx.Folders == nil {
s.idx.Folders = map[string]*Folder{}
}
return s, nil
}
// saveLocked atomically persists the index. The caller must hold s.mu.
func (s *Store) saveLocked() error {
data, err := json.MarshalIndent(s.idx, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// ── Panel records ────────────────────────────────────────────────────────────
// GetPanel returns a copy of a panel's ACL record, or nil if it is unmanaged.
func (s *Store) GetPanel(id string) *PanelACL {
s.mu.RLock()
defer s.mu.RUnlock()
acl, ok := s.idx.Panels[id]
if !ok {
return nil
}
return clonePanel(acl)
}
// CreatePanel records ownership for a newly created panel, defaulting to
// private (owner-only) access. Existing records are left untouched.
func (s *Store) CreatePanel(id, owner string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.idx.Panels[id]; ok {
return nil
}
s.idx.Panels[id] = &PanelACL{Owner: owner}
return s.saveLocked()
}
// SetPanel replaces a panel's sharing settings (folder, public level, grants)
// while preserving its existing owner (or adopting the supplied owner when the
// panel was previously unmanaged).
func (s *Store) SetPanel(id, owner, folder, public string, grants []Grant) error {
s.mu.Lock()
defer s.mu.Unlock()
acl := s.idx.Panels[id]
if acl == nil {
acl = &PanelACL{Owner: owner}
s.idx.Panels[id] = acl
}
acl.Folder = folder
acl.Public = public
acl.Grants = grants
return s.saveLocked()
}
// PlacePanel sets only a panel's organizational fields — its folder and sort
// order — preserving ownership and sharing. A previously-unmanaged panel gets a
// record with no owner, which PanelPerm still treats as fully open (so dragging
// a legacy panel into a folder does not lock it).
func (s *Store) PlacePanel(id, folder string, order float64) error {
s.mu.Lock()
defer s.mu.Unlock()
acl := s.idx.Panels[id]
if acl == nil {
acl = &PanelACL{}
s.idx.Panels[id] = acl
}
acl.Folder = folder
acl.Order = order
return s.saveLocked()
}
// DeletePanel removes a panel's ACL record (called when the panel is deleted).
func (s *Store) DeletePanel(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.idx.Panels[id]; !ok {
return nil
}
delete(s.idx.Panels, id)
return s.saveLocked()
}
// ── Folders ──────────────────────────────────────────────────────────────────
// Folders returns a copy of every folder, keyed by id.
func (s *Store) Folders() map[string]Folder {
s.mu.RLock()
defer s.mu.RUnlock()
out := make(map[string]Folder, len(s.idx.Folders))
for id, f := range s.idx.Folders {
out[id] = *cloneFolder(f)
}
return out
}
// GetFolder returns a copy of a folder, or ErrNotFound.
func (s *Store) GetFolder(id string) (Folder, error) {
s.mu.RLock()
defer s.mu.RUnlock()
f, ok := s.idx.Folders[id]
if !ok {
return Folder{}, ErrNotFound
}
return *cloneFolder(f), nil
}
// CreateFolder adds a new folder owned by owner and returns it.
func (s *Store) CreateFolder(id, name, parent, owner string) (Folder, error) {
s.mu.Lock()
defer s.mu.Unlock()
if parent != "" {
if _, ok := s.idx.Folders[parent]; !ok {
return Folder{}, ErrNotFound
}
}
f := &Folder{ID: id, Name: name, Parent: parent, Owner: owner}
s.idx.Folders[id] = f
if err := s.saveLocked(); err != nil {
return Folder{}, err
}
return *cloneFolder(f), nil
}
// UpdateFolder replaces a folder's mutable fields (name, parent, public,
// grants). It rejects parent changes that would create a cycle.
func (s *Store) UpdateFolder(id, name, parent, public string, grants []Grant) error {
s.mu.Lock()
defer s.mu.Unlock()
f, ok := s.idx.Folders[id]
if !ok {
return ErrNotFound
}
if parent != "" {
if _, ok := s.idx.Folders[parent]; !ok {
return ErrNotFound
}
if s.createsCycleLocked(id, parent) {
return errors.New("folder parent change would create a cycle")
}
}
f.Name = name
f.Parent = parent
f.Public = public
f.Grants = grants
return s.saveLocked()
}
// DeleteFolder removes a folder, reparenting its child folders and panels to the
// deleted folder's parent (so nothing is orphaned).
func (s *Store) DeleteFolder(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
f, ok := s.idx.Folders[id]
if !ok {
return ErrNotFound
}
newParent := f.Parent
for _, child := range s.idx.Folders {
if child.Parent == id {
child.Parent = newParent
}
}
for _, acl := range s.idx.Panels {
if acl.Folder == id {
acl.Folder = newParent
}
}
delete(s.idx.Folders, id)
return s.saveLocked()
}
// createsCycleLocked reports whether setting folder id's parent to newParent
// would introduce a cycle. The caller must hold s.mu.
func (s *Store) createsCycleLocked(id, newParent string) bool {
for cur := newParent; cur != ""; {
if cur == id {
return true
}
f, ok := s.idx.Folders[cur]
if !ok {
return false
}
cur = f.Parent
}
return false
}
// ── Permission resolution ────────────────────────────────────────────────────
// PanelPerm returns the effective permission user has on panel id, given the
// user-groups the user belongs to. Unmanaged panels are fully open.
func (s *Store) PanelPerm(id, user string, groups []string) Perm {
s.mu.RLock()
defer s.mu.RUnlock()
acl, ok := s.idx.Panels[id]
if !ok {
return PermWrite // legacy/unmanaged panel: fully open
}
// A record carrying only organizational metadata (folder/order) with no
// owner, public level, or grants is still open — keeps an unmanaged panel
// accessible after it has merely been moved into a folder or reordered.
if acl.Owner == "" && acl.Public == "" && len(acl.Grants) == 0 {
return PermWrite
}
best := PermNone
if user != "" && acl.Owner == user {
return PermWrite
}
best = maxPerm(best, parsePerm(acl.Public))
best = maxPerm(best, grantsPerm(acl.Grants, user, groups))
if acl.Folder != "" {
best = maxPerm(best, s.folderPermLocked(acl.Folder, user, groups, map[string]bool{}))
}
return best
}
// FolderPerm returns the effective permission user has on folder id.
func (s *Store) FolderPerm(id, user string, groups []string) Perm {
s.mu.RLock()
defer s.mu.RUnlock()
return s.folderPermLocked(id, user, groups, map[string]bool{})
}
func (s *Store) folderPermLocked(id, user string, groups []string, seen map[string]bool) Perm {
if id == "" || seen[id] {
return PermNone
}
seen[id] = true
f, ok := s.idx.Folders[id]
if !ok {
return PermNone
}
if user != "" && f.Owner == user {
return PermWrite
}
best := PermNone
best = maxPerm(best, parsePerm(f.Public))
best = maxPerm(best, grantsPerm(f.Grants, user, groups))
best = maxPerm(best, s.folderPermLocked(f.Parent, user, groups, seen))
return best
}
func grantsPerm(grants []Grant, user string, groups []string) Perm {
best := PermNone
for _, g := range grants {
switch g.Kind {
case "user":
if user != "" && g.Name == user {
best = maxPerm(best, parsePerm(g.Perm))
}
case "group":
if contains(groups, g.Name) {
best = maxPerm(best, parsePerm(g.Perm))
}
}
}
return best
}
// ── helpers ──────────────────────────────────────────────────────────────────
func maxPerm(a, b Perm) Perm {
if a > b {
return a
}
return b
}
func contains(xs []string, v string) bool {
for _, x := range xs {
if x == v {
return true
}
}
return false
}
func clonePanel(a *PanelACL) *PanelACL {
c := *a
c.Grants = append([]Grant(nil), a.Grants...)
return &c
}
func cloneFolder(f *Folder) *Folder {
c := *f
c.Grants = append([]Grant(nil), f.Grants...)
return &c
}
+109
View File
@@ -0,0 +1,109 @@
package panelacl
import "testing"
func newStore(t *testing.T) *Store {
t.Helper()
s, err := New(t.TempDir())
if err != nil {
t.Fatal("New:", err)
}
return s
}
func TestUnmanagedPanelIsOpen(t *testing.T) {
s := newStore(t)
if got := s.PanelPerm("legacy", "alice", nil); got != PermWrite {
t.Fatalf("unmanaged panel perm = %v, want write", got)
}
}
func TestOwnerAlwaysWrites(t *testing.T) {
s := newStore(t)
if err := s.CreatePanel("p1", "alice"); err != nil {
t.Fatal(err)
}
if got := s.PanelPerm("p1", "alice", nil); got != PermWrite {
t.Fatalf("owner perm = %v, want write", got)
}
// A freshly created panel is private to its owner.
if got := s.PanelPerm("p1", "bob", nil); got != PermNone {
t.Fatalf("non-owner perm on private panel = %v, want none", got)
}
}
func TestPublicAndGrants(t *testing.T) {
s := newStore(t)
if err := s.CreatePanel("p1", "alice"); err != nil {
t.Fatal(err)
}
grants := []Grant{
{Kind: "user", Name: "bob", Perm: "write"},
{Kind: "group", Name: "ops", Perm: "read"},
}
if err := s.SetPanel("p1", "alice", "", "read", grants); err != nil {
t.Fatal(err)
}
// Public read applies to anyone.
if got := s.PanelPerm("p1", "carol", nil); got != PermRead {
t.Fatalf("public perm = %v, want read", got)
}
// Direct user grant raises bob to write.
if got := s.PanelPerm("p1", "bob", nil); got != PermWrite {
t.Fatalf("granted user perm = %v, want write", got)
}
// Group membership grants read.
if got := s.PanelPerm("p1", "dave", []string{"ops"}); got != PermRead {
t.Fatalf("group perm = %v, want read", got)
}
}
func TestFolderInheritance(t *testing.T) {
s := newStore(t)
f, err := s.CreateFolder("f1", "Shared", "", "alice")
if err != nil {
t.Fatal(err)
}
if err := s.UpdateFolder(f.ID, f.Name, "", "read", nil); err != nil {
t.Fatal(err)
}
if err := s.CreatePanel("p1", "alice"); err != nil {
t.Fatal(err)
}
if err := s.SetPanel("p1", "alice", f.ID, "", nil); err != nil {
t.Fatal(err)
}
// The panel inherits the folder's public-read level.
if got := s.PanelPerm("p1", "bob", nil); got != PermRead {
t.Fatalf("inherited perm = %v, want read", got)
}
}
func TestFolderCycleRejected(t *testing.T) {
s := newStore(t)
a, _ := s.CreateFolder("a", "A", "", "alice")
b, _ := s.CreateFolder("b", "B", a.ID, "alice")
// Re-parenting A under its own descendant B must be rejected.
if err := s.UpdateFolder(a.ID, a.Name, b.ID, "", nil); err == nil {
t.Fatal("expected cycle rejection, got nil")
}
}
func TestDeleteFolderReparents(t *testing.T) {
s := newStore(t)
a, _ := s.CreateFolder("a", "A", "", "alice")
b, _ := s.CreateFolder("b", "B", a.ID, "alice")
if err := s.CreatePanel("p1", "alice"); err != nil {
t.Fatal(err)
}
if err := s.SetPanel("p1", "alice", b.ID, "", nil); err != nil {
t.Fatal(err)
}
if err := s.DeleteFolder(b.ID); err != nil {
t.Fatal(err)
}
// B's panel should reparent to A (B's former parent), not orphan.
if acl := s.GetPanel("p1"); acl == nil || acl.Folder != a.ID {
t.Fatalf("panel folder after delete = %+v, want %s", acl, a.ID)
}
}
+48 -4
View File
@@ -5,12 +5,15 @@ import (
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker"
"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"
)
@@ -23,7 +26,7 @@ type Server struct {
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Server {
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
mux := http.NewServeMux()
// Health check
@@ -33,13 +36,16 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
})
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy})
// Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
// REST API
api.New(brk, synth, store, channelFinderURL, archiverURL, log).Register(mux, apiPrefix)
// REST API — registered on a dedicated mux so it can be wrapped with the
// access-control middleware (identity resolution + global level enforcement).
apiMux := http.NewServeMux()
api.New(brk, synth, store, policy, acl, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
// Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS))
@@ -56,6 +62,44 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
}
}
// accessMiddleware resolves the end-user identity from the trusted proxy header
// (falling back to the configured default_user), stores it on the request
// context, and enforces the user's global access level on every API request:
// - LevelNone → 403 for all requests.
// - LevelRead → 403 for any mutating request (non GET/HEAD).
// - LevelWrite → no restriction.
//
// The /me endpoint is always reachable so the frontend can discover the
// caller's identity and level even when otherwise restricted.
func accessMiddleware(policy *access.Policy, userHeader string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var raw string
if userHeader != "" {
raw = r.Header.Get(userHeader)
}
user := policy.ResolveUser(raw)
r = r.WithContext(access.WithUser(r.Context(), user))
// Always allow identity discovery.
if strings.HasSuffix(r.URL.Path, apiPrefix+"/me") {
next.ServeHTTP(w, r)
return
}
switch policy.Level(user) {
case access.LevelNone:
http.Error(w, "access denied", http.StatusForbidden)
return
case access.LevelRead:
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "read-only access", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
// Start listens and serves until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error {
s.log.Info("listening", "addr", s.httpServer.Addr)
+30 -1
View File
@@ -6,11 +6,13 @@ import (
"errors"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/coder/websocket"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
@@ -87,9 +89,21 @@ type histPoint struct {
type wsHandler struct {
broker *broker.Broker
log *slog.Logger
// userHeader is the HTTP header from which the per-session end-user identity
// is read (set by a trusted auth proxy). Empty disables per-user identity.
userHeader string
// policy enforces the global access level (blacklist) on signal writes.
policy *access.Policy
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Capture the end-user identity from the trusted proxy header (if enabled)
// before the connection is upgraded, while the original headers are present.
var user string
if h.userHeader != "" {
user = strings.TrimSpace(r.Header.Get(h.userHeader))
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
InsecureSkipVerify: true,
@@ -107,6 +121,8 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c := &wsClient{
conn: conn,
broker: h.broker,
user: user,
policy: h.policy,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()),
@@ -136,6 +152,8 @@ type wsClient struct {
conn *websocket.Conn
broker *broker.Broker
log *slog.Logger
user string // end-user identity from the trusted proxy header ("" if none)
policy *access.Policy // global access-level enforcement
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
@@ -277,6 +295,14 @@ func (c *wsClient) handleUnsubscribe(refs []sigRef) {
}
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
// Enforce the user's global access level: blacklisted read-only / no-access
// users may not write signals, regardless of the channel's own writability.
if c.policy != nil && c.policy.Level(c.policy.ResolveUser(c.user)) < access.LevelWrite {
c.log.Warn("write: access denied", "ds", msg.DS, "name", msg.Name, "user", c.user)
c.sendError(ctx, "ACCESS_DENIED", "you do not have write access")
return
}
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
@@ -305,8 +331,11 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
return
}
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value)
c.log.Info("write", "ds", msg.DS, "name", msg.Name, "value", value, "user", c.user)
metrics.IncWrites()
// Attribute the write to the connecting end-user so data sources (EPICS) can
// act under that identity rather than the server's.
ctx = datasource.WithUser(ctx, c.user)
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.log.Warn("write: ds.Write failed", "ds", msg.DS, "name", msg.Name, "err", err)
c.sendError(ctx, "WRITE_ERROR", err.Error())
+235 -10
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -23,6 +24,15 @@ type InterfaceMeta struct {
Version int `json:"version"`
}
// VersionMeta describes a single persisted revision of an interface.
type VersionMeta struct {
Version int `json:"version"`
Name string `json:"name"`
Tag string `json:"tag,omitempty"`
Current bool `json:"current"`
SavedAt time.Time `json:"savedAt"`
}
// Store persists interface definitions as XML files under {storageDir}/interfaces/
// and workspace-level JSON blobs directly in {storageDir}.
type Store struct {
@@ -123,6 +133,7 @@ type rootAttrs struct {
ID string `xml:"id,attr"`
Name string `xml:"name,attr"`
Version int `xml:"version,attr"`
Tag string `xml:"tag,attr"`
}
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
@@ -151,8 +162,8 @@ func (s *Store) Get(id string) ([]byte, error) {
// Create stores new interface XML and returns the generated ID.
// The ID is derived from the name attribute; a timestamp suffix is added to
// ensure uniqueness.
func (s *Store) Create(xmlData []byte) (string, error) {
// ensure uniqueness. If tag is non-empty it is stamped as the revision label.
func (s *Store) Create(xmlData []byte, tag string) (string, error) {
var root rootAttrs
if err := xml.Unmarshal(xmlData, &root); err != nil {
return "", fmt.Errorf("invalid XML: %w", err)
@@ -167,18 +178,30 @@ func (s *Store) Create(xmlData []byte) (string, error) {
id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli())
}
// Initialize version to 1.
// Initialize version to 1 and stamp the generated ID so the persisted XML
// is self-consistent (the client sends an empty id for new interfaces).
updatedData, err := setXMLAttribute(xmlData, "version", "1")
if err != nil {
return "", fmt.Errorf("initialize version attribute: %w", err)
}
updatedData, err = setXMLAttribute(updatedData, "id", id)
if err != nil {
return "", fmt.Errorf("stamp id attribute: %w", err)
}
if tag != "" {
updatedData, err = setXMLAttribute(updatedData, "tag", tag)
if err != nil {
return "", fmt.Errorf("set tag attribute: %w", err)
}
}
return id, os.WriteFile(s.filePath(id), updatedData, 0o644)
}
// Update replaces the XML for an existing interface, preserving the previous
// version as a separate file.
func (s *Store) Update(id string, xmlData []byte) error {
// version as a separate file. If tag is non-empty it is stamped as the label
// for the new revision.
func (s *Store) Update(id string, xmlData []byte, tag string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
@@ -213,21 +236,223 @@ func (s *Store) Update(id string, xmlData []byte) error {
if err != nil {
return fmt.Errorf("update version attribute: %w", err)
}
if tag != "" {
updatedData, err = setXMLAttribute(updatedData, "tag", tag)
if err != nil {
return fmt.Errorf("set tag attribute: %w", err)
}
}
return os.WriteFile(oldPath, updatedData, 0o644)
}
// Versions returns metadata for every persisted revision of the interface,
// ordered newest-first. The current file is flagged with Current=true.
func (s *Store) Versions(id string) ([]VersionMeta, error) {
if err := validateID(id); err != nil {
return nil, ErrNotFound
}
// Current revision.
curInfo, err := os.Stat(s.filePath(id))
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
} else if err != nil {
return nil, err
}
curRoot, err := s.readRoot(s.filePath(id))
if err != nil {
return nil, err
}
out := []VersionMeta{{
Version: curRoot.Version,
Name: curRoot.Name,
Tag: curRoot.Tag,
Current: true,
SavedAt: curInfo.ModTime(),
}}
// Backup revisions: id.vN.xml
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
prefix := id + ".v"
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".xml") {
continue
}
if !isVersioned(name) {
continue
}
p := filepath.Join(s.dir, name)
info, err := e.Info()
if err != nil {
continue
}
root, err := s.readRoot(p)
if err != nil {
continue
}
out = append(out, VersionMeta{
Version: root.Version,
Name: root.Name,
Tag: root.Tag,
SavedAt: info.ModTime(),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
return out, nil
}
// GetVersion returns the raw XML bytes for a specific revision of an interface.
func (s *Store) GetVersion(id string, version int) ([]byte, error) {
if err := validateID(id); err != nil {
return nil, ErrNotFound
}
// The current file may hold the requested version.
curRoot, err := s.readRoot(s.filePath(id))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return nil, err
}
if curRoot.Version == version {
return os.ReadFile(s.filePath(id))
}
backupPath := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
data, err := os.ReadFile(backupPath)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
return data, err
}
// versionPath returns the on-disk path for a specific revision, resolving the
// current file when version matches the live revision.
func (s *Store) versionPath(id string, version int) (string, error) {
cur := s.filePath(id)
curRoot, err := s.readRoot(cur)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", ErrNotFound
}
return "", err
}
if curRoot.Version == version {
return cur, nil
}
backup := filepath.Join(s.dir, fmt.Sprintf("%s.v%d.xml", id, version))
if _, err := os.Stat(backup); err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", ErrNotFound
}
return "", err
}
return backup, nil
}
// SetVersionTag sets (or clears, when tag is empty) the label of a specific
// revision in place, without creating a new revision.
func (s *Store) SetVersionTag(id string, version int, tag string) error {
if err := validateID(id); err != nil {
return ErrNotFound
}
path, err := s.versionPath(id, version)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
updated, err := setXMLAttribute(data, "tag", tag)
if err != nil {
return fmt.Errorf("set tag attribute: %w", err)
}
return os.WriteFile(path, updated, 0o644)
}
// Promote makes a past revision the current one by re-saving its content as a
// new revision on top of history. The existing current revision is preserved
// as a backup, so promotion is non-destructive.
func (s *Store) Promote(id string, version int) error {
data, err := s.GetVersion(id, version)
if err != nil {
return err
}
return s.Update(id, data, fmt.Sprintf("restored from v%d", version))
}
// Fork creates a brand-new interface from the content of a specific revision,
// assigning a fresh unique ID and resetting its version to 1.
func (s *Store) Fork(id string, version int) (string, error) {
data, err := s.GetVersion(id, version)
if err != nil {
return "", err
}
newID := id + "-fork-" + fmt.Sprintf("%d", time.Now().UnixMilli())
updated, err := setXMLAttribute(data, "version", "1")
if err != nil {
return "", fmt.Errorf("reset version attribute: %w", err)
}
updated, err = setXMLAttribute(updated, "id", newID)
if err != nil {
return "", fmt.Errorf("stamp id attribute: %w", err)
}
updated, err = setXMLAttribute(updated, "tag", "")
if err != nil {
return "", fmt.Errorf("clear tag attribute: %w", err)
}
return newID, os.WriteFile(s.filePath(newID), updated, 0o644)
}
// readRoot parses the top-level interface attributes from the file at path.
func (s *Store) readRoot(path string) (rootAttrs, error) {
data, err := os.ReadFile(path)
if err != nil {
return rootAttrs{}, err
}
var root rootAttrs
if err := xml.Unmarshal(data, &root); err != nil {
return rootAttrs{}, err
}
return root, nil
}
// setXMLAttribute is a primitive helper to update a top-level attribute in an
// XML blob without full unmarshal/marshal (which might mess up formatting).
func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
// Very basic implementation: look for the attribute in the first 512 bytes.
// A proper implementation would use an XML encoder or a better regex.
s := string(data)
tagEnd := strings.Index(s, ">")
if tagEnd == -1 {
// Locate the root element's opening tag, skipping any XML declaration
// (<?xml ... ?>), comments, or processing instructions. Operating on the
// first '>' in the document would match the '?>' of the XML declaration and
// corrupt its version attribute (producing invalid XML).
tagStart := -1
for i := 0; i+1 < len(s); i++ {
if s[i] == '<' && s[i+1] != '?' && s[i+1] != '!' {
tagStart = i
break
}
}
if tagStart == -1 {
return nil, errors.New("malformed XML: no root element")
}
rel := strings.Index(s[tagStart:], ">")
if rel == -1 {
return nil, errors.New("malformed XML: no root tag end")
}
rootTag := s[:tagEnd]
tagEnd := tagStart + rel
rootTag := s[tagStart:tagEnd]
attrSearch := " " + attr + "=\""
idx := strings.Index(rootTag, attrSearch)
@@ -240,8 +465,8 @@ func setXMLAttribute(data []byte, attr, value string) ([]byte, error) {
return []byte(s[:insertIdx] + fmt.Sprintf(" %s=\"%s\"", attr, value) + s[insertIdx:]), nil
}
// Attribute found, replace its value.
valStart := idx + len(attrSearch)
// Attribute found, replace its value (idx is relative to rootTag).
valStart := tagStart + idx + len(attrSearch)
valEnd := strings.Index(s[valStart:], "\"")
if valEnd == -1 {
return nil, errors.New("malformed XML: attribute value not closed")
+11 -11
View File
@@ -35,7 +35,7 @@ func TestListEmpty(t *testing.T) {
func TestListAfterCreate(t *testing.T) {
s := newStore(t)
if _, err := s.Create([]byte(sampleXML)); err != nil {
if _, err := s.Create([]byte(sampleXML), ""); err != nil {
t.Fatalf("Create: %v", err)
}
list, err := s.List()
@@ -59,7 +59,7 @@ func TestListAfterCreate(t *testing.T) {
func TestCreateAndGet(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -78,7 +78,7 @@ func TestCreateAndGet(t *testing.T) {
func TestCreateUsesEmbeddedID(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -90,11 +90,11 @@ func TestCreateUsesEmbeddedID(t *testing.T) {
func TestCreateDuplicateGetsUniqueID(t *testing.T) {
s := newStore(t)
id1, err := s.Create([]byte(sampleXML))
id1, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("first Create: %v", err)
}
id2, err := s.Create([]byte(sampleXML))
id2, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("second Create: %v", err)
}
@@ -105,7 +105,7 @@ func TestCreateDuplicateGetsUniqueID(t *testing.T) {
func TestCreateInvalidXML(t *testing.T) {
s := newStore(t)
_, err := s.Create([]byte("not xml"))
_, err := s.Create([]byte("not xml"), "")
if err == nil {
t.Error("expected error for invalid XML")
}
@@ -145,13 +145,13 @@ func TestGetEmptyID(t *testing.T) {
func TestUpdate(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
updated := `<interface id="test-if" name="Updated" version="2"><widget/></interface>`
if err := s.Update(id, []byte(updated)); err != nil {
if err := s.Update(id, []byte(updated), ""); err != nil {
t.Fatalf("Update: %v", err)
}
@@ -166,7 +166,7 @@ func TestUpdate(t *testing.T) {
func TestUpdateNotFound(t *testing.T) {
s := newStore(t)
err := s.Update("no-such-id", []byte(sampleXML))
err := s.Update("no-such-id", []byte(sampleXML), "")
if !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Update missing: want ErrNotFound, got %v", err)
}
@@ -178,7 +178,7 @@ func TestUpdateNotFound(t *testing.T) {
func TestDelete(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
@@ -205,7 +205,7 @@ func TestDeleteNotFound(t *testing.T) {
func TestClone(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(sampleXML))
id, err := s.Create([]byte(sampleXML), "")
if err != nil {
t.Fatalf("Create: %v", err)
}