Working on audit

This commit is contained in:
Martino Ferrari
2026-06-19 09:44:57 +02:00
parent 914108e575
commit 8f6dbcba49
11 changed files with 122 additions and 17 deletions
+32 -1
View File
@@ -61,18 +61,20 @@ type Policy struct {
userGroups map[string][]string // user → groups they belong to userGroups map[string][]string // user → groups they belong to
groupNames []string // all configured group names (sorted) groupNames []string // all configured group names (sorted)
logicEditors map[string]bool // users + group names allowed to edit logic 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; // New builds a Policy. blacklist maps a username to a config level string;
// groups maps a group name to its member usernames. logicEditors optionally // groups maps a group name to its member usernames. logicEditors optionally
// restricts who may edit panel/control logic (usernames or group names); empty // restricts who may edit panel/control logic (usernames or group names); empty
// means no restriction. // 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{ p := &Policy{
defaultUser: strings.TrimSpace(defaultUser), defaultUser: strings.TrimSpace(defaultUser),
blacklist: make(map[string]Level), blacklist: make(map[string]Level),
userGroups: make(map[string][]string), userGroups: make(map[string][]string),
logicEditors: make(map[string]bool), logicEditors: make(map[string]bool),
auditReaders: make(map[string]bool),
} }
for _, e := range logicEditors { for _, e := range logicEditors {
e = strings.TrimSpace(e) e = strings.TrimSpace(e)
@@ -80,6 +82,12 @@ func New(defaultUser string, blacklist map[string]string, groups map[string][]st
p.logicEditors[e] = true p.logicEditors[e] = true
} }
} }
for _, e := range auditReaders {
e = strings.TrimSpace(e)
if e != "" {
p.auditReaders[e] = true
}
}
for user, lvl := range blacklist { for user, lvl := range blacklist {
u := strings.TrimSpace(user) u := strings.TrimSpace(user)
if u == "" { if u == "" {
@@ -165,6 +173,29 @@ func (p *Policy) CanEditLogic(user string) bool {
return false 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. // GroupsOf returns a copy of the groups a user belongs to.
func (p *Policy) GroupsOf(user string) []string { func (p *Policy) GroupsOf(user string) []string {
src := p.userGroups[strings.TrimSpace(user)] src := p.userGroups[strings.TrimSpace(user)]
+22
View File
@@ -12,10 +12,23 @@ import (
type Config struct { type Config struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"` Datasource DatasourceConfig `toml:"datasource"`
Audit AuditConfig `toml:"audit"`
// Groups are named sets of users, referenced by panel sharing rules. // Groups are named sets of users, referenced by panel sharing rules.
Groups []GroupDef `toml:"groups"` 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. // GroupDef is a named set of users defined as [[groups]] in the config file.
type GroupDef struct { type GroupDef struct {
Name string `toml:"name"` Name string `toml:"name"`
@@ -141,6 +154,15 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" { if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
cfg.Server.LogicEditors = strings.Fields(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 != "" { if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v cfg.Datasource.EPICS.CAAddrList = v
} }
+2
View File
@@ -112,6 +112,8 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
Timestamp: ts, Timestamp: ts,
Data: data, Data: data,
Quality: q, Quality: q,
Severity: p.Severity,
Status: p.Status,
}) })
} }
+9 -2
View File
@@ -22,7 +22,7 @@ import (
// //
//export goCAMonitorCallback //export goCAMonitorCallback
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long, 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) h := uintptr(handle)
@@ -79,7 +79,13 @@ func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
data = float64(0) 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 // 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. // 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(), Timestamp: time.Now(),
Data: float64(0), Data: float64(0),
Quality: datasource.QualityBad, Quality: datasource.QualityBad,
Severity: 3, // INVALID
} }
select { select {
case ch <- v: case ch <- v:
+10 -3
View File
@@ -13,7 +13,7 @@
* goCAConnectionCallback is called when a channel connects or disconnects. * goCAConnectionCallback is called when a channel connects or disconnects.
*/ */
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count, extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
void *dbr, int severity, void *dbr, int severity, int status,
double epicsTimeSecs); double epicsTimeSecs);
extern void goCAConnectionCallback(uintptr_t handle, int connected); extern void goCAConnectionCallback(uintptr_t handle, int connected);
@@ -30,9 +30,10 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
double timeSecs = 0.0; double timeSecs = 0.0;
int severity = 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 * 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.). * 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; const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_FLOAT: { case DBR_TIME_FLOAT: {
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr; const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_LONG: { case DBR_TIME_LONG: {
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr; const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_SHORT: { case DBR_TIME_SHORT: {
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr; const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_STRING: { case DBR_TIME_STRING: {
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr; const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
case DBR_TIME_ENUM: { case DBR_TIME_ENUM: {
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr; const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9; timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
severity = (int)p->severity; severity = (int)p->severity;
status = (int)p->status;
break; break;
} }
default: default:
@@ -78,7 +85,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
} }
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count, goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
(void *)args.dbr, severity, timeSecs); (void *)args.dbr, severity, status, timeSecs);
} }
/* /*
+2
View File
@@ -275,6 +275,8 @@ func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Valu
Timestamp: tv.Timestamp, Timestamp: tv.Timestamp,
Data: data, Data: data,
Quality: quality, Quality: quality,
Severity: int(tv.Severity),
Status: int(tv.Status),
} }
} }
+2
View File
@@ -45,6 +45,8 @@ type Value struct {
Timestamp time.Time Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool Data any // float64 | []float64 | string | int64 | bool
Quality Quality 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 MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
} }
+23
View File
@@ -180,6 +180,7 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
val := fieldByName(sv, "value") val := fieldByName(sv, "value")
ts := extractTimestamp(sv) ts := extractTimestamp(sv)
quality := extractQuality(sv) quality := extractQuality(sv)
severity, status := extractSeverityStatus(sv)
var data any var data any
if val != nil { if val != nil {
@@ -190,6 +191,8 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
Timestamp: ts, Timestamp: ts,
Data: data, Data: data,
Quality: quality, 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) { func toFloat64(v any) (float64, bool) {
switch x := v.(type) { switch x := v.(type) {
case float64: case float64:
+4
View File
@@ -53,6 +53,8 @@ type outMsg struct {
TS string `json:"ts,omitempty"` TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"` Value any `json:"value,omitempty"`
Quality string `json:"quality,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
Meta *metaPayload `json:"meta,omitempty"` Meta *metaPayload `json:"meta,omitempty"`
@@ -196,6 +198,8 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano), TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data, Value: u.Value.Data,
Quality: u.Value.Quality.String(), Quality: u.Value.Quality.String(),
Severity: u.Value.Severity,
Status: u.Value.Status,
} }
b, err := json.Marshal(msg) b, err := json.Marshal(msg)
if err != nil { if err != nil {
+3
View File
@@ -9,6 +9,9 @@ export interface SignalValue {
value: any; value: any;
quality: 'good' | 'uncertain' | 'bad' | 'unknown'; quality: 'good' | 'uncertain' | 'bad' | 'unknown';
ts: string | null; 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) // Metadata for a signal (received once on subscribe)
+2
View File
@@ -130,6 +130,8 @@ class WsClient {
value: msg.value, value: msg.value,
quality: msg.quality ?? 'unknown', quality: msg.quality ?? 'unknown',
ts: msg.ts ?? null, ts: msg.ts ?? null,
severity: msg.severity ?? 0,
status: msg.status ?? 0,
}; };
for (const s of subs) s.onUpdate(val); for (const s of subs) s.onUpdate(val);
break; break;