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
+23 -4
View File
@@ -123,6 +123,16 @@ function who(id, name) {
return name || 'someone';
}
// ackActorLabel renders whoever acknowledged inc, human or service account —
// the two are mutually exclusive (migration 015), and a service account is a
// credential, not "you" or "nobody", so it gets its own branch rather than
// going through who()'s id-vs-myID() check.
function ackActorLabel() {
if (inc.acknowledged_by_id != null) return who(inc.acknowledged_by_id, inc.acknowledged_by);
if (inc.acknowledged_by_service_account_id != null) return inc.acknowledged_by_service_account || 'a service account';
return null;
}
function facts() {
const rows = [];
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
@@ -132,8 +142,7 @@ function facts() {
// take reading top to bottom to piece together.
const elapsedTo = inc.resolved_at ? Date.parse(inc.resolved_at) : Date.now();
const responsible = inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to)
: inc.acknowledged_by_id != null ? who(inc.acknowledged_by_id, inc.acknowledged_by)
: 'Unassigned';
: ackActorLabel() || 'Unassigned';
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' },
h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
@@ -142,7 +151,7 @@ function facts() {
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
if (inc.acknowledged_at) {
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
add('Acknowledged', `${ackActorLabel()} · ${when(inc.acknowledged_at)}`);
}
add('Assigned', inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to) : 'Unassigned');
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
@@ -206,9 +215,19 @@ function alertItem(a) {
// ---------- timeline ----------
// actorLabel renders whoever performed ev, human or service account — the
// two are mutually exclusive (migration 015). null means the server acted:
// ev.user_id == null no longer means that by itself, now that a service
// account's events also leave it null.
function actorLabel(ev, named = false) {
if (ev.user_id != null) return named ? (ev.username || 'someone') : who(ev.user_id, ev.username);
if (ev.service_account_id != null) return ev.service_account_name || 'a service account';
return null;
}
// named spells users out instead of "you", for text that leaves this page.
function eventText(ev, named = false) {
const person = ev.user_id != null ? (named ? ev.username || 'someone' : who(ev.user_id, ev.username)) : null;
const person = actorLabel(ev, named);
const strong = (t) => h('span', { class: 'who', text: t || 'someone' });
const alertName = () => {
const a = (inc.alerts || []).find((x) => x.id === ev.alert_id);