Implementing more advanced feature: audit and more
This commit is contained in:
@@ -5,7 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
@@ -96,6 +99,8 @@ type wsHandler struct {
|
||||
userHeader string
|
||||
// policy enforces the global access level (blacklist) on signal writes.
|
||||
policy *access.Policy
|
||||
// audit records signal writes (never nil; audit.Nop when disabled).
|
||||
audit audit.Recorder
|
||||
}
|
||||
|
||||
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -105,6 +110,12 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if h.userHeader != "" {
|
||||
user = strings.TrimSpace(r.Header.Get(h.userHeader))
|
||||
}
|
||||
// Resolve to the configured default_user when the header is absent so the
|
||||
// session carries a real identity (used for audit + EPICS write attribution).
|
||||
if h.policy != nil {
|
||||
user = h.policy.ResolveUser(user)
|
||||
}
|
||||
clientIP := clientIP(r)
|
||||
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
|
||||
@@ -120,11 +131,18 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
defer cancel()
|
||||
|
||||
rec := h.audit
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
|
||||
c := &wsClient{
|
||||
conn: conn,
|
||||
broker: h.broker,
|
||||
user: user,
|
||||
ip: clientIP,
|
||||
policy: h.policy,
|
||||
audit: rec,
|
||||
outCh: make(chan []byte, 512),
|
||||
updateCh: make(chan broker.Update, 1024),
|
||||
subs: make(map[broker.SignalRef]func()),
|
||||
@@ -155,7 +173,9 @@ type wsClient struct {
|
||||
broker *broker.Broker
|
||||
log *slog.Logger
|
||||
user string // end-user identity from the trusted proxy header ("" if none)
|
||||
ip string // client address, for audit attribution
|
||||
policy *access.Policy // global access-level enforcement
|
||||
audit audit.Recorder // signal-write audit recorder (never nil)
|
||||
|
||||
outCh chan []byte // serialised outgoing messages
|
||||
updateCh chan broker.Update // raw updates from the broker
|
||||
@@ -307,6 +327,14 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
return
|
||||
}
|
||||
|
||||
// Server variables are read-any but write only for control-logic editors, so a
|
||||
// panel cannot mutate sequence state unless its user owns control-logic access.
|
||||
if msg.DS == "srv" && c.policy != nil && !c.policy.CanEditLogic(c.policy.ResolveUser(c.user)) {
|
||||
c.log.Warn("write: server variable denied", "name", msg.Name, "user", c.user)
|
||||
c.sendError(ctx, "ACCESS_DENIED", "writing server variables requires control-logic access")
|
||||
return
|
||||
}
|
||||
|
||||
ds, ok := c.broker.Source(msg.DS)
|
||||
if !ok {
|
||||
c.log.Warn("write: unknown data source", "ds", msg.DS, "name", msg.Name)
|
||||
@@ -340,10 +368,25 @@ func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
|
||||
// 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)
|
||||
ev := audit.Event{
|
||||
Actor: c.user,
|
||||
ActorType: audit.ActorUser,
|
||||
Action: "signal.write",
|
||||
DS: msg.DS,
|
||||
Signal: msg.Name,
|
||||
Value: formatAuditValue(value),
|
||||
IP: c.ip,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
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)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
c.audit.Record(ev)
|
||||
c.sendError(ctx, "WRITE_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
c.audit.Record(ev)
|
||||
}
|
||||
|
||||
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
||||
@@ -420,6 +463,44 @@ func (c *wsClient) sendError(ctx context.Context, code, message string) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// clientIP returns the best-effort client address for audit attribution,
|
||||
// preferring the X-Forwarded-For / X-Real-IP headers set by a reverse proxy and
|
||||
// falling back to the TCP peer address.
|
||||
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
|
||||
}
|
||||
|
||||
// formatAuditValue renders a written value as a compact string for the audit log.
|
||||
func formatAuditValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'g', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(x)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
if b, err := json.Marshal(v); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func metadataToPayload(m datasource.Metadata) *metaPayload {
|
||||
return &metaPayload{
|
||||
Type: dataTypeName(m.Type),
|
||||
|
||||
Reference in New Issue
Block a user