Stop 500ing when a service account acts on an incident

Every incident-mutation handler read userFromContext(ctx) and wrote the
result's .ID into acknowledged_by/incident_events.user_id without checking
the ok bool. For a team-scoped service-account caller this returned a
zero-value user id, which violated the users(id) FK and 500'd on
acknowledge, unacknowledge, resolve, snooze, unsnooze and create-note.
handleDeleteNote didn't crash but silently matched zero rows instead
(WHERE user_id = 0), so a service account could never delete its own note.

Add acknowledged_by_service_account_id (incidents) and service_account_id
(incident_events) as nullable FKs to service_accounts(id), parallel to and
mutually exclusive with the existing human columns (migration 015, with a
CHECK enforcing the exclusion). Route every one of the six handlers plus
delete-note through a new callerActorIDs() helper that branches on
Caller.AsHuman()/ServiceAccountID() instead of assuming a human, and thread
a serviceAccountID parameter through logEvent and the new
acknowledgeIncidentAs (acknowledgeIncident itself is untouched: its only
other caller, the push-notification Acknowledge button, is always human).
Render the new actor distinctly from both a human and "the server acted"
in the web UI's incident timeline and facts card.

handleIncidentAssign, handleIncidentArchive and handleIncidentUnarchive are
deliberately not touched here — they track no actor at all today, for
anyone, which is a separate pre-existing gap (follow-up issue to come).

Fixes #25
This commit is contained in:
Niklas Ye
2026-10-07 21:09:31 +02:00
parent 2b2609e98f
commit 9da913080f
12 changed files with 301 additions and 72 deletions
+34 -24
View File
@@ -157,9 +157,11 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
rows, err := db.QueryContext(r.Context(), `
SELECT e.id, e.incident_id, e.type, e.user_id, u.username,
e.service_account_id, sa.name,
e.alert_id, e.detail, e.created_at
FROM incident_events e
LEFT JOIN users u ON u.id = e.user_id
LEFT JOIN service_accounts sa ON sa.id = e.service_account_id
WHERE e.incident_id = $1
ORDER BY e.created_at ASC, e.id ASC`, id)
if err != nil {
@@ -173,6 +175,7 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
var e models.IncidentEvent
var ts int64
if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username,
&e.ServiceAccountID, &e.ServiceAccountName,
&e.AlertID, &e.Detail, &ts); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -190,8 +193,8 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
if !ok {
return
}
user, _ := userFromContext(r.Context())
acked, err := acknowledgeIncident(r.Context(), db, id, user.ID)
userID, saID := callerActorIDs(r.Context())
acked, err := acknowledgeIncidentAs(r.Context(), db, id, userID, saID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -223,13 +226,14 @@ func handleIncidentUnacknowledge(db *sql.DB) http.HandlerFunc {
if !ok {
return
}
user, _ := userFromContext(r.Context())
userID, saID := callerActorIDs(r.Context())
if !updateOpenIncident(w, r, db, id,
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL, acknowledged_at = NULL
`UPDATE incidents SET status = 'triggered', acknowledged_by = NULL,
acknowledged_by_service_account_id = NULL, acknowledged_at = NULL
WHERE id = $1 AND resolved_at IS NULL`, id) {
return
}
if err := logEvent(r.Context(), db, id, evUnacknowledged, &user.ID, nil, nil); err != nil {
if err := logEvent(r.Context(), db, id, evUnacknowledged, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -247,7 +251,7 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
if !ok {
return
}
user, _ := userFromContext(r.Context())
userID, saID := callerActorIDs(r.Context())
// The body is optional: clients that predate resolution notes send none.
var req struct {
Resolution string `json:"resolution"`
@@ -268,12 +272,12 @@ func handleIncidentResolve(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if err := logEvent(r.Context(), db, id, evResolved, &user.ID, nil, nil); err != nil {
if err := logEvent(r.Context(), db, id, evResolved, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if req.Resolution != "" {
if err := logEvent(r.Context(), db, id, evResolutionNote, &user.ID, nil, &req.Resolution); err != nil {
if err := logEvent(r.Context(), db, id, evResolutionNote, userID, saID, nil, &req.Resolution); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -312,7 +316,7 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
return
}
// On an "assigned" event user_id is the assignee, not the actor.
if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil); err != nil {
if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -363,14 +367,14 @@ func handleIncidentSnooze(db *sql.DB) http.HandlerFunc {
return
}
user, _ := userFromContext(r.Context())
userID, saID := callerActorIDs(r.Context())
if !updateOpenIncident(w, r, db, id,
"UPDATE incidents SET snoozed_until = $1 WHERE id = $2 AND resolved_at IS NULL",
until.Unix(), id) {
return
}
detail := until.UTC().Format(time.RFC3339)
if err := logEvent(r.Context(), db, id, evSnoozed, &user.ID, nil, &detail); err != nil {
if err := logEvent(r.Context(), db, id, evSnoozed, userID, saID, nil, &detail); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -384,12 +388,12 @@ func handleIncidentUnsnooze(db *sql.DB) http.HandlerFunc {
if !ok {
return
}
user, _ := userFromContext(r.Context())
userID, saID := callerActorIDs(r.Context())
if !updateOpenIncident(w, r, db, id,
"UPDATE incidents SET snoozed_until = NULL WHERE id = $1 AND resolved_at IS NULL", id) {
return
}
if err := logEvent(r.Context(), db, id, evUnsnoozed, &user.ID, nil, nil); err != nil {
if err := logEvent(r.Context(), db, id, evUnsnoozed, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -466,27 +470,32 @@ func handleCreateNote(db *sql.DB) http.HandlerFunc {
return
}
user, _ := userFromContext(r.Context())
caller, _ := callerFromContext(r.Context())
userID, saID := callerActorIDs(r.Context())
now := time.Now()
var eventID int64
err := db.QueryRowContext(r.Context(), `
INSERT INTO incident_events (incident_id, type, user_id, detail, created_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`, id, noteType, user.ID, req.Content, now.Unix()).Scan(&eventID)
INSERT INTO incident_events (incident_id, type, user_id, service_account_id, detail, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`, id, noteType, userID, saID, req.Content, now.Unix()).Scan(&eventID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusCreated, models.IncidentEvent{
resp := models.IncidentEvent{
ID: eventID,
IncidentID: id,
Type: noteType,
UserID: &user.ID,
Username: &user.Username,
Detail: &req.Content,
CreatedAt: now.UTC().Truncate(time.Second),
})
}
if u, ok := caller.AsHuman(); ok {
resp.UserID, resp.Username = &u.ID, &u.Username
} else if saName, ok := caller.ServiceAccountName(); ok {
resp.ServiceAccountID, resp.ServiceAccountName = saID, &saName
}
respond(w, http.StatusCreated, resp)
}
}
@@ -504,11 +513,12 @@ func handleDeleteNote(db *sql.DB) http.HandlerFunc {
return
}
user, _ := userFromContext(r.Context())
userID, saID := callerActorIDs(r.Context())
res, err := db.ExecContext(r.Context(), `
DELETE FROM incident_events
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4) AND user_id = $5`,
eventID, id, evNote, evResolutionNote, user.ID)
WHERE id = $1 AND incident_id = $2 AND type IN ($3, $4)
AND (user_id = $5 OR service_account_id = $6)`,
eventID, id, evNote, evResolutionNote, userID, saID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return