From 8f6dbcba495d80598f180787b4aedb95872dcb8b Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Fri, 19 Jun 2026 09:44:57 +0200 Subject: [PATCH] Working on audit --- internal/access/access.go | 33 +++++++++++++++++++++++- internal/config/config.go | 22 ++++++++++++++++ internal/datasource/epics/archive.go | 2 ++ internal/datasource/epics/ca_callback.go | 11 ++++++-- internal/datasource/epics/ca_wrapper.h | 13 +++++++--- internal/datasource/epics/noop.go | 2 ++ internal/datasource/iface.go | 2 ++ internal/datasource/pva/pva.go | 23 +++++++++++++++++ internal/server/ws.go | 26 +++++++++++-------- web/src/lib/types.ts | 3 +++ web/src/lib/ws.ts | 2 ++ 11 files changed, 122 insertions(+), 17 deletions(-) diff --git a/internal/access/access.go b/internal/access/access.go index ac4c923..1fd7c82 100644 --- a/internal/access/access.go +++ b/internal/access/access.go @@ -61,18 +61,20 @@ type Policy struct { userGroups map[string][]string // user → groups they belong to groupNames []string // all configured group names (sorted) logicEditors map[string]bool // users + group names allowed to edit logic + auditReaders map[string]bool // users + group names allowed to view the audit log } // New builds a Policy. blacklist maps a username to a config level string; // groups maps a group name to its member usernames. logicEditors optionally // restricts who may edit panel/control logic (usernames or group names); empty // means no restriction. -func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy { +func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *Policy { p := &Policy{ defaultUser: strings.TrimSpace(defaultUser), blacklist: make(map[string]Level), userGroups: make(map[string][]string), logicEditors: make(map[string]bool), + auditReaders: make(map[string]bool), } for _, e := range logicEditors { e = strings.TrimSpace(e) @@ -80,6 +82,12 @@ func New(defaultUser string, blacklist map[string]string, groups map[string][]st p.logicEditors[e] = true } } + for _, e := range auditReaders { + e = strings.TrimSpace(e) + if e != "" { + p.auditReaders[e] = true + } + } for user, lvl := range blacklist { u := strings.TrimSpace(user) if u == "" { @@ -165,6 +173,29 @@ func (p *Policy) CanEditLogic(user string) bool { return false } +// CanViewAudit reports whether a user may view the audit log. When no reader +// allowlist is configured everyone with read access qualifies; otherwise the +// user (or one of their groups) must be listed. Anonymous/trusted-LAN callers +// (user=="") are always permitted. +func (p *Policy) CanViewAudit(user string) bool { + user = strings.TrimSpace(user) + if user == "" { + return true + } + if len(p.auditReaders) == 0 { + return true + } + if p.auditReaders[user] { + return true + } + for _, g := range p.userGroups[user] { + if p.auditReaders[g] { + return true + } + } + return false +} + // GroupsOf returns a copy of the groups a user belongs to. func (p *Policy) GroupsOf(user string) []string { src := p.userGroups[strings.TrimSpace(user)] diff --git a/internal/config/config.go b/internal/config/config.go index 2ef06f9..09d67c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,10 +12,23 @@ import ( type Config struct { Server ServerConfig `toml:"server"` Datasource DatasourceConfig `toml:"datasource"` + Audit AuditConfig `toml:"audit"` // Groups are named sets of users, referenced by panel sharing rules. Groups []GroupDef `toml:"groups"` } +// AuditConfig controls the audit trail. When enabled, every user and automated +// action that could affect the controlled system (signal writes, control-logic +// changes) is recorded to a SQLite database for later review by audit staff. +type AuditConfig struct { + Enabled bool `toml:"enabled"` + DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db + // Readers lists users or group names allowed to view the audit log. Empty + // leaves viewing unrestricted (any caller with read access). Anonymous / + // trusted-LAN callers are always permitted. + Readers []string `toml:"readers"` +} + // GroupDef is a named set of users defined as [[groups]] in the config file. type GroupDef struct { Name string `toml:"name"` @@ -141,6 +154,15 @@ func applyEnv(cfg *Config) { if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" { cfg.Server.LogicEditors = strings.Fields(v) } + if v := env("UOPI_AUDIT_ENABLED"); v != "" { + cfg.Audit.Enabled = (v == "true" || v == "YES") + } + if v := env("UOPI_AUDIT_DB_PATH"); v != "" { + cfg.Audit.DBPath = v + } + if v := env("UOPI_AUDIT_READERS"); v != "" { + cfg.Audit.Readers = strings.Fields(v) + } if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" { cfg.Datasource.EPICS.CAAddrList = v } diff --git a/internal/datasource/epics/archive.go b/internal/datasource/epics/archive.go index 409556d..2fbc1b6 100644 --- a/internal/datasource/epics/archive.go +++ b/internal/datasource/epics/archive.go @@ -112,6 +112,8 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start, Timestamp: ts, Data: data, Quality: q, + Severity: p.Severity, + Status: p.Status, }) } diff --git a/internal/datasource/epics/ca_callback.go b/internal/datasource/epics/ca_callback.go index 88c25a8..7d6956f 100644 --- a/internal/datasource/epics/ca_callback.go +++ b/internal/datasource/epics/ca_callback.go @@ -22,7 +22,7 @@ import ( // //export goCAMonitorCallback func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long, - dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) { + dbr unsafe.Pointer, severity C.int, status C.int, epicsTimeSecs C.double) { h := uintptr(handle) @@ -79,7 +79,13 @@ func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long, data = float64(0) } - v := datasource.Value{Timestamp: ts, Data: data, Quality: q} + v := datasource.Value{ + Timestamp: ts, + Data: data, + Quality: q, + Severity: int(severity), + Status: int(status), + } // Look up the subscription channel under the global handle table lock and // perform a non-blocking send so we never stall the CA callback thread. @@ -129,6 +135,7 @@ func goCAConnectionCallback(handle C.uintptr_t, connected C.int) { Timestamp: time.Now(), Data: float64(0), Quality: datasource.QualityBad, + Severity: 3, // INVALID } select { case ch <- v: diff --git a/internal/datasource/epics/ca_wrapper.h b/internal/datasource/epics/ca_wrapper.h index 830870e..bc78800 100644 --- a/internal/datasource/epics/ca_wrapper.h +++ b/internal/datasource/epics/ca_wrapper.h @@ -13,7 +13,7 @@ * goCAConnectionCallback is called when a channel connects or disconnects. */ extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count, - void *dbr, int severity, + void *dbr, int severity, int status, double epicsTimeSecs); extern void goCAConnectionCallback(uintptr_t handle, int connected); @@ -30,9 +30,10 @@ static void caMonitorCallbackShim(struct event_handler_args args) { double timeSecs = 0.0; int severity = 0; + int status = 0; /* - * Determine the timestamp and alarm severity from the DBR type. + * Determine the timestamp and alarm severity/status from the DBR type. * We request DBR_TIME_* types so the timestamp is embedded in the value * buffer right after the alarm fields (struct dbr_time_double et al.). */ @@ -41,36 +42,42 @@ static void caMonitorCallbackShim(struct event_handler_args args) { const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; severity = (int)p->severity; + status = (int)p->status; break; } case DBR_TIME_FLOAT: { const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; severity = (int)p->severity; + status = (int)p->status; break; } case DBR_TIME_LONG: { const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; severity = (int)p->severity; + status = (int)p->status; break; } case DBR_TIME_SHORT: { const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; severity = (int)p->severity; + status = (int)p->status; break; } case DBR_TIME_STRING: { const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; severity = (int)p->severity; + status = (int)p->status; break; } case DBR_TIME_ENUM: { const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; severity = (int)p->severity; + status = (int)p->status; break; } default: @@ -78,7 +85,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) { } goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count, - (void *)args.dbr, severity, timeSecs); + (void *)args.dbr, severity, status, timeSecs); } /* diff --git a/internal/datasource/epics/noop.go b/internal/datasource/epics/noop.go index 69ebe54..a34d15d 100644 --- a/internal/datasource/epics/noop.go +++ b/internal/datasource/epics/noop.go @@ -275,6 +275,8 @@ func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Valu Timestamp: tv.Timestamp, Data: data, Quality: quality, + Severity: int(tv.Severity), + Status: int(tv.Status), } } diff --git a/internal/datasource/iface.go b/internal/datasource/iface.go index a61389f..53442a8 100644 --- a/internal/datasource/iface.go +++ b/internal/datasource/iface.go @@ -45,6 +45,8 @@ type Value struct { Timestamp time.Time Data any // float64 | []float64 | string | int64 | bool Quality Quality + Severity int // raw EPICS alarm severity (0=NO_ALARM,1=MINOR,2=MAJOR,3=INVALID); 0 for sources without alarm info + Status int // raw EPICS alarm status (.STAT); 0 for sources without alarm info MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta } diff --git a/internal/datasource/pva/pva.go b/internal/datasource/pva/pva.go index fddaf18..6650cdd 100644 --- a/internal/datasource/pva/pva.go +++ b/internal/datasource/pva/pva.go @@ -180,6 +180,7 @@ func structToValue(sv pvdata.StructValue) datasource.Value { val := fieldByName(sv, "value") ts := extractTimestamp(sv) quality := extractQuality(sv) + severity, status := extractSeverityStatus(sv) var data any if val != nil { @@ -190,6 +191,8 @@ func structToValue(sv pvdata.StructValue) datasource.Value { Timestamp: ts, Data: data, Quality: quality, + Severity: severity, + Status: status, } } @@ -329,6 +332,26 @@ func extractQuality(sv pvdata.StructValue) datasource.Quality { } } +// extractSeverityStatus pulls the raw EPICS alarm severity and status from the +// NTScalar "alarm" struct. Returns (0, 0) when no alarm struct is present. +func extractSeverityStatus(sv pvdata.StructValue) (severity, status int) { + alarm := structByName(sv, "alarm") + if alarm == nil { + return 0, 0 + } + if v := fieldByName(*alarm, "severity"); v != nil { + if s, ok := v.(int32); ok { + severity = int(s) + } + } + if v := fieldByName(*alarm, "status"); v != nil { + if s, ok := v.(int32); ok { + status = int(s) + } + } + return severity, status +} + func toFloat64(v any) (float64, bool) { switch x := v.(type) { case float64: diff --git a/internal/server/ws.go b/internal/server/ws.go index ff71e9e..3c440c3 100644 --- a/internal/server/ws.go +++ b/internal/server/ws.go @@ -48,11 +48,13 @@ type outMsg struct { Type string `json:"type"` // update - DS string `json:"ds,omitempty"` - Name string `json:"name,omitempty"` - TS string `json:"ts,omitempty"` - Value any `json:"value,omitempty"` - Quality string `json:"quality,omitempty"` + DS string `json:"ds,omitempty"` + Name string `json:"name,omitempty"` + TS string `json:"ts,omitempty"` + Value any `json:"value,omitempty"` + Quality string `json:"quality,omitempty"` + Severity int `json:"severity,omitempty"` // raw EPICS alarm severity (0=NO_ALARM) + Status int `json:"status,omitempty"` // raw EPICS alarm status // meta Meta *metaPayload `json:"meta,omitempty"` @@ -190,12 +192,14 @@ func (c *wsClient) dispatchLoop(ctx context.Context) { continue } msg := outMsg{ - Type: "update", - DS: u.Ref.DS, - Name: u.Ref.Name, - TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano), - Value: u.Value.Data, - Quality: u.Value.Quality.String(), + Type: "update", + DS: u.Ref.DS, + Name: u.Ref.Name, + TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano), + Value: u.Value.Data, + Quality: u.Value.Quality.String(), + Severity: u.Value.Severity, + Status: u.Value.Status, } b, err := json.Marshal(msg) if err != nil { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 730bc7a..50285f2 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -9,6 +9,9 @@ export interface SignalValue { value: any; quality: 'good' | 'uncertain' | 'bad' | 'unknown'; ts: string | null; + // Raw EPICS alarm fields (absent/0 = NO_ALARM). severity: 1=MINOR,2=MAJOR,3=INVALID. + severity?: number; + status?: number; } // Metadata for a signal (received once on subscribe) diff --git a/web/src/lib/ws.ts b/web/src/lib/ws.ts index b64646a..6e889c7 100644 --- a/web/src/lib/ws.ts +++ b/web/src/lib/ws.ts @@ -130,6 +130,8 @@ class WsClient { value: msg.value, quality: msg.quality ?? 'unknown', ts: msg.ts ?? null, + severity: msg.severity ?? 0, + status: msg.status ?? 0, }; for (const s of subs) s.onUpdate(val); break;