Summary
Owner rows (IncidentOwnerTeam, IncidentOwnerUser, and every other *OwnerTeam / *OwnerUser model) are de-duplicated only in the dashboard picker. Nothing in the persistence layer enforces it — no unique constraint, no service-level guard — so every non-dashboard path inserts unconditionally and can attach the same team/user to the same resource any number of times.
The duplicate is not cosmetic: onCreateSuccess runs per row, so each duplicate writes its own incident-feed entry and fires its own owner notification.
Observed on a self-hosted 12.0.13 instance; code below is from tag 12.0.14.
Where the guard is, and where it isn't
The dashboard filters correctly. With team web already an owner of an incident, opening Owners → Add owner and typing web returns "No matches found." So this is not reachable through the UI, which is presumably why it has gone unnoticed.
The database does not. The index is on exactly the tuple that should be unique, but it is not marked unique — Common/Models/DatabaseModels/IncidentOwnerTeam.ts:
@Index(["incidentId", "teamId", "projectId"])
export default class IncidentOwnerTeam extends BaseModel {
Same shape across the family, none of them unique:
IncidentOwnerTeam @Index(["incidentId", "teamId", "projectId"])
IncidentOwnerUser @Index(["incidentId", "userId", "projectId"])
AlertOwnerTeam @Index(["alertId", "teamId", "projectId"])
MonitorOwnerTeam @Index(["monitorId", "teamId", "projectId"])
ScheduledMaintenanceOwnerTeam @Index(["scheduledMaintenanceId", "teamId", "projectId"])
The service does not. IncidentOwnerTeamService has no onBeforeCreate and no existence check anywhere in its 232 lines.
addOwners does not. IncidentService.addOwners() loops and creates, with no lookup for an existing row:
for (let teamId of teamIds) {
...
const teamOwner: IncidentOwnerTeam = new IncidentOwnerTeam();
teamOwner.incidentId = incidentId;
teamOwner.projectId = projectId;
teamOwner.teamId = teamId;
teamOwner.isOwnerNotified = !notifyOwners;
await IncidentOwnerTeamService.create({ data: teamOwner, props: props });
}
This is the method MonitorIncident.ts calls when a monitor criteria raises an incident with ownerTeamIds set.
Reproduction
- Add team
web as an owner of an incident through the dashboard. One row.
- Add it again from any non-dashboard path — the REST API, or a workflow using the
Create One Incident Team Owner / Create Many Incident Team Owners components with the same incidentId + teamId.
POST /api/incident-owner-team/get-list now returns two rows with identical incidentId and teamId:
{"data":[
{"_id":"2aadeb1b-526f-4733-a0fd-a00b8702ba44","teamId":{"value":"ecfa2e15-…"}},
{"_id":"cf27ee98-e7dc-42d0-accd-45b5f2721e49","teamId":{"value":"ecfa2e15-…"}}
], "count":2}
The incident's Owners tab renders the same team twice.
Impact
IncidentOwnerTeamService.onCreateSuccess fires per row, so a duplicate produces:
- a second incident-feed item — "👨🏻👩🏻👦🏻 Added team web to the Incident INC-29 as the owner."
- a second owner notification / workspace notification
So an integration that re-runs — a retried workflow, an at-least-once webhook, or a criteria template that lists a team twice — silently double-notifies the on-call team. There is no error and nothing in the logs to indicate why.
It is also easy to reach accidentally by combining paths that do not know about each other: e.g. owner_team_ids on a monitor criteria (which routes through addOwners) plus a workflow adding the same team. Each inserts independently.
Suggested fix
Either would do, and they compose:
- Mark the existing indexes unique —
@Index(["incidentId", "teamId", "projectId"], { unique: true }) — and the same for the sibling owner models. The index is already on the right columns; only the flag is missing. Needs a migration to collapse any existing duplicates first.
- Add an
onBeforeCreate existence check in the owner services (and/or make addOwners skip teams/users already attached), so the API returns a clean no-op or ALREADY_EXISTS instead of a second row.
The second alone fixes the notification symptom; the first is what makes the invariant true regardless of which path writes.
Summary
Owner rows (
IncidentOwnerTeam,IncidentOwnerUser, and every other*OwnerTeam/*OwnerUsermodel) are de-duplicated only in the dashboard picker. Nothing in the persistence layer enforces it — no unique constraint, no service-level guard — so every non-dashboard path inserts unconditionally and can attach the same team/user to the same resource any number of times.The duplicate is not cosmetic:
onCreateSuccessruns per row, so each duplicate writes its own incident-feed entry and fires its own owner notification.Observed on a self-hosted 12.0.13 instance; code below is from tag
12.0.14.Where the guard is, and where it isn't
The dashboard filters correctly. With team
webalready an owner of an incident, opening Owners → Add owner and typingwebreturns "No matches found." So this is not reachable through the UI, which is presumably why it has gone unnoticed.The database does not. The index is on exactly the tuple that should be unique, but it is not marked unique —
Common/Models/DatabaseModels/IncidentOwnerTeam.ts:Same shape across the family, none of them unique:
The service does not.
IncidentOwnerTeamServicehas noonBeforeCreateand no existence check anywhere in its 232 lines.addOwnersdoes not.IncidentService.addOwners()loops and creates, with no lookup for an existing row:This is the method
MonitorIncident.tscalls when a monitor criteria raises an incident withownerTeamIdsset.Reproduction
webas an owner of an incident through the dashboard. One row.Create One Incident Team Owner/Create Many Incident Team Ownerscomponents with the sameincidentId+teamId.POST /api/incident-owner-team/get-listnow returns two rows with identicalincidentIdandteamId:{"data":[ {"_id":"2aadeb1b-526f-4733-a0fd-a00b8702ba44","teamId":{"value":"ecfa2e15-…"}}, {"_id":"cf27ee98-e7dc-42d0-accd-45b5f2721e49","teamId":{"value":"ecfa2e15-…"}} ], "count":2}The incident's Owners tab renders the same team twice.
Impact
IncidentOwnerTeamService.onCreateSuccessfires per row, so a duplicate produces:So an integration that re-runs — a retried workflow, an at-least-once webhook, or a criteria template that lists a team twice — silently double-notifies the on-call team. There is no error and nothing in the logs to indicate why.
It is also easy to reach accidentally by combining paths that do not know about each other: e.g.
owner_team_idson a monitor criteria (which routes throughaddOwners) plus a workflow adding the same team. Each inserts independently.Suggested fix
Either would do, and they compose:
@Index(["incidentId", "teamId", "projectId"], { unique: true })— and the same for the sibling owner models. The index is already on the right columns; only the flag is missing. Needs a migration to collapse any existing duplicates first.onBeforeCreateexistence check in the owner services (and/or makeaddOwnersskip teams/users already attached), so the API returns a clean no-op orALREADY_EXISTSinstead of a second row.The second alone fixes the notification symptom; the first is what makes the invariant true regardless of which path writes.