#3409·oneuptime

Cannot add the first incident to an Incident Episode via API: onBeforeCreate stamps isOwnerNotifiedOfIncidentAdded, which is create: []

Author: p-paulCreated Aug 26, 2026Updated Aug 26, 2026

Summary

The first IncidentEpisodeMember of an episode can never be created by a non-root caller (e.g. a project API key). IncidentEpisodeMemberService.onBeforeCreate stamps isOwnerNotifiedOfIncidentAdded = true when the episode has zero members, but that column is declared create: []. DatabaseService.create runs the hook first and only then validates column ACLs against the now-stamped data using the caller's permissions, so the request is rejected because of a value the server itself wrote.

HTTP 400
{"error":"User is not allowed to create on isOwnerNotifiedOfIncidentAdded column of Incident Episode Member"}

Members 2..N succeed, because the hook only stamps when the member count is 0. The net effect is that an episode created through the API can never be populated through the API — it is stuck at incidentCount: 0 forever.

This is the same defect class as #2065 (oneuptime_domain / isVerified), and the same one pinned by the regression test at Common/Tests/Server/Types/Database/Permissions/WorkspaceMethodStampedColumnCreate.test.ts, whose header comment describes the mechanism exactly:

DatabaseService.create runs that hook FIRST and only then calls ModelPermission.checkCreatePermissions on the STAMPED data — so the column ACLs are evaluated against values the SERVER wrote, with the CALLER's permissions. When those three columns carried create: [], every non-root create was refused […] and no test noticed because the service suites call the hook directly rather than DatabaseService.create.

UserSlack / UserMicrosoftTeams were fixed by giving the stamped columns create: [Permission.CurrentUser]. IncidentEpisodeMember.isOwnerNotifiedOfIncidentAdded never got the equivalent treatment.

Environment

  • Reproduced on self-hosted 12.0.13 (Community Edition).
  • Code references below are from 12.0.23, where the code path is unchanged.
  • Caller: a project API key with Project Admin permissions.

Steps to reproduce

  1. Create an episode (any route), so it has zero members:
bash
curl -X POST "$ONEUPTIME_URL/api/incident-episode" \
  -H "ApiKey: $PROJECT_API_KEY" -H 'Content-Type: application/json' \
  -d '{"data":{"title":"repro","incidentSeverityId":"<severity-id>","isManuallyCreated":true}}'
  1. Add the first incident to it:
bash
curl -X POST "$ONEUPTIME_URL/api/incident-episode-member" \
  -H "ApiKey: $PROJECT_API_KEY" -H 'Content-Type: application/json' \
  -d '{"data":{"incidentEpisodeId":"<episode-id>","incidentId":"<incident-id>","addedBy":"api"}}'

Expected: 200, member created, incidentCount becomes 1.

Actual: 400 User is not allowed to create on isOwnerNotifiedOfIncidentAdded column of Incident Episode Member.

Passing the column explicitly ("isOwnerNotifiedOfIncidentAdded": false) fails identically — the ACL forbids creating it at all.

  1. Now seed one member by any root-context route, then retry step 2 for a different incident: it returns 200, and the response confirms the column was not stamped ("isOwnerNotifiedOfIncidentAdded": false).

Root cause

File What happens
Common/Server/Services/IncidentEpisodeMemberService.ts (onBeforeCreate) If isOwnerNotifiedOfIncidentAdded === undefined and the episode has 0 members, sets it to true.
Common/Models/DatabaseModels/IncidentEpisodeMember.ts:658-685 That column is declared @ColumnAccessControl({ create: [], … }).
Common/Server/Services/DatabaseService.ts:980 then :1017 _onBeforeCreate(createBy) runs, then ModelPermission.checkCreatePermissions(this.modelType, data, _createdBy.props) validates the stamped data.
Common/Server/Types/Database/Permissions/CreatePermission.ts:19-21 Only props.isRoot || props.isMasterAdmin returns early. Everyone else reaches ColumnPermissions.checkDataColumnPermissions, where create: [] can never intersect the caller's permissions.

Why this may have gone unnoticed

  • Internal callers pass props: { isRoot: true } (IncidentGroupingEngineService.addIncidentToEpisode…), so rule-driven grouping — the main path that populates episodes — is unaffected.
  • A dashboard session running as master admin also takes the isMasterAdmin early return. On our instance the "Add Incident Episode Member" button worked while the API key failed against the same episode, which matches that split.
  • Service-level tests call onBeforeCreate directly rather than going through DatabaseService.create, so the ACL is never exercised — exactly the gap called out in the WorkspaceMethodStampedColumnCreate comment.

Suggested fix

Mirror the UserSlack fix: give the column a create ACL that the stamping path can satisfy (e.g. the same permission set already used for create on incidentEpisodeId / incidentId on this model), and keep the value out of client hands by having onBeforeCreate reject a non-root payload that carries it, rather than relying on create: [].

A regression test that goes through DatabaseService.create (not the hook directly) for the first member of an episode would pin it.

Worth grepping for other models with the same shape — a column that is create: [] and also written by its service's onBeforeCreate.

Workaround

Seed one member per episode from a master-admin dashboard session (or any root-context path), then add the remaining members via the API. IncidentEpisodeService.updateIncidentCount recomputes from a COUNT(*), so a mixed UI + API population still ends up with a correct incidentCount.