Added ldap and pam authentication

This commit is contained in:
Martino Ferrari
2026-06-23 17:59:40 +02:00
parent ac24011487
commit 11120bedca
36 changed files with 1935 additions and 66 deletions
+59 -16
View File
@@ -44,12 +44,13 @@ type Handler struct {
audit audit.Recorder // never nil; audit.Nop when disabled
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
uiDefaultZoom float64 // base UI scale sent to clients; 0 means 1.0
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, uiDefaultZoom float64, log *slog.Logger) *Handler {
if rec == nil {
rec = audit.Nop()
}
@@ -65,6 +66,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg
audit: rec,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
uiDefaultZoom: uiDefaultZoom,
log: log,
}
}
@@ -192,6 +194,10 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
if groups == nil {
groups = []string{}
}
defaultZoom := h.uiDefaultZoom
if defaultZoom <= 0 {
defaultZoom = 1.0
}
jsonOK(w, map[string]any{
"user": user,
"level": h.policy.Level(user).String(),
@@ -199,6 +205,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
"canEditLogic": h.policy.CanEditLogic(user),
"canViewAudit": h.policy.CanViewAudit(user),
"canAdmin": h.policy.CanAdmin(user),
"defaultZoom": defaultZoom,
})
}
@@ -366,8 +373,9 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
if dsName == "synthetic" && h.synthetic != nil {
user := caller(r)
panel := r.URL.Query().Get("panel")
userGroups := h.policy.GroupsOf(user)
metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
return synVisible(d, user, panel)
return synVisible(d, user, panel, userGroups)
})
out := make([]signalInfo, len(metas))
for i, m := range metas {
@@ -398,12 +406,15 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
}
// 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 {
// caller (a member of userGroups) 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, userGroups []string) bool {
switch d.Visibility {
case "user":
return user != "" && d.Owner == user
case "group":
return access.CanSee(user, d.Owner, access.ScopeGroup, d.Groups, userGroups)
case "panel":
return panel != "" && d.Panel == panel
default: // "global" or legacy empty
@@ -431,13 +442,14 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
user := caller(r)
panel := r.URL.Query().Get("panel")
userGroups := h.policy.GroupsOf(user)
var out []signalInfo
for _, ds := range sources {
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)
return synVisible(d, user, panel, userGroups)
})
} else {
var err error
@@ -593,10 +605,33 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
// render sharing affordances and filter the visible set.
type interfaceListItem struct {
storage.InterfaceMeta
Owner string `json:"owner,omitempty"`
Folder string `json:"folder,omitempty"`
Order float64 `json:"order,omitempty"`
Perm string `json:"perm"`
Owner string `json:"owner,omitempty"`
Folder string `json:"folder,omitempty"`
Order float64 `json:"order,omitempty"`
Perm string `json:"perm"`
Scope string `json:"scope,omitempty"` // derived visibility bucket: private|group|global
Groups []string `json:"groups,omitempty"` // groups a group-scoped panel is shared with
}
// panelScope derives a uniform visibility token (and, for group scope, the
// shared group names) from a panel's ACL record so the selector tree can bucket
// it as Mine/Group/Global like the other scoped subsystems. An unmanaged panel
// (nil record) or any panel exposed publicly is global; otherwise a panel shared
// with one or more user-groups is group-scoped; everything else is private.
func panelScope(acl *panelacl.PanelACL) (string, []string) {
if acl == nil || acl.Public != "" {
return access.ScopeGlobal, nil
}
var groups []string
for _, g := range acl.Grants {
if g.Kind == "group" {
groups = append(groups, g.Name)
}
}
if len(groups) > 0 {
return access.ScopeGroup, groups
}
return access.ScopePrivate, nil
}
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
@@ -613,11 +648,13 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
continue // hide panels the caller cannot see
}
item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()}
if acl := h.acl.GetPanel(m.ID); acl != nil {
acl := h.acl.GetPanel(m.ID)
if acl != nil {
item.Owner = acl.Owner
item.Folder = acl.Folder
item.Order = acl.Order
}
item.Scope, item.Groups = panelScope(acl)
out = append(out, item)
}
h.log.Info("list interfaces", "count", len(out))
@@ -1548,14 +1585,17 @@ func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
// ── Control logic ──────────────────────────────────────────────────────────────
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) listControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonOK(w, []any{})
return
}
graphs := h.ctrlLogic.List()
if graphs == nil {
graphs = []controllogic.Graph{}
user := caller(r)
graphs := []controllogic.Graph{}
for _, g := range h.ctrlLogic.List() {
if h.policy.CanSee(user, g.Owner, g.Scope, g.ScopeGroups) {
graphs = append(graphs, g)
}
}
jsonOK(w, graphs)
}
@@ -1588,6 +1628,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
g.ID = genID("cl")
g.Owner = caller(r) // stamp ownership from the trusted identity
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
@@ -1608,7 +1649,8 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
id := r.PathValue("id")
if _, err := h.ctrlLogic.Get(id); err != nil {
prev, err := h.ctrlLogic.Get(id)
if err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
@@ -1618,6 +1660,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
g.ID = id
g.Owner = prev.Owner // owner is immutable across revisions
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
+2 -2
View File
@@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
mux := http.NewServeMux()
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1")
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {
@@ -485,7 +485,7 @@ func adminSetup(t *testing.T) (*httptest.Server, func()) {
}
inner := http.NewServeMux()
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", log).Register(inner, "/api/v1")
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(inner, "/api/v1")
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+23 -4
View File
@@ -37,7 +37,7 @@ func configStatus(err error) int {
// ── config sets ─────────────────────────────────────────────────────────────
func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) listConfigSets(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
@@ -46,7 +46,7 @@ func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, sets)
jsonOK(w, h.filterConfigMetas(r, sets))
}
func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) {
@@ -92,6 +92,9 @@ func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if prev, err := h.cfg.GetSet(id); err == nil {
set.Owner = prev.Owner // owner is immutable across revisions
}
out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
@@ -227,7 +230,7 @@ func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
// ── config instances ────────────────────────────────────────────────────────
func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) listConfigInstances(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
@@ -236,7 +239,20 @@ func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, insts)
jsonOK(w, h.filterConfigMetas(r, insts))
}
// filterConfigMetas drops entries the caller may not see per their scope
// (private/group/global). Owner always sees their own; empty scope = global.
func (h *Handler) filterConfigMetas(r *http.Request, metas []confmgr.Meta) []confmgr.Meta {
user := caller(r)
out := make([]confmgr.Meta, 0, len(metas))
for _, m := range metas {
if h.policy.CanSee(user, m.Owner, m.Scope, m.Groups) {
out = append(out, m)
}
}
return out
}
func (h *Handler) getConfigInstance(w http.ResponseWriter, r *http.Request) {
@@ -282,6 +298,9 @@ func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if prev, err := h.cfg.GetInstance(id); err == nil {
inst.Owner = prev.Owner // owner is immutable across revisions
}
out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())