Major changes: logic add to panel, local variables, panel histor, users management...
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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].
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user