Incident-mutation routes 500 for a service-account caller: zero-value user id violates the acknowledged_by/assigned_to/user_id FK #25
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Found during the authorization audit in #24 (not fixed there — deliberately
out of scope, see that PR's description). Every incident-mutation route
reads
userFromContext(ctx)and writes the result's.IDinto a realforeign-key column, without checking the returned
okbool:POST /api/incidents/{id}/acknowledge,DELETE .../acknowledgePOST .../resolve,POST .../assignPOST .../snooze,DELETE .../snoozePOST .../archive,DELETE .../archivePOST .../notes,DELETE .../notes/{eventID}(all in
internal/api/incidents.go)For a human caller this is fine. For a team-scoped service account
calling one of these on an incident belonging to its own team — which it
can reach today, since
requireTeamMember-equivalent scoping (via thesynthetic owner membership
serveAsServiceAccountinjects) already lets itthrough —
userFromContextreturnsok=falseand a zero-valuemodels.User{ID: 0}. The handler writes0anyway into:(
internal/db/migrations/001_baseline.sql). All three are nullable, but0is not
NULLand nousersrow has id0— so theINSERT/UPDATEforeign-key-violates and the request 500s, for every one of the nine routes
above.
Why this matters
SERVICE-ACCOUNTS.mdalready states the intended behavior: "aservice-account principal is stored and displayed distinctly... never
coerced into a
user_idFK." That promise isn't implemented anywhere —there is no
service_account_idcolumn onincidentsorincident_events, and the actual behavior is the opposite of "distinct anddisplayed": a bare
0that fails the constraint.This is also a real, plausible capability gap, not just a crash: a
team-scoped service account is explicitly meant to act with that team's
owner-equivalent reach (escalation, dead-man switches, integrations, and —
per #24 — membership/invites). Acting on that team's own incidents
(auto-acknowledging, auto-resolving a stale one, a ChatOps-style
integration acknowledging on a channel's behalf) is a reasonable thing for
that same credential to do, and today it can reach these routes — it just
crashes the moment it tries to write anything.
Suggested fix (not prescriptive)
Needs a schema migration, which is why this is separate from #24's pure
authorization fix: an actor-attribution representation that can name either
a human (
user_id) or a service account (e.g. a new nullableservice_account_idcolumn, or a combinedactor_type/actor_idpair) onincidents.acknowledged_by/assigned_toandincident_events.user_id,plus updating all nine handlers in
internal/api/incidents.goto useCaller.AsHuman()/Caller.ServiceAccountID()(both now exist,internal/api/caller.gofrom #24) to populate the right column instead ofassuming a human.
Whatever shape is chosen, the timeline/incident JSON responses need to
render a service-account actor distinctly too (
Caller.Identity()—"service-account:7"— already exists for exactly this, unused today).References
internal/api/incidents.go— the nine handlers.internal/db/migrations/001_baseline.sql:101,103,139— the three FKcolumns.
internal/api/caller.go(landing in #24) —Caller.AsHuman(),Caller.ServiceAccountID(),Caller.Identity().SERVICE-ACCOUNTS.md— the unimplemented "stored and displayeddistinctly" promise.