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
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)]
+22
View File
@@ -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
}
+2
View File
@@ -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,
})
}
+9 -2
View File
@@ -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:
+10 -3
View File
@@ -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);
}
/*
+2
View File
@@ -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),
}
}
+2
View File
@@ -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
}
+23
View File
@@ -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:
+4
View File
@@ -53,6 +53,8 @@ type outMsg struct {
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"`
@@ -196,6 +198,8 @@ func (c *wsClient) dispatchLoop(ctx context.Context) {
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 {
+3
View File
@@ -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)
+2
View File
@@ -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;