diff --git a/docs/features/runtime-incident-actor.md b/docs/features/runtime-incident-actor.md new file mode 100644 index 00000000..9b3f301e --- /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" bson:"userName,omitempty"` +UserGroups []string `json:"userGroups,omitempty" bson:"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. + +## bson tags + +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 + +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..d2cb4448 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" 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 new file mode 100644 index 00000000..082ddf85 --- /dev/null +++ b/notifications/runtimeincidents_test.go @@ -0,0 +1,63 @@ +package notifications + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "go.mongodb.org/mongo-driver/bson" +) + +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) + } + } +} + +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) + } +}