Implementing more advanced feature: audit and more

This commit is contained in:
Martino Ferrari
2026-06-19 14:17:46 +02:00
parent 8f6dbcba49
commit 901b87d407
31 changed files with 1669 additions and 569 deletions
+100 -5
View File
@@ -8,12 +8,15 @@ import (
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
@@ -31,13 +34,18 @@ type Handler struct {
acl *panelacl.Store
ctrlLogic *controllogic.Store
ctrlEngine *controllogic.Engine
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
audit audit.Recorder // never nil; audit.Nop when disabled
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, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
if rec == nil {
rec = audit.Nop()
}
return &Handler{
broker: b,
synthetic: synth,
@@ -46,6 +54,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
acl: acl,
ctrlLogic: ctrlLogic,
ctrlEngine: ctrlEngine,
audit: rec,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
log: log,
@@ -56,6 +65,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
// 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+"/audit", h.getAudit)
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
@@ -118,15 +128,93 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
"level": h.policy.Level(user).String(),
"groups": groups,
"canEditLogic": h.policy.CanEditLogic(user),
"canViewAudit": h.policy.CanViewAudit(user),
})
}
// ── /audit ────────────────────────────────────────────────────────────────────
// getAudit returns audit-log entries matching the query filters. Access is
// restricted to users permitted by CanViewAudit (the audit-readers allowlist).
// Filters: start, end (RFC3339), user, action, ds, signal, limit.
func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) {
if !h.policy.CanViewAudit(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log")
return
}
q := r.URL.Query()
f := audit.Filter{
Actor: q.Get("user"),
Action: q.Get("action"),
DS: q.Get("ds"),
Signal: q.Get("signal"),
}
if v := q.Get("start"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v)
return
}
f.Start = t
}
if v := q.Get("end"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v)
return
}
f.End = t
}
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
f.Limit = n
}
}
events, err := h.audit.Query(f)
if err != nil {
h.log.Error("audit query", "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, events)
}
// ── 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()) }
// clientIP returns the best-effort client address for audit attribution,
// preferring the proxy-set X-Forwarded-For / X-Real-IP headers.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// recordMutation appends a successful configuration change to the audit log.
func (h *Handler) recordMutation(r *http.Request, action, detail string) {
h.audit.Record(audit.Event{
Actor: caller(r),
ActorType: audit.ActorUser,
Action: action,
Detail: detail,
IP: clientIP(r),
Outcome: audit.OutcomeOK,
})
}
// 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.
@@ -490,6 +578,7 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
h.log.Error("record panel ownership", "id", id, "err", err)
}
}
h.recordMutation(r, "interface.create", id)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
@@ -580,6 +669,7 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
}
return
}
h.recordMutation(r, "interface.update", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -721,6 +811,7 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
if err := h.acl.DeletePanel(id); err != nil {
h.log.Error("delete panel ACL", "id", id, "err", err)
}
h.recordMutation(r, "interface.delete", id)
w.WriteHeader(http.StatusNoContent)
}
@@ -1123,6 +1214,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, g)
}
@@ -1152,6 +1244,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
jsonOK(w, g)
}
@@ -1164,11 +1257,13 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
id := r.PathValue("id")
if err := h.ctrlLogic.Delete(id); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
h.ctrlEngine.Reload()
h.recordMutation(r, "controllogic.delete", id)
w.WriteHeader(http.StatusNoContent)
}