Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/features/runtime-incident-actor.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions notifications/runtimeincidents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
63 changes: 63 additions & 0 deletions notifications/runtimeincidents_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading