@checkstack/incident-common 1.5.2 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,162 @@
1
1
  # @checkstack/incident-common
2
2
 
3
+ ## 1.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [b1a5f3c]
8
+ - @checkstack/frontend-api@0.11.0
9
+ - @checkstack/catalog-common@2.4.1
10
+
11
+ ## 1.6.0
12
+
13
+ ### Minor Changes
14
+
15
+ - d2077bd: Platform-wide team-scoped access control on a unified relation-tuple store.
16
+
17
+ Admins can scope any resource to teams, and the **platform** (not each plugin)
18
+ enforces it. A plugin opts in declaratively by adding `instanceAccess` to a
19
+ procedure's contract; the auth middleware does the rest, so enforcement is
20
+ consistent across catalog, health checks, incidents, maintenances, SLOs,
21
+ automations, and the dependency map, and any third-party plugin gets it for free.
22
+
23
+ Core model:
24
+
25
+ - **Teams are optional.** A resource with no team grants behaves exactly as
26
+ before.
27
+ - **Team grants are additive and restrict who can CHANGE a resource, not who can
28
+ SEE it.** Granting a team `Manage` lets its members view and change the
29
+ resource; `Read-only` lets them view it. Either level grants access to team
30
+ members **even when they lack the global permission**, and granting never
31
+ removes read from anyone who already had it (e.g. a public status page stays
32
+ readable). Privacy is a separate, explicit opt-in via the **Private** toggle,
33
+ which removes the global read path so only the resource's teams can see it.
34
+ - **Ownership at creation.** Create forms expose an **Owning team** picker. A
35
+ non-admin can create a resource for a team they belong to that holds a
36
+ create-capability grant for that type; the new resource is auto-granted to that
37
+ team. Incidents and maintenances are **parent-gated**: anyone who can manage a
38
+ system may open incidents/maintenances for it, no separate grant needed.
39
+ - **Meaningful authorization errors.** A caller with neither the global rule nor
40
+ any team grant for a resource type gets a `403` with a structured body instead
41
+ of a silently-empty `200`. Anonymous callers on public endpoints are never
42
+ `403`'d, so status pages keep rendering.
43
+
44
+ Unified relation-tuple store:
45
+
46
+ - The previously separate access primitives (`resource_team_access.canRead` /
47
+ `.canManage`, ownership, `resource_access_settings.teamOnly`, and
48
+ `resource_create_grant`) are collapsed onto ONE
49
+ `relation_tuple(object, relation, subject)` store: "a team has
50
+ `viewer`/`editor`/`owner` on an object, or `creator` on a type". Privacy is an
51
+ explicit **`private` marker** tuple — its **presence** closes the global read
52
+ path (team grants only), its **absence** is the readable-by-default state, so a
53
+ private resource with zero grants is correctly inaccessible to everyone rather
54
+ than silently globalized. The access decision is a pure, unit-tested function.
55
+ - The auth API is generic: `writeRelation` / `removeRelation` / `setObjectPublic`
56
+ / `listObjectRelations` / `listSubjectRelations` / `setCreateGrant` /
57
+ `listTeamCreateGrants` (user-facing) and `check` / `listAccessibleObjectIds` /
58
+ `hasAnyTypeGrant` / `authorizeCreate` / `setOwner` / `deleteObjectRelations`
59
+ (service-to-service). Migration `0008` backfills tuples from the legacy tables
60
+ and drops them.
61
+
62
+ Explicit per-procedure scoping:
63
+
64
+ - Access rules (`access()` / `accessPair()`) define only the rule (id, level,
65
+ defaults); every procedure declares its own `instanceAccess`. This removes a
66
+ "loaded gun" default that silently applied a shared `idParam` to any procedure
67
+ which forgot its own override.
68
+ - Modes: `idParam` (single-resource pre-check, fails **closed** if the id does
69
+ not resolve), `listKey` / `recordKey` (post-filter a list/record to the
70
+ accessible subset), `create` (authorize creation + write the owning-team
71
+ grant), `parentScope` (scope by read/manage access to a PARENT type,
72
+ cross-plugin single-hop: "you may see incidents/maintenances/SLOs/health for
73
+ system S iff you may see S"), and `global: true` (the honest "intentionally not
74
+ team-scoped" opt-out). A boot-time validator **rejects** any procedure gated on
75
+ a team-scopable resource type that declares no `instanceAccess`, turning the
76
+ previous fail-open into a boot error.
77
+
78
+ Teams administration:
79
+
80
+ - **Team managers** manage their own team's members and managers without the
81
+ global `auth.teams.manage` rule; creating, deleting, and granting a team access
82
+ remain admin-only.
83
+ - A **standalone Teams page** (gated on `auth.teams.read`) lets managers reach
84
+ team administration without the admin Auth Settings page; members are added via
85
+ a debounced directory picker.
86
+ - A **cross-plugin `ResourceResolverRegistry`** lets owning plugins register a
87
+ name/search resolver for their resource types, so the Teams page lists a team's
88
+ grants **by name** (grouped by type) and offers a resource picker — an admin can
89
+ change a grant's level, revoke it, or add one, without auth depending on every
90
+ plugin. Resolvers shipped for catalog systems, health-check configurations,
91
+ incidents, maintenances, SLO objectives, and automations.
92
+
93
+ Frontend:
94
+
95
+ - The resource-side editor is **"Who can change this"** (one Manage checkbox per
96
+ team; unticked = read-only), with an always-visible **Private** toggle
97
+ (disabled until a team that can Manage exists, so a resource can't be stranded).
98
+ - `TeamOwnershipPicker` explains _why_ there's nothing to pick (not a member of
99
+ any team, or none of your teams manage the selected parent) instead of a bare
100
+ "global resource" line.
101
+ - Read-only **"who can change this"** indicators on resource detail pages expand
102
+ to the actual people by name; bulk + per-row **Scope to team** actions in the
103
+ catalog systems list; and the team-access copy spells out that grants are
104
+ additive and that Read-only grants view (not change) even without the global
105
+ permission.
106
+
107
+ Security hardening:
108
+
109
+ - Child deletes in catalog (`removeSystemContact` / `removeSystemLink`) are scoped
110
+ to both the child id and its parent `systemId`, closing a cross-system IDOR for
111
+ team-scoped managers.
112
+ - `searchUsers` is restricted to team administrators, closing a directory/email
113
+ enumeration path opened by the default `auth.teams.read` rule.
114
+ - Grant setters reject unregistered resource types.
115
+
116
+ BREAKING CHANGES (beta; shipped as minor bumps):
117
+
118
+ - `access()` and `accessPair()` no longer accept `idParam` / `listKey` /
119
+ `recordKey`; move instance config to the procedure's `instanceAccess`.
120
+ - Boot fails if a procedure gated on a team-scopable resource type omits
121
+ `instanceAccess`. Declare a scoping mode or `instanceAccess: { global: true }`.
122
+ - The `AuthService` interface is reshaped: `check`, `listAccessibleObjectIds`,
123
+ `hasAnyTypeGrant`, `authorizeCreate` (returns `isPrivate`), `setOwner`
124
+ (`isPrivate`), and `deleteObjectRelations`. Custom `AuthService` implementations
125
+ and mocks must update.
126
+ - The auth RPC contract's per-concept resource-access endpoints are replaced by
127
+ the generic tuple API above; external callers of the old
128
+ `getResourceTeamAccess` / `setResourceTeamAccess` / `setResourceAccessSettings`
129
+ / `grantResourceCreate` / etc. must move to the new procedures.
130
+ - Several contract inputs changed from a bare `string` to an object so the
131
+ middleware can resolve the resource id: catalog `deleteSystem` (`{ id }`),
132
+ `removeSystemContact` / `removeSystemLink` (`{ id, systemId }`); health-check
133
+ `deleteConfiguration` / `pauseConfiguration` / `resumeConfiguration` (`{ id }`).
134
+ All in-tree callers are updated.
135
+ - List/record endpoints that relied on returning an empty `200` to signal "no
136
+ access" now return a `403` for categorically-unauthorized principals.
137
+ - The mis-keyed bulk endpoints `getBulkIncidentsForSystems`,
138
+ `getBulkMaintenancesForSystems`, and `getBulkObjectivesForSystems` no longer
139
+ post-filter their (systemId-keyed) result; access is already gated by
140
+ `catalog.system` upstream.
141
+ - Team membership/manager mutations (`addUserToTeam`, `removeUserFromTeam`,
142
+ `addTeamManager`, `removeTeamManager`) now require `auth.teams.read` instead of
143
+ `auth.teams.manage` at the contract level (broadened to per-team managers).
144
+ - The `resource_team_access`, `resource_access_settings`, and
145
+ `resource_create_grant` tables are dropped (data backfilled into
146
+ `relation_tuple` by migration `0008`). A previously inconsistent "team-only with
147
+ zero grants" resource is now correctly inaccessible to global-access holders.
148
+
149
+ ### Patch Changes
150
+
151
+ - Updated dependencies [d2077bd]
152
+ - Updated dependencies [9ab73c5]
153
+ - Updated dependencies [5c6393f]
154
+ - @checkstack/common@0.16.0
155
+ - @checkstack/catalog-common@2.4.0
156
+ - @checkstack/frontend-api@0.10.0
157
+ - @checkstack/notification-common@1.3.4
158
+ - @checkstack/signal-common@0.2.10
159
+
3
160
  ## 1.5.2
4
161
 
5
162
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-common",
3
- "version": "1.5.2",
3
+ "version": "1.6.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,18 +9,18 @@
9
9
  }
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/common": "0.15.0",
13
- "@checkstack/catalog-common": "2.3.6",
14
- "@checkstack/frontend-api": "0.9.0",
15
- "@checkstack/notification-common": "1.3.3",
16
- "@checkstack/signal-common": "0.2.9",
12
+ "@checkstack/common": "0.16.0",
13
+ "@checkstack/catalog-common": "2.4.1",
14
+ "@checkstack/frontend-api": "0.11.0",
15
+ "@checkstack/notification-common": "1.3.4",
16
+ "@checkstack/signal-common": "0.2.10",
17
17
  "@orpc/contract": "^1.14.4",
18
18
  "zod": "^4.2.1"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^5.7.2",
22
22
  "@checkstack/tsconfig": "0.0.7",
23
- "@checkstack/scripts": "0.6.1"
23
+ "@checkstack/scripts": "0.6.2"
24
24
  },
25
25
  "scripts": {
26
26
  "typecheck": "tsgo -b",
package/src/access.ts CHANGED
@@ -26,7 +26,6 @@ export const incidentAccess = {
26
26
  },
27
27
  },
28
28
  {
29
- idParam: "systemId",
30
29
  pluginId: pluginMetadata.pluginId,
31
30
  },
32
31
  ),
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { incidentContract } from "./rpc-contract";
3
+
4
+ /**
5
+ * Team-scoping regression guards (G9 mis-keying).
6
+ *
7
+ * Grants for this plugin are keyed by INCIDENT id: the frontend
8
+ * `TeamAccessEditor` writes `resourceType="incident.incident"`,
9
+ * `resourceId={incident.id}`. The shared `incident` access rule, however,
10
+ * declares `idParam: "systemId"`. Without a per-proc override, the
11
+ * `autoAuthMiddleware` would look for `input.systemId` on object-scoped procs
12
+ * like `updateIncident({ id })`, find nothing, and SKIP the instance check —
13
+ * so any holder of `incident.manage` could edit any incident.
14
+ *
15
+ * These tests assert each proc carries the expected `instanceAccess` override
16
+ * resolving to the INCIDENT's own id (or the correct list/record key), and that
17
+ * the deliberately-unscoped procs carry NO override.
18
+ *
19
+ * `~orpc.meta` is the contract-procedure internals (the same accessor the
20
+ * catalog contract test uses); `meta.instanceAccess` is the per-proc override
21
+ * the middleware reads.
22
+ */
23
+ interface InstanceAccessCreate {
24
+ teamIdParam?: string;
25
+ idField?: string;
26
+ }
27
+
28
+ interface InstanceAccessParentScope {
29
+ resourceType?: string;
30
+ action?: "read" | "manage";
31
+ idParam?: string;
32
+ recordKey?: string;
33
+ }
34
+
35
+ interface InstanceAccess {
36
+ global?: boolean;
37
+ idParam?: string;
38
+ listKey?: string;
39
+ recordKey?: string;
40
+ create?: InstanceAccessCreate;
41
+ parentScope?: InstanceAccessParentScope;
42
+ }
43
+
44
+ function instanceAccessFor(
45
+ procName: keyof typeof incidentContract,
46
+ ): InstanceAccess | undefined {
47
+ const proc = incidentContract[procName] as unknown as Record<string, unknown>;
48
+ const orpc = proc["~orpc"] as {
49
+ meta?: { instanceAccess?: InstanceAccess };
50
+ };
51
+ return orpc.meta?.instanceAccess;
52
+ }
53
+
54
+ describe("incident contract object-scoped procs gate on the incident's own id", () => {
55
+ const idProcs: Array<{
56
+ proc: keyof typeof incidentContract;
57
+ idParam: string;
58
+ }> = [
59
+ { proc: "getIncident", idParam: "id" },
60
+ { proc: "updateIncident", idParam: "id" },
61
+ { proc: "resolveIncident", idParam: "id" },
62
+ { proc: "deleteIncident", idParam: "id" },
63
+ { proc: "addUpdate", idParam: "incidentId" },
64
+ { proc: "addLink", idParam: "incidentId" },
65
+ ];
66
+
67
+ for (const { proc, idParam } of idProcs) {
68
+ test(`${proc} carries instanceAccess idParam=${idParam} (not systemId)`, () => {
69
+ const access = instanceAccessFor(proc);
70
+ expect(access).toBeDefined();
71
+ expect(access?.idParam).toBe(idParam);
72
+ // G9 regression: must NOT inherit the rule's mis-keyed systemId.
73
+ expect(access?.idParam).not.toBe("systemId");
74
+ expect(access?.listKey).toBeUndefined();
75
+ expect(access?.recordKey).toBeUndefined();
76
+ });
77
+ }
78
+ });
79
+
80
+ describe("incident contract listIncidents post-filters by incident id", () => {
81
+ test("listIncidents carries instanceAccess listKey=incidents", () => {
82
+ const access = instanceAccessFor("listIncidents");
83
+ expect(access).toBeDefined();
84
+ expect(access?.listKey).toBe("incidents");
85
+ expect(access?.idParam).toBeUndefined();
86
+ expect(access?.recordKey).toBeUndefined();
87
+ });
88
+ });
89
+
90
+ describe("incident contract system-scoped / unresolvable procs", () => {
91
+ // SYSTEM-scoped reads now gate on catalog.system via parentScope (cross-plugin,
92
+ // single-hop): "you may see incidents for system S iff you may see S".
93
+ test("getIncidentsForSystem parent-scopes on catalog.system (read, idParam)", () => {
94
+ const ps = instanceAccessFor("getIncidentsForSystem")?.parentScope;
95
+ expect(ps?.resourceType).toBe("catalog.system");
96
+ expect(ps?.action).toBe("read");
97
+ expect(ps?.idParam).toBe("systemId");
98
+ });
99
+
100
+ test("getBulkIncidentsForSystems parent-scopes on catalog.system (read, recordKey)", () => {
101
+ const ps = instanceAccessFor("getBulkIncidentsForSystems")?.parentScope;
102
+ expect(ps?.resourceType).toBe("catalog.system");
103
+ expect(ps?.action).toBe("read");
104
+ expect(ps?.recordKey).toBe("incidents");
105
+ });
106
+
107
+ // removeLink's input `id` is the LINK id (not the incident or system id), so
108
+ // no middleware pre-check can resolve a grant; it is explicitly global (the
109
+ // incident.manage rule still gates it).
110
+ test("removeLink is explicitly global (link id is not a scopable resource id)", () => {
111
+ expect(instanceAccessFor("removeLink")?.global).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe("incident contract createIncident carries create-mode team ownership", () => {
116
+ /**
117
+ * Regression guard for create-mode wiring. The autoAuthMiddleware reads
118
+ * `instanceAccess.create` on a mutation proc to:
119
+ * 1. (pre-handler) resolve + authorise the owning team from `input[teamIdParam]`.
120
+ * 2. (post-handler) write a team-scoped grant keyed by `response[idField]`
121
+ * via `setResourceOwner`.
122
+ *
123
+ * Grants for this plugin are keyed `incident.incident / {incident.id}`, which
124
+ * matches the `TeamAccessEditor` usage in `IncidentEditor` (resourceType =
125
+ * "incident.incident", resourceId = incident.id). Hence `idField: "id"` and
126
+ * `teamIdParam: "teamId"` are the correct values.
127
+ *
128
+ * `createAutoIncident` is intentionally excluded: it is `userType: "service"`
129
+ * (system-generated incidents have no interactive team owner) and must NOT
130
+ * carry create-mode.
131
+ */
132
+ test("createIncident carries instanceAccess.create with teamIdParam=teamId and idField=id", () => {
133
+ const access = instanceAccessFor("createIncident");
134
+ expect(access).toBeDefined();
135
+ expect(access?.create).toBeDefined();
136
+ expect(access?.create?.teamIdParam).toBe("teamId");
137
+ expect(access?.create?.idField).toBe("id");
138
+ // Must not carry object-scope fields — create-mode is distinct from read/update gating.
139
+ expect(access?.idParam).toBeUndefined();
140
+ expect(access?.listKey).toBeUndefined();
141
+ expect(access?.recordKey).toBeUndefined();
142
+ });
143
+
144
+ test("createAutoIncident does NOT carry instanceAccess.create (service userType)", () => {
145
+ const access = instanceAccessFor("createAutoIncident");
146
+ expect(access?.create).toBeUndefined();
147
+ });
148
+ });
@@ -20,6 +20,11 @@ export const incidentContract = {
20
20
  operationType: "query",
21
21
  userType: "public",
22
22
  access: [incidentAccess.incident.read],
23
+ // Grants are keyed by INCIDENT id (frontend `TeamAccessEditor` writes
24
+ // `resourceType="incident.incident"`, `resourceId={incident.id}`). Each
25
+ // returned item is an `IncidentWithSystems` whose `.id` is the incident id,
26
+ // so the list post-filter matches grants correctly.
27
+ instanceAccess: { listKey: "incidents" },
23
28
  })
24
29
  .input(
25
30
  z
@@ -37,6 +42,11 @@ export const incidentContract = {
37
42
  operationType: "query",
38
43
  userType: "public",
39
44
  access: [incidentAccess.incident.read],
45
+ // Object-scoped: gate on the incident's OWN grant. The shared `incident`
46
+ // access rule declares `idParam: "systemId"`, but grants are keyed by
47
+ // incident id and this input carries `id` (no `systemId`), so without this
48
+ // override the middleware would find no id and SKIP the check (G9 bug).
49
+ instanceAccess: { idParam: "id" },
40
50
  })
41
51
  .input(z.object({ id: z.string() }))
42
52
  .output(IncidentDetailSchema.nullable()),
@@ -46,6 +56,12 @@ export const incidentContract = {
46
56
  operationType: "query",
47
57
  userType: "public",
48
58
  access: [incidentAccess.incident.read],
59
+ // SYSTEM-scoped read: input carries `systemId` and this endpoint returns
60
+ // incidents FOR that system. Access follows "can you read that system"
61
+ // (catalog.system / read), not incident-level grants.
62
+ instanceAccess: {
63
+ parentScope: { resourceType: "catalog.system", action: "read", idParam: "systemId" },
64
+ },
49
65
  })
50
66
  .input(z.object({ systemId: z.string() }))
51
67
  .output(z.array(IncidentWithSystemsSchema)),
@@ -57,7 +73,13 @@ export const incidentContract = {
57
73
  operationType: "query",
58
74
  userType: "public",
59
75
  access: [incidentAccess.incident.read],
60
- instanceAccess: { recordKey: "incidents" },
76
+ // SYSTEM-scoped bulk read: output is `{ incidents: Record<systemId, ...> }`.
77
+ // The record is keyed by system id, so access follows per-system catalog
78
+ // visibility — the validator post-filters the record to only the system ids
79
+ // the caller can read.
80
+ instanceAccess: {
81
+ parentScope: { resourceType: "catalog.system", action: "read", recordKey: "incidents" },
82
+ },
61
83
  })
62
84
  .route({ method: "POST" })
63
85
  .input(z.object({ systemIds: z.array(z.string()) }))
@@ -72,6 +94,22 @@ export const incidentContract = {
72
94
  operationType: "mutation",
73
95
  userType: "authenticated",
74
96
  access: [incidentAccess.incident.manage],
97
+ // CREATE-mode team ownership: the autoAuthMiddleware reads `input.teamId`
98
+ // (optional) to resolve the owning team and, post-handler, writes a
99
+ // team-scoped grant keyed `incident.incident / {incident.id}` via
100
+ // `setResourceOwner`. The `idField: "id"` tells the middleware which field
101
+ // of the response carries the new resource's id. Grants are keyed by
102
+ // incident id, matching the `TeamAccessEditor` usage in IncidentEditor.
103
+ instanceAccess: {
104
+ create: {
105
+ teamIdParam: "teamId",
106
+ idField: "id",
107
+ // Anyone who can MANAGE a referenced system may create one for it; the
108
+ // result stays globally readable. Falls back to a per-type
109
+ // create-capability grant when no parent gate matches.
110
+ parent: { resourceType: "catalog.system", idParam: "systemIds" },
111
+ },
112
+ },
75
113
  })
76
114
  .input(CreateIncidentInputSchema)
77
115
  .output(IncidentWithSystemsSchema),
@@ -81,6 +119,9 @@ export const incidentContract = {
81
119
  operationType: "mutation",
82
120
  userType: "authenticated",
83
121
  access: [incidentAccess.incident.manage],
122
+ // Object-scoped: gate on the incident's OWN grant via the `id` input.
123
+ // Overrides the rule's `idParam: "systemId"` (G9 bug — no `systemId` here).
124
+ instanceAccess: { idParam: "id" },
84
125
  })
85
126
  .route({ method: "PATCH" })
86
127
  .input(UpdateIncidentInputSchema)
@@ -91,6 +132,10 @@ export const incidentContract = {
91
132
  operationType: "mutation",
92
133
  userType: "authenticated",
93
134
  access: [incidentAccess.incident.manage],
135
+ // Object-scoped: gate on the incident's OWN grant. This input carries the
136
+ // incident id as `incidentId` (not `id`). Overrides the rule's
137
+ // `idParam: "systemId"` (G9 bug — no `systemId` here).
138
+ instanceAccess: { idParam: "incidentId" },
94
139
  })
95
140
  .input(AddIncidentUpdateInputSchema)
96
141
  .output(IncidentUpdateSchema),
@@ -100,6 +145,9 @@ export const incidentContract = {
100
145
  operationType: "mutation",
101
146
  userType: "authenticated",
102
147
  access: [incidentAccess.incident.manage],
148
+ // Object-scoped: gate on the incident's OWN grant via the `id` input.
149
+ // Overrides the rule's `idParam: "systemId"` (G9 bug — no `systemId` here).
150
+ instanceAccess: { idParam: "id" },
103
151
  })
104
152
  .input(z.object({ id: z.string(), message: z.string().optional() }))
105
153
  .output(IncidentWithSystemsSchema),
@@ -109,6 +157,9 @@ export const incidentContract = {
109
157
  operationType: "mutation",
110
158
  userType: "authenticated",
111
159
  access: [incidentAccess.incident.manage],
160
+ // Object-scoped: gate on the incident's OWN grant via the `incidentId`
161
+ // input. Overrides the rule's `idParam: "systemId"` (G9 bug).
162
+ instanceAccess: { idParam: "incidentId" },
112
163
  })
113
164
  .input(AddIncidentLinkInputSchema)
114
165
  .output(IncidentLinkSchema),
@@ -118,6 +169,12 @@ export const incidentContract = {
118
169
  operationType: "mutation",
119
170
  userType: "authenticated",
120
171
  access: [incidentAccess.incident.manage],
172
+ // global:true — input carries only a LINK id (`incidentLinks.id`), not an
173
+ // incident id or system id. The owning incident id is only resolvable
174
+ // server-side after the lookup, so no pre-check can scope this to a resource.
175
+ // `idParam: "id"` would compare a link id against incident grants and always
176
+ // 403. The `incident.manage` access rule still gates this endpoint globally.
177
+ instanceAccess: { global: true },
121
178
  })
122
179
  .route({ method: "DELETE" })
123
180
  .input(z.object({ id: z.string() }))
@@ -128,6 +185,9 @@ export const incidentContract = {
128
185
  operationType: "mutation",
129
186
  userType: "authenticated",
130
187
  access: [incidentAccess.incident.manage],
188
+ // Object-scoped: gate on the incident's OWN grant via the `id` input.
189
+ // Overrides the rule's `idParam: "systemId"` (G9 bug — no `systemId` here).
190
+ instanceAccess: { idParam: "id" },
131
191
  })
132
192
  .route({ method: "DELETE" })
133
193
  .input(z.object({ id: z.string() }))
package/src/schemas.ts CHANGED
@@ -96,6 +96,15 @@ export const CreateIncidentInputSchema = z.object({
96
96
  .string()
97
97
  .optional()
98
98
  .describe("Optional initial status update message"),
99
+ /**
100
+ * Optional owning-team id. When supplied by an authenticated team member, the
101
+ * autoAuthMiddleware create-mode writes a team-scoped ownership grant for the
102
+ * newly created incident. Omitting it (or calling with global-manage) creates
103
+ * the incident without team scoping, exactly as before. Never written to the
104
+ * incident row — the router handler ignores this field before delegating to
105
+ * the service.
106
+ */
107
+ teamId: z.string().optional(),
99
108
  });
100
109
  export type CreateIncidentInput = z.infer<typeof CreateIncidentInputSchema>;
101
110