@checkstack/incident-backend 1.10.0 → 1.11.0

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,104 @@
1
1
  # @checkstack/incident-backend
2
2
 
3
+ ## 1.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f93ee7a: Fix a class of 403s where team-scoped managers were blocked from endpoints they
8
+ needed. A repo-wide audit of every `instanceAccess: { global: true }` procedure
9
+ found more instances of the same bug behind the health-check editor fix: an
10
+ endpoint on a team-scopable resource type, gated so only the GLOBAL access rule
11
+ (never a team grant) authorizes it.
12
+
13
+ Automation: the editor utilities and catalogs (`validateDefinition`,
14
+ `listTriggers`, `listActions`, `listArtifactTypes`, `listAutomationGroups`,
15
+ `listAutomationTemplates`, `renderTemplate`, `testScript`) now use `typeScoped`
16
+ so a team-scoped automation manager can author without the global rule. The run
17
+ endpoints (`listRuns`, `getRun`, `cancelRun`, `getRunScopeForReplay`) are scoped
18
+ to their parent automation via `parentScope` on `automationId`; `getRun`,
19
+ `cancelRun`, and `getRunScopeForReplay` now take the owning `automationId`
20
+ (always available in the run URL/editor) and the handler filters the run fetch by
21
+ it, so a run id cannot be paired with a foreign automation the caller happens to
22
+ hold a grant on. The two migration-admin endpoints stay `global: true` (genuine
23
+ platform-admin actions).
24
+
25
+ Health check: `validateConfiguration` (editor deep-validate) and
26
+ `getPlatformNotificationDefaults` (fetched on every assignment-editor mount) move
27
+ to `typeScoped`. The paired WRITE `setPlatformNotificationDefaults` stays
28
+ `global: true` on purpose - it rewrites instance-wide defaults for every team, so
29
+ a single team grant must not authorize it. Because that write stays global-only,
30
+ the assignment editor's "Notification defaults" button is now gated on the global
31
+ `configuration.manage` rule (`healthcheck-frontend`), so a team-scoped manager no
32
+ longer sees an editor whose Save always 403'd.
33
+
34
+ Anomaly: the anomaly settings panels embedded in the health-check editor
35
+ (`updateAnomalyConfig` / `getAnomalyConfig` and `updateAnomalyAssignmentConfig` /
36
+ `getAnomalyAssignmentConfig`) were authorized against the non-team-scopable
37
+ `anomaly_feed` type (via `global: true` or an `idParam` that could never match a
38
+ team grant), so a team-scoped manager who owns the check/system saw "Save
39
+ Defaults" / "Save Exceptions" buttons whose Save always 403'd. They now
40
+ `parentScope` on the owning health-check configuration (`healthcheck.healthcheck`)
41
+ and catalog system (`catalog.system`) respectively, so managing the check/system
42
+ authorizes reading and editing its anomaly settings. The frontend needed no
43
+ change: those buttons were already disabled for non-managers, and the panels are
44
+ only reachable inside the manager-gated editor. Also, the automation "New
45
+ automation" template picker (`automation-frontend`) gated its page on the bare
46
+ global manage rule; it now uses the create capability, so a team-scoped creator
47
+ (whom the route already reveals the page to) is no longer shown a blocked page.
48
+
49
+ Incident & maintenance: `removeLink` was `global: true` because its input carried
50
+ only the link id. It now takes the owning `incidentId` / `maintenanceId`
51
+ (mirroring `addLink`), authorizes per-instance via `idParam`, and the service
52
+ scopes the delete by that parent id so a link cannot be removed by pairing its id
53
+ with a different incident/maintenance the caller manages. The AI `removeLink`
54
+ tools carry the parent id too.
55
+
56
+ BREAKING CHANGES: `automation.getRun`, `automation.cancelRun`,
57
+ `automation.getRunScopeForReplay`, `incident.removeLink`, and
58
+ `maintenance.removeLink` now require a parent id (`automationId` /
59
+ `incidentId` / `maintenanceId`) in their input. Endpoints previously gated by a
60
+ global rule alone now also accept the owning team's grant; no endpoint became
61
+ more permissive for a user who lacks both the global rule and a relevant team
62
+ grant.
63
+
64
+ Not team-scopable, so intentionally left `global: true` (verified by the audit):
65
+ catalog environments, anomaly config, SLO list/streak/milestone reads and
66
+ health-check history/stats (their read rules are public/default), and every
67
+ hand-rolled HTTP route (global admin/infra or already team-aware).
68
+
69
+ ### Patch Changes
70
+
71
+ - Updated dependencies [f93ee7a]
72
+ - Updated dependencies [f93ee7a]
73
+ - Updated dependencies [8aae4e2]
74
+ - Updated dependencies [d0eddc9]
75
+ - Updated dependencies [d0eddc9]
76
+ - Updated dependencies [f93ee7a]
77
+ - Updated dependencies [f93ee7a]
78
+ - Updated dependencies [d0eddc9]
79
+ - Updated dependencies [d0eddc9]
80
+ - Updated dependencies [d0eddc9]
81
+ - Updated dependencies [f93ee7a]
82
+ - @checkstack/common@0.22.0
83
+ - @checkstack/catalog-common@2.6.3
84
+ - @checkstack/ai-backend@0.10.9
85
+ - @checkstack/backend-api@0.31.0
86
+ - @checkstack/automation-common@0.10.0
87
+ - @checkstack/automation-backend@0.11.0
88
+ - @checkstack/incident-common@1.9.0
89
+ - @checkstack/auth-common@0.13.0
90
+ - @checkstack/ai-common@0.6.6
91
+ - @checkstack/cache-api@0.3.19
92
+ - @checkstack/catalog-backend@1.6.9
93
+ - @checkstack/command-backend@0.2.21
94
+ - @checkstack/integration-backend@0.7.3
95
+ - @checkstack/integration-common@0.9.8
96
+ - @checkstack/notification-common@1.5.3
97
+ - @checkstack/signal-common@0.2.17
98
+ - @checkstack/status-page-backend@0.4.8
99
+ - @checkstack/status-page-common@0.5.3
100
+ - @checkstack/cache-utils@0.2.24
101
+
3
102
  ## 1.10.0
4
103
 
5
104
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,25 +14,25 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.10.8",
18
- "@checkstack/ai-common": "0.6.5",
19
- "@checkstack/backend-api": "0.30.0",
20
- "@checkstack/cache-api": "0.3.18",
21
- "@checkstack/cache-utils": "0.2.23",
22
- "@checkstack/incident-common": "1.8.0",
23
- "@checkstack/catalog-common": "2.6.2",
24
- "@checkstack/catalog-backend": "1.6.8",
25
- "@checkstack/notification-common": "1.5.2",
26
- "@checkstack/auth-common": "0.12.2",
27
- "@checkstack/command-backend": "0.2.20",
28
- "@checkstack/signal-common": "0.2.16",
29
- "@checkstack/status-page-backend": "0.4.7",
30
- "@checkstack/status-page-common": "0.5.2",
31
- "@checkstack/integration-backend": "0.7.2",
32
- "@checkstack/integration-common": "0.9.7",
33
- "@checkstack/automation-backend": "0.10.10",
34
- "@checkstack/automation-common": "0.9.2",
35
- "@checkstack/common": "0.21.0",
17
+ "@checkstack/ai-backend": "0.10.9",
18
+ "@checkstack/ai-common": "0.6.6",
19
+ "@checkstack/backend-api": "0.31.0",
20
+ "@checkstack/cache-api": "0.3.19",
21
+ "@checkstack/cache-utils": "0.2.24",
22
+ "@checkstack/incident-common": "1.9.0",
23
+ "@checkstack/catalog-common": "2.6.3",
24
+ "@checkstack/catalog-backend": "1.6.9",
25
+ "@checkstack/notification-common": "1.5.3",
26
+ "@checkstack/auth-common": "0.13.0",
27
+ "@checkstack/command-backend": "0.2.21",
28
+ "@checkstack/signal-common": "0.2.17",
29
+ "@checkstack/status-page-backend": "0.4.8",
30
+ "@checkstack/status-page-common": "0.5.3",
31
+ "@checkstack/integration-backend": "0.7.3",
32
+ "@checkstack/integration-common": "0.9.8",
33
+ "@checkstack/automation-backend": "0.11.0",
34
+ "@checkstack/automation-common": "0.10.0",
35
+ "@checkstack/common": "0.22.0",
36
36
  "drizzle-orm": "^0.45.0",
37
37
  "zod": "^4.2.1",
38
38
  "@orpc/contract": "^1.14.4",
@@ -40,11 +40,13 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@checkstack/drizzle-helper": "0.0.6",
43
- "@checkstack/scripts": "0.7.2",
44
- "@checkstack/test-utils-backend": "0.1.54",
43
+ "@checkstack/scripts": "0.7.3",
44
+ "@checkstack/test-utils-backend": "0.1.55",
45
45
  "@checkstack/tsconfig": "0.0.7",
46
46
  "@types/bun": "^1.0.0",
47
+ "@types/pg": "^8.20.0",
47
48
  "drizzle-kit": "^0.31.10",
49
+ "pg": "^8.21.0",
48
50
  "typescript": "^5.0.0"
49
51
  }
50
52
  }
@@ -32,25 +32,25 @@ describe("incident.removeLink tool", () => {
32
32
  const rpcClient = fakeRpcClient({ removeLink });
33
33
  const tool = createIncidentRemoveLinkTool();
34
34
  const preview = await tool.dryRun!({
35
- input: { id: "lnk1" },
35
+ input: { id: "lnk1", incidentId: "inc1" },
36
36
  principal,
37
37
  rpcClient,
38
38
  });
39
39
  expect(removeLink).not.toHaveBeenCalled();
40
40
  expect(preview.summary).toContain("permanent");
41
- expect(preview.payload).toEqual({ id: "lnk1" });
41
+ expect(preview.payload).toEqual({ id: "lnk1", incidentId: "inc1" });
42
42
  });
43
43
 
44
- test("execute (apply) removes via removeLink with {id}", async () => {
44
+ test("execute (apply) removes via removeLink with {id, incidentId}", async () => {
45
45
  const removeLink = mock(() => Promise.resolve({ success: true }));
46
46
  const rpcClient = fakeRpcClient({ removeLink });
47
47
  const tool = createIncidentRemoveLinkTool();
48
48
  const result = await tool.execute({
49
- input: { id: "lnk1" },
49
+ input: { id: "lnk1", incidentId: "inc1" },
50
50
  principal,
51
51
  rpcClient,
52
52
  });
53
- expect(removeLink).toHaveBeenCalledWith({ id: "lnk1" });
53
+ expect(removeLink).toHaveBeenCalledWith({ id: "lnk1", incidentId: "inc1" });
54
54
  expect(result).toEqual({ id: "lnk1", removed: true });
55
55
  });
56
56
  });
@@ -9,9 +9,10 @@ import {
9
9
  import type { AiProposalPreview } from "@checkstack/ai-common";
10
10
  import type { RegisteredAiTool } from "@checkstack/ai-backend";
11
11
 
12
- /** Input for `incident.removeLink`: the link id to remove. */
12
+ /** Input for `incident.removeLink`: the link id to remove + its owning incident. */
13
13
  export const IncidentRemoveLinkInputSchema = z.object({
14
14
  id: z.string(),
15
+ incidentId: z.string(),
15
16
  });
16
17
  export type IncidentRemoveLinkInput = z.infer<
17
18
  typeof IncidentRemoveLinkInputSchema
@@ -45,15 +46,15 @@ export function createIncidentRemoveLinkTool(): RegisteredAiTool<
45
46
  rpcClient: RpcClient;
46
47
  }): Promise<AiProposalPreview<IncidentRemoveLinkInput>> => {
47
48
  return {
48
- summary: `Remove link ${input.id} from its incident. This is permanent.`,
49
- payload: { id: input.id },
49
+ summary: `Remove link ${input.id} from incident ${input.incidentId}. This is permanent.`,
50
+ payload: { id: input.id, incidentId: input.incidentId },
50
51
  };
51
52
  };
52
53
 
53
54
  return {
54
55
  name: "incident.removeLink",
55
56
  description:
56
- "Remove a hotlink from an incident by link id. DESTRUCTIVE and irreversible. Never removes directly; a person must approve the confirmation. Find the link id by reading the incident's details first.",
57
+ "Remove a hotlink from an incident by link id and its owning incidentId. DESTRUCTIVE and irreversible. Never removes directly; a person must approve the confirmation. Find the link id and incidentId by reading the incident's details first.",
57
58
  effect: "destructive",
58
59
  input: IncidentRemoveLinkInputSchema,
59
60
  requiredAccessRules: [
@@ -62,7 +63,10 @@ export function createIncidentRemoveLinkTool(): RegisteredAiTool<
62
63
  dryRun,
63
64
  async execute({ input, rpcClient }) {
64
65
  const incidentClient = rpcClient.forPlugin(IncidentApi);
65
- await incidentClient.removeLink({ id: input.id });
66
+ await incidentClient.removeLink({
67
+ id: input.id,
68
+ incidentId: input.incidentId,
69
+ });
66
70
  return { id: input.id, removed: true };
67
71
  },
68
72
  };
package/src/router.ts CHANGED
@@ -676,7 +676,7 @@ export function createRouter({
676
676
  }),
677
677
 
678
678
  removeLink: os.removeLink.handler(async ({ input }) => {
679
- const incidentId = await service.removeLink(input.id);
679
+ const incidentId = await service.removeLink(input.id, input.incidentId);
680
680
  if (!incidentId) {
681
681
  return { success: false };
682
682
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Integration test for IncidentService.removeLink against a REAL Postgres,
3
+ * exercising the anti-spoof WHERE clause (`WHERE id AND incidentId`) that the
4
+ * mocked-DB unit tests cannot. removeLink is authorized with `idParam:
5
+ * "incidentId"`, so the caller proves a grant on the PARENT incident. The
6
+ * service MUST additionally scope the delete by that incidentId, or a caller
7
+ * could pair a link id belonging to incident A with an incident B they manage
8
+ * and delete A's link (an IDOR). These tests prove the scoping holds at the SQL
9
+ * layer: a mismatched incidentId removes nothing and returns undefined; only a
10
+ * matched pair deletes.
11
+ *
12
+ * Gated on CHECKSTACK_IT so it runs in CI (shared compose Postgres) and is
13
+ * skipped in the default `bun test` run, matching the other *.it.test.ts here.
14
+ */
15
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
16
+ import { drizzle } from "drizzle-orm/node-postgres";
17
+ import { Pool } from "pg";
18
+ import type {
19
+ AdvisoryLockService,
20
+ SafeDatabase,
21
+ } from "@checkstack/backend-api";
22
+ import * as schema from "./schema";
23
+ import { IncidentService } from "./service";
24
+
25
+ const PG_URL =
26
+ process.env.CHECKSTACK_IT_PG_URL ??
27
+ "postgres://postgres:postgres@localhost:5432/postgres";
28
+ const SCHEMA = "incident_it_removelink";
29
+
30
+ // removeLink never touches the advisory lock, so a no-op faithful stub is all
31
+ // the constructor needs (no cast).
32
+ const noopAdvisoryLock: AdvisoryLockService = {
33
+ tryAcquire: async () => null,
34
+ withXactLock: async ({ fn }) => fn(),
35
+ };
36
+
37
+ let admin: Pool;
38
+ let pool: Pool;
39
+ let service: IncidentService;
40
+
41
+ async function insertLink(row: {
42
+ id: string;
43
+ incidentId: string;
44
+ url: string;
45
+ }): Promise<void> {
46
+ await pool.query(
47
+ `INSERT INTO "${SCHEMA}".incident_links (id, incident_id, url)
48
+ VALUES ($1, $2, $3)`,
49
+ [row.id, row.incidentId, row.url],
50
+ );
51
+ }
52
+
53
+ async function linkExists(id: string): Promise<boolean> {
54
+ const res = await pool.query(
55
+ `SELECT 1 FROM "${SCHEMA}".incident_links WHERE id = $1`,
56
+ [id],
57
+ );
58
+ return res.rowCount === 1;
59
+ }
60
+
61
+ describe.skipIf(!process.env.CHECKSTACK_IT)(
62
+ "IncidentService.removeLink anti-spoof scoping (shared Postgres)",
63
+ () => {
64
+ beforeAll(async () => {
65
+ admin = new Pool({ connectionString: PG_URL });
66
+ await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
67
+ await admin.query(`CREATE SCHEMA "${SCHEMA}"`);
68
+ // Only the incident_links table is exercised; no FK to incidents so the
69
+ // DDL stays minimal and focused on the WHERE-clause behavior.
70
+ await admin.query(
71
+ `CREATE TABLE "${SCHEMA}".incident_links (
72
+ id text PRIMARY KEY,
73
+ incident_id text NOT NULL,
74
+ label text,
75
+ url text NOT NULL,
76
+ created_at timestamp NOT NULL DEFAULT now()
77
+ )`,
78
+ );
79
+ pool = new Pool({
80
+ connectionString: PG_URL,
81
+ options: `-c search_path=${SCHEMA}`,
82
+ });
83
+ const db = drizzle(pool, {
84
+ schema,
85
+ }) as unknown as SafeDatabase<typeof schema>;
86
+ service = new IncidentService(db, noopAdvisoryLock);
87
+ });
88
+
89
+ afterAll(async () => {
90
+ await pool?.end();
91
+ await admin.query(`DROP SCHEMA IF EXISTS "${SCHEMA}" CASCADE`);
92
+ await admin.end();
93
+ });
94
+
95
+ beforeEach(async () => {
96
+ await pool.query(`TRUNCATE "${SCHEMA}".incident_links`);
97
+ });
98
+
99
+ it("removes the link and returns the incidentId when the pair matches", async () => {
100
+ await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
101
+
102
+ expect(await service.removeLink("lnk-1", "inc-1")).toBe("inc-1");
103
+ expect(await linkExists("lnk-1")).toBe(false);
104
+ });
105
+
106
+ it("removes nothing and returns undefined when the incidentId does not own the link", async () => {
107
+ // Link belongs to inc-1; the caller is authorized against inc-2 (a
108
+ // different incident they manage). The spoofed pair must NOT delete.
109
+ await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
110
+
111
+ expect(await service.removeLink("lnk-1", "inc-2")).toBeUndefined();
112
+ // The link is untouched.
113
+ expect(await linkExists("lnk-1")).toBe(true);
114
+ });
115
+
116
+ it("returns undefined for a link id that does not exist", async () => {
117
+ expect(await service.removeLink("missing", "inc-1")).toBeUndefined();
118
+ });
119
+
120
+ it("only deletes the link matching BOTH id and incidentId", async () => {
121
+ await insertLink({ id: "lnk-1", incidentId: "inc-1", url: "https://a" });
122
+ await insertLink({ id: "lnk-2", incidentId: "inc-2", url: "https://b" });
123
+
124
+ expect(await service.removeLink("lnk-1", "inc-1")).toBe("inc-1");
125
+ expect(await linkExists("lnk-1")).toBe(false);
126
+ // The other incident's link is untouched.
127
+ expect(await linkExists("lnk-2")).toBe(true);
128
+ });
129
+ },
130
+ );
package/src/service.ts CHANGED
@@ -554,13 +554,22 @@ export class IncidentService {
554
554
  * invalidate the right cache entry, or undefined if the link did not
555
555
  * exist.
556
556
  */
557
- async removeLink(id: string): Promise<string | undefined> {
557
+ async removeLink(id: string, incidentId: string): Promise<string | undefined> {
558
+ // Scope by incidentId: the caller is authorized (idParam) against THIS
559
+ // incident, so a link belonging to a different incident must not be
560
+ // removable by pairing its link id with an incident the caller manages.
558
561
  const [existing] = await this.db
559
562
  .select()
560
563
  .from(incidentLinks)
561
- .where(eq(incidentLinks.id, id));
564
+ .where(
565
+ and(eq(incidentLinks.id, id), eq(incidentLinks.incidentId, incidentId)),
566
+ );
562
567
  if (!existing) return undefined;
563
- await this.db.delete(incidentLinks).where(eq(incidentLinks.id, id));
568
+ await this.db
569
+ .delete(incidentLinks)
570
+ .where(
571
+ and(eq(incidentLinks.id, id), eq(incidentLinks.incidentId, incidentId)),
572
+ );
564
573
  return existing.incidentId;
565
574
  }
566
575