Skip to content

Owner rows are de-duplicated only in the dashboard: API and workflow paths create duplicate owners, double-notifying the team #3394

Description

@p-paul

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

  1. Add team web as an owner of an incident through the dashboard. One row.
  2. 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.
  3. 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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions