From 094f128e7f23dcd420162b34e8c2680588c2f719 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 7 Oct 2026 14:43:18 +0300 Subject: [PATCH 1/2] feat(notifications): add actor fields to NewRuntimeIncident Carry the Kubernetes user (name and groups) of admission incidents such as exec to pod, so alert channels (Slack, Teams, etc.) can show who triggered the incident. JSON tags match kubescape/messaging RuntimeIncidentIngesterOnFinishedMessage (v0.0.114), which users-notification-service passes through as the notification data. Signed-off-by: Ben --- docs/features/runtime-incident-actor.md | 66 +++++++++++++++++++++++++ notifications/runtimeincidents.go | 4 ++ notifications/runtimeincidents_test.go | 36 ++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 docs/features/runtime-incident-actor.md create mode 100644 notifications/runtimeincidents_test.go diff --git a/docs/features/runtime-incident-actor.md b/docs/features/runtime-incident-actor.md new file mode 100644 index 00000000..de46c6f1 --- /dev/null +++ b/docs/features/runtime-incident-actor.md @@ -0,0 +1,66 @@ +--- +type: reality +status: active +owner: slashben +scope: repo +related_code: + - notifications/runtimeincidents.go + - notifications/runtimeincidents_test.go +--- + +# Runtime Incident Actor + +Lets runtime incident notifications (Slack, Teams, Jira, webhooks and the other +alert channels) show who triggered an incident, for example the Kubernetes user +who ran "Exec to pod". This library change defines the notification contract +only. Populating it lives in event-ingester-service and rendering it in +users-notification-service, both in armosec/backend. + +## The problem + +Admission alerts already record the requesting Kubernetes user. +`armotypes.AdmissionAlert.UserInfo` holds the username and groups, and the +backend's stored runtime incident embeds `armotypes.RuntimeAlert`, so it has +the user too. The notification payload had no field for it, so the user was +dropped before any alert channel could show it. + +## What this adds + +Two fields on `NewRuntimeIncident`: + +```go +UserName string `json:"userName,omitempty"` +UserGroups []string `json:"userGroups,omitempty"` +``` + +Nothing existing changed. No field was renamed or removed. + +## Why the JSON tags must match kubescape/messaging + +event-ingester-service sends `RuntimeIncidentIngesterOnFinishedMessage` from +`github.com/kubescape/messaging/pulsar/common/kdr`, which gained the same two +fields with the same tags in v0.0.114. users-notification-service does not copy +that message field by field into `NewRuntimeIncident`. It passes the whole +message through as the notification data, which is decoded as JSON. The tags are +therefore the contract: a renamed tag on either side silently drops the actor. +`TestNewRuntimeIncidentActorJSON` decodes a payload in the messaging shape and +pins the tags. + +## Why omitempty + +Only admission alerts have a Kubernetes user. Node-agent rules (process, file, +network) and CDR alerts leave both fields empty, and `omitempty` keeps those +payloads byte-identical to before. `TestNewRuntimeIncidentActorOmittedWhenEmpty` +pins this. Templates should render the actor row only when `UserName` is set. + +## No bson tags + +`AGENTS.md` asks for `json` and `bson` tags together. `NewRuntimeIncident` is a +notification payload that is never stored in MongoDB, and none of its existing +fields carry `bson` tags, so the new fields follow the struct and use `json` +only. + +## What this does not cover + +Node-agent and CDR alerts have no Kubernetes user. An actor for those (process +or UID on the node, cloud identity for CDR) would be a separate change. diff --git a/notifications/runtimeincidents.go b/notifications/runtimeincidents.go index 0ee5b511..7d884bb5 100644 --- a/notifications/runtimeincidents.go +++ b/notifications/runtimeincidents.go @@ -22,6 +22,10 @@ type NewRuntimeIncident struct { ReportTime time.Time `json:"reportTime"` PolicyGUIDToName map[string]string `json:"policyGUIDToName,omitempty"` Classification string `json:"classification,omitempty"` + // UserName and UserGroups identify the Kubernetes user behind the incident + // (admission alerts only, e.g. exec to pod) + UserName string `json:"userName,omitempty"` + UserGroups []string `json:"userGroups,omitempty"` } type RuntimeIncidentResponse struct { diff --git a/notifications/runtimeincidents_test.go b/notifications/runtimeincidents_test.go new file mode 100644 index 00000000..74029bab --- /dev/null +++ b/notifications/runtimeincidents_test.go @@ -0,0 +1,36 @@ +package notifications + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestNewRuntimeIncidentActorJSON(t *testing.T) { + // payload shape produced by kubescape/messaging RuntimeIncidentIngesterOnFinishedMessage + payload := []byte(`{"incidentGUID":"i1","userName":"alice@example.com","userGroups":["system:authenticated","devs"]}`) + + var incident NewRuntimeIncident + if err := json.Unmarshal(payload, &incident); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if incident.UserName != "alice@example.com" { + t.Errorf("UserName = %q, want %q", incident.UserName, "alice@example.com") + } + if want := []string{"system:authenticated", "devs"}; !reflect.DeepEqual(incident.UserGroups, want) { + t.Errorf("UserGroups = %v, want %v", incident.UserGroups, want) + } +} + +func TestNewRuntimeIncidentActorOmittedWhenEmpty(t *testing.T) { + data, err := json.Marshal(NewRuntimeIncident{IncidentGUID: "i1"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, key := range []string{`"userName"`, `"userGroups"`} { + if strings.Contains(string(data), key) { + t.Errorf("expected %s to be omitted, got %s", key, data) + } + } +} From 772f2874966fada35dda4d560bf14bea1e6be0db Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 7 Oct 2026 17:13:32 +0300 Subject: [PATCH 2/2] feat(notifications): add bson tags to NewRuntimeIncident actor fields Keep json and bson tag names aligned per AGENTS.md, pin them with a bson round-trip test, and describe the tags in the feature doc. Signed-off-by: Ben --- docs/features/runtime-incident-actor.md | 14 ++++++------- notifications/runtimeincidents.go | 4 ++-- notifications/runtimeincidents_test.go | 27 +++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/features/runtime-incident-actor.md b/docs/features/runtime-incident-actor.md index de46c6f1..9b3f301e 100644 --- a/docs/features/runtime-incident-actor.md +++ b/docs/features/runtime-incident-actor.md @@ -29,8 +29,8 @@ dropped before any alert channel could show it. Two fields on `NewRuntimeIncident`: ```go -UserName string `json:"userName,omitempty"` -UserGroups []string `json:"userGroups,omitempty"` +UserName string `json:"userName,omitempty" bson:"userName,omitempty"` +UserGroups []string `json:"userGroups,omitempty" bson:"userGroups,omitempty"` ``` Nothing existing changed. No field was renamed or removed. @@ -53,12 +53,12 @@ network) and CDR alerts leave both fields empty, and `omitempty` keeps those payloads byte-identical to before. `TestNewRuntimeIncidentActorOmittedWhenEmpty` pins this. Templates should render the actor row only when `UserName` is set. -## No bson tags +## bson tags -`AGENTS.md` asks for `json` and `bson` tags together. `NewRuntimeIncident` is a -notification payload that is never stored in MongoDB, and none of its existing -fields carry `bson` tags, so the new fields follow the struct and use `json` -only. +Both fields carry `bson` tags with the same names as their `json` tags, per +`AGENTS.md`. The notification travels as JSON today, but matching names keep +the actor readable under the same keys if the payload is ever stored in +MongoDB. `TestNewRuntimeIncidentActorBSONRoundTrip` pins the bson names. ## What this does not cover diff --git a/notifications/runtimeincidents.go b/notifications/runtimeincidents.go index 7d884bb5..d2cb4448 100644 --- a/notifications/runtimeincidents.go +++ b/notifications/runtimeincidents.go @@ -24,8 +24,8 @@ type NewRuntimeIncident struct { Classification string `json:"classification,omitempty"` // UserName and UserGroups identify the Kubernetes user behind the incident // (admission alerts only, e.g. exec to pod) - UserName string `json:"userName,omitempty"` - UserGroups []string `json:"userGroups,omitempty"` + UserName string `json:"userName,omitempty" bson:"userName,omitempty"` + UserGroups []string `json:"userGroups,omitempty" bson:"userGroups,omitempty"` } type RuntimeIncidentResponse struct { diff --git a/notifications/runtimeincidents_test.go b/notifications/runtimeincidents_test.go index 74029bab..082ddf85 100644 --- a/notifications/runtimeincidents_test.go +++ b/notifications/runtimeincidents_test.go @@ -5,6 +5,8 @@ import ( "reflect" "strings" "testing" + + "go.mongodb.org/mongo-driver/bson" ) func TestNewRuntimeIncidentActorJSON(t *testing.T) { @@ -34,3 +36,28 @@ func TestNewRuntimeIncidentActorOmittedWhenEmpty(t *testing.T) { } } } + +func TestNewRuntimeIncidentActorBSONRoundTrip(t *testing.T) { + in := NewRuntimeIncident{UserName: "alice@example.com", UserGroups: []string{"devs"}} + data, err := bson.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw bson.M + if err := bson.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + if raw["userName"] != "alice@example.com" { + t.Errorf(`bson key "userName" = %v, want %q`, raw["userName"], "alice@example.com") + } + if _, ok := raw["userGroups"]; !ok { + t.Errorf(`bson key "userGroups" missing in %v`, raw) + } + var out NewRuntimeIncident + if err := bson.Unmarshal(data, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.UserName != in.UserName || !reflect.DeepEqual(out.UserGroups, in.UserGroups) { + t.Errorf("round trip = %+v, want %+v", out, in) + } +}