Working on audit
This commit is contained in:
@@ -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)]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user