@checkstack/healthcheck-backend 1.20.1 → 1.21.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.
@@ -0,0 +1,260 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { createHealthCheckRouter } from "./router";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import type { RpcContext } from "@checkstack/backend-api";
5
+ import { call } from "@orpc/server";
6
+ import { createStubHealthCheckCache } from "./cache-test-stub";
7
+
8
+ /**
9
+ * Router-level tests for the HANDLER-side authorization of the
10
+ * configuration-centric assignment reads (`getConfigurationAssignments`) and
11
+ * the relaxed single-configuration read (`getConfiguration`). Their contract
12
+ * `access` is deliberately empty (see assignment-access.ts), so these tests
13
+ * are the regression guard that the router actually enforces the rule.
14
+ */
15
+
16
+ const passthroughCache = createStubHealthCheckCache();
17
+
18
+ const CONFIG_ID = "12345678-1234-4234-8234-123456789012";
19
+
20
+ const CONFIG_ROW = {
21
+ id: CONFIG_ID,
22
+ name: "Payments API root",
23
+ strategyId: "healthcheck-http.http",
24
+ config: { url: "https://api.example.com/healthz" },
25
+ collectors: null,
26
+ intervalSeconds: 60,
27
+ paused: false,
28
+ createdAt: new Date(),
29
+ updatedAt: new Date(),
30
+ };
31
+
32
+ const ASSIGNMENT_ROWS = [
33
+ {
34
+ systemId: "sys-1",
35
+ enabled: true,
36
+ stateThresholds: null,
37
+ satelliteIds: null,
38
+ environmentIds: null,
39
+ includeLocal: true,
40
+ notificationPolicy: null,
41
+ },
42
+ {
43
+ systemId: "sys-2",
44
+ enabled: false,
45
+ stateThresholds: null,
46
+ satelliteIds: ["sat-1"],
47
+ environmentIds: [],
48
+ includeLocal: false,
49
+ notificationPolicy: null,
50
+ },
51
+ ];
52
+
53
+ /**
54
+ * A DB serving the three selects these handlers issue, disambiguated by the
55
+ * projected columns: `select()` (no projection) resolves the configuration
56
+ * row; a projection containing `enabled` resolves the full assignment rows; a
57
+ * projection of only `systemId` resolves the assigned-id list.
58
+ */
59
+ function createReadOnlyDb() {
60
+ return {
61
+ select: (projection?: Record<string, unknown>) => ({
62
+ from: () => {
63
+ const rows =
64
+ projection === undefined
65
+ ? [CONFIG_ROW]
66
+ : "enabled" in projection
67
+ ? ASSIGNMENT_ROWS
68
+ : ASSIGNMENT_ROWS.map((row) => ({ systemId: row.systemId }));
69
+ return Object.assign(Promise.resolve(rows), {
70
+ where: () => Promise.resolve(rows),
71
+ });
72
+ },
73
+ }),
74
+ };
75
+ }
76
+
77
+ function buildRouter() {
78
+ return createHealthCheckRouter({
79
+ database: createReadOnlyDb() as never,
80
+ registry: { getStrategy: mock(() => undefined) } as never,
81
+ collectorRegistry: { getCollector: mock(() => undefined) } as never,
82
+ gitOpsClient: {
83
+ getProvenance: mock(() => Promise.resolve(null)),
84
+ } as never,
85
+ getEmitHook: () => undefined,
86
+ cache: passthroughCache,
87
+ configService: {
88
+ get: mock(async () => undefined),
89
+ set: mock(async () => {}),
90
+ } as never,
91
+ catalogClient: {
92
+ getSystems: mock(async () => ({
93
+ systems: [
94
+ { id: "sys-1", name: "Payments" },
95
+ { id: "sys-2", name: "Checkout" },
96
+ ],
97
+ })),
98
+ } as never,
99
+ maintenanceClient: {
100
+ hasActiveMaintenance: mock(async () => ({ active: false })),
101
+ } as never,
102
+ logger: {
103
+ debug: mock(() => {}),
104
+ info: mock(() => {}),
105
+ warn: mock(() => {}),
106
+ error: mock(() => {}),
107
+ } as never,
108
+ });
109
+ }
110
+
111
+ const teamUser = {
112
+ type: "user" as const,
113
+ id: "team-user",
114
+ accessRules: [] as string[],
115
+ };
116
+
117
+ /** An auth override where the caller holds the given grants and nothing else. */
118
+ function authOverride({
119
+ configGrant,
120
+ readableSystemIds,
121
+ }: {
122
+ configGrant: boolean;
123
+ readableSystemIds: string[];
124
+ }): Partial<RpcContext> {
125
+ return {
126
+ auth: {
127
+ check: mock(async ({ objectType }: { objectType: string }) => ({
128
+ hasAccess: objectType === "healthcheck.healthcheck" && configGrant,
129
+ })),
130
+ listAccessibleObjectIds: mock(
131
+ async ({ objectIds }: { objectIds: string[] }) =>
132
+ objectIds.filter((id) => readableSystemIds.includes(id)),
133
+ ),
134
+ } as unknown as RpcContext["auth"],
135
+ };
136
+ }
137
+
138
+ describe("getConfigurationAssignments authorization", () => {
139
+ it("global configuration.read sees every row with resolved system names", async () => {
140
+ const router = buildRouter();
141
+ const context = createMockRpcContext({
142
+ user: {
143
+ type: "user",
144
+ id: "global-reader",
145
+ accessRules: ["healthcheck.healthcheck.read"],
146
+ },
147
+ });
148
+
149
+ const rows = await call(
150
+ router.getConfigurationAssignments,
151
+ { configId: CONFIG_ID },
152
+ { context },
153
+ );
154
+
155
+ expect(rows.map((r) => r.systemName).toSorted()).toEqual([
156
+ "Checkout",
157
+ "Payments",
158
+ ]);
159
+ // null environmentIds (= all envs) must survive the trip untouched.
160
+ expect(rows.find((r) => r.systemId === "sys-1")?.environmentIds).toBeNull();
161
+ expect(rows.find((r) => r.systemId === "sys-2")?.environmentIds).toEqual(
162
+ [],
163
+ );
164
+ });
165
+
166
+ it("a team grant on the CONFIGURATION sees every row", async () => {
167
+ const router = buildRouter();
168
+ const context = createMockRpcContext({
169
+ user: teamUser,
170
+ ...authOverride({ configGrant: true, readableSystemIds: [] }),
171
+ });
172
+
173
+ const rows = await call(
174
+ router.getConfigurationAssignments,
175
+ { configId: CONFIG_ID },
176
+ { context },
177
+ );
178
+ expect(rows).toHaveLength(2);
179
+ });
180
+
181
+ it("a system-only caller sees ONLY their systems' rows", async () => {
182
+ const router = buildRouter();
183
+ const context = createMockRpcContext({
184
+ user: teamUser,
185
+ ...authOverride({ configGrant: false, readableSystemIds: ["sys-2"] }),
186
+ });
187
+
188
+ const rows = await call(
189
+ router.getConfigurationAssignments,
190
+ { configId: CONFIG_ID },
191
+ { context },
192
+ );
193
+ expect(rows).toHaveLength(1);
194
+ expect(rows[0].systemId).toBe("sys-2");
195
+ expect(rows[0].systemName).toBe("Checkout");
196
+ });
197
+
198
+ it("a caller with no grant of either kind is FORBIDDEN", async () => {
199
+ const router = buildRouter();
200
+ const context = createMockRpcContext({
201
+ user: teamUser,
202
+ ...authOverride({ configGrant: false, readableSystemIds: [] }),
203
+ });
204
+
205
+ await expect(
206
+ call(
207
+ router.getConfigurationAssignments,
208
+ { configId: CONFIG_ID },
209
+ { context },
210
+ ),
211
+ ).rejects.toThrow(/FORBIDDEN|read access/i);
212
+ });
213
+ });
214
+
215
+ describe("getConfiguration relaxed authorization", () => {
216
+ it("a team grant on the configuration reads it", async () => {
217
+ const router = buildRouter();
218
+ const context = createMockRpcContext({
219
+ user: teamUser,
220
+ ...authOverride({ configGrant: true, readableSystemIds: [] }),
221
+ });
222
+
223
+ const config = await call(
224
+ router.getConfiguration,
225
+ { id: CONFIG_ID },
226
+ { context },
227
+ );
228
+ expect(config?.name).toBe("Payments API root");
229
+ });
230
+
231
+ it("a reader of an ASSIGNED system reads it", async () => {
232
+ const router = buildRouter();
233
+ const context = createMockRpcContext({
234
+ user: teamUser,
235
+ ...authOverride({ configGrant: false, readableSystemIds: ["sys-1"] }),
236
+ });
237
+
238
+ const config = await call(
239
+ router.getConfiguration,
240
+ { id: CONFIG_ID },
241
+ { context },
242
+ );
243
+ expect(config?.name).toBe("Payments API root");
244
+ });
245
+
246
+ it("an unrelated caller gets the same `undefined` as a missing id (no existence leak)", async () => {
247
+ const router = buildRouter();
248
+ const context = createMockRpcContext({
249
+ user: teamUser,
250
+ ...authOverride({ configGrant: false, readableSystemIds: [] }),
251
+ });
252
+
253
+ const config = await call(
254
+ router.getConfiguration,
255
+ { id: CONFIG_ID },
256
+ { context },
257
+ );
258
+ expect(config).toBeUndefined();
259
+ });
260
+ });
package/src/router.ts CHANGED
@@ -28,6 +28,13 @@ import {
28
28
  listTeamManageableConfigurationIds,
29
29
  resolveHistoryScope,
30
30
  } from "./history-access";
31
+ import {
32
+ canReadConfigurationScope,
33
+ hasConfigurationReadGrant,
34
+ hasGlobalConfigurationRead,
35
+ listReadableSystemIds,
36
+ resolveAssignmentRowScope,
37
+ } from "./assignment-access";
31
38
  import { collectConfigurationIssues } from "./validate-configuration";
32
39
  import { runCollectorScriptTest } from "./collector-script-test";
33
40
  import { healthCheckHooks } from "./hooks";
@@ -340,9 +347,26 @@ export const createHealthCheckRouter = (opts: {
340
347
  return { configurations: await service.getConfigurationsRedacted() };
341
348
  }),
342
349
 
343
- getConfiguration: os.getConfiguration.handler(async ({ input }) => {
344
- return service.getConfigurationRedacted(input.id);
345
- }),
350
+ getConfiguration: os.getConfiguration.handler(
351
+ async ({ input, context }) => {
352
+ // Handler-side authorization (the contract's `access` is deliberately
353
+ // empty - see the contract doc): global configuration read, a team
354
+ // grant on the configuration, or read access to an ASSIGNED system.
355
+ // An unauthorized caller gets the same `undefined` as a missing id,
356
+ // so configuration ids don't leak existence. Fail-closed via
357
+ // assignment-access.ts.
358
+ const configuration = await service.getConfigurationRedacted(input.id);
359
+ if (!configuration) return;
360
+ const allowed = await canReadConfigurationScope({
361
+ auth: context.auth,
362
+ user: context.user,
363
+ configurationId: input.id,
364
+ getAssignedSystemIds: () => service.getAssignedSystemIds(input.id),
365
+ });
366
+ if (!allowed) return;
367
+ return configuration;
368
+ },
369
+ ),
346
370
 
347
371
  createConfiguration: os.createConfiguration.handler(async ({ input }) => {
348
372
  const created = await service.createConfiguration(input);
@@ -494,6 +518,66 @@ export const createHealthCheckRouter = (opts: {
494
518
  },
495
519
  ),
496
520
 
521
+ getConfigurationAssignments: os.getConfigurationAssignments.handler(
522
+ async ({ input, context }) => {
523
+ // Handler-side authorization (the contract's `access` is deliberately
524
+ // empty - see the contract doc): global configuration read or a team
525
+ // grant on the CONFIGURATION sees every row; otherwise rows are
526
+ // filtered to the systems the caller may read; neither is forbidden.
527
+ // Fail-closed via assignment-access.ts.
528
+ const user = context.user;
529
+ const rows = await service.getConfigurationAssignments(input.configId);
530
+
531
+ let visibleRows = rows;
532
+ if (!hasGlobalConfigurationRead(user)) {
533
+ if (!user || (user.type !== "user" && user.type !== "application")) {
534
+ throw new ORPCError("FORBIDDEN", {
535
+ message:
536
+ "Assignment details require health check read access (globally, or via a team grant on the configuration or an assigned system)",
537
+ });
538
+ }
539
+ const hasConfigGrant = await hasConfigurationReadGrant({
540
+ auth: context.auth,
541
+ user,
542
+ configurationId: input.configId,
543
+ });
544
+ const readableSystemIds = hasConfigGrant
545
+ ? []
546
+ : await listReadableSystemIds({
547
+ auth: context.auth,
548
+ user,
549
+ allSystemIds: rows.map((row) => row.systemId),
550
+ });
551
+ const scope = resolveAssignmentRowScope({
552
+ user,
553
+ hasConfigurationGrant: hasConfigGrant,
554
+ readableSystemIds,
555
+ });
556
+ if (scope.kind === "forbidden") {
557
+ throw new ORPCError("FORBIDDEN", {
558
+ message:
559
+ "Assignment details require health check read access (globally, or via a team grant on the configuration or an assigned system)",
560
+ });
561
+ }
562
+ if (scope.kind === "scoped") {
563
+ const readable = new Set(scope.systemIds);
564
+ visibleRows = rows.filter((row) => readable.has(row.systemId));
565
+ }
566
+ }
567
+
568
+ if (visibleRows.length === 0) return [];
569
+
570
+ // Resolve system display names in ONE trusted S2S call (names live in
571
+ // the catalog plugin); fall back to the id if a system vanished.
572
+ const { systems } = await catalogClient.getSystems();
573
+ const nameById = new Map(systems.map((s) => [s.id, s.name]));
574
+ return visibleRows.map((row) => ({
575
+ ...row,
576
+ systemName: nameById.get(row.systemId) ?? row.systemId,
577
+ }));
578
+ },
579
+ ),
580
+
497
581
  getBulkAssignedHealthCheckCounts:
498
582
  os.getBulkAssignedHealthCheckCounts.handler(async ({ input }) => {
499
583
  // ONE grouped query for the whole visible system list (replaces the
@@ -505,6 +589,21 @@ export const createHealthCheckRouter = (opts: {
505
589
  return { counts };
506
590
  }),
507
591
 
592
+ resolveEnqueueEnvironments: os.resolveEnqueueEnvironments.handler(
593
+ async ({ input }) => {
594
+ // Delegates to the same resolution the run_now automation + scheduler
595
+ // use, so a cross-plugin one-off run (e.g. logstream fast-path) targets
596
+ // exactly the environment slices the executor accepts.
597
+ const environmentIds = await service.resolveEnqueueEnvironmentIds({
598
+ systemId: input.systemId,
599
+ configurationId: input.configId,
600
+ catalogClient,
601
+ logger,
602
+ });
603
+ return { environmentIds };
604
+ },
605
+ ),
606
+
508
607
  associateSystem: os.associateSystem.handler(async ({ input, context }) => {
509
608
  await enforceNotGitOpsLocked("System", input.systemId);
510
609
  await service.associateSystem({
package/src/service.ts CHANGED
@@ -989,6 +989,63 @@ export class HealthCheckService {
989
989
  return results;
990
990
  }
991
991
 
992
+ /**
993
+ * Configuration-centric inverse of {@link getSystemAssociations}: the
994
+ * per-system assignment rows of ONE configuration. System NAMES are
995
+ * resolved by the router via the catalog S2S client (they live in the
996
+ * catalog plugin's storage); rows here carry ids only.
997
+ */
998
+ async getConfigurationAssignments(configurationId: string) {
999
+ const rows = await this.db
1000
+ .select({
1001
+ systemId: systemHealthChecks.systemId,
1002
+ enabled: systemHealthChecks.enabled,
1003
+ stateThresholds: systemHealthChecks.stateThresholds,
1004
+ satelliteIds: systemHealthChecks.satelliteIds,
1005
+ environmentIds: systemHealthChecks.environmentIds,
1006
+ includeLocal: systemHealthChecks.includeLocal,
1007
+ notificationPolicy: systemHealthChecks.notificationPolicy,
1008
+ })
1009
+ .from(systemHealthChecks)
1010
+ .where(eq(systemHealthChecks.configurationId, configurationId));
1011
+
1012
+ // Migrate and extract thresholds for each assignment (same treatment as
1013
+ // getSystemAssociations).
1014
+ const results = [];
1015
+ for (const row of rows) {
1016
+ let thresholds: StateThresholds | undefined;
1017
+ if (row.stateThresholds) {
1018
+ thresholds = await stateThresholds.parse(row.stateThresholds);
1019
+ }
1020
+ results.push({
1021
+ systemId: row.systemId,
1022
+ enabled: row.enabled,
1023
+ stateThresholds: thresholds,
1024
+ satelliteIds: row.satelliteIds ?? undefined,
1025
+ // Preserve the null/[]/list distinction (null = all envs, [] = opt
1026
+ // out). Do NOT collapse null to undefined via `??`.
1027
+ environmentIds: row.environmentIds,
1028
+ includeLocal: row.includeLocal,
1029
+ notificationPolicy: row.notificationPolicy ?? undefined,
1030
+ });
1031
+ }
1032
+ return results;
1033
+ }
1034
+
1035
+ /**
1036
+ * The system ids a configuration is assigned to. Authorization helper for
1037
+ * the relaxed `getConfiguration` read (see assignment-access.ts) - kept as
1038
+ * a bare id select so the auth path never pays for the full assignment
1039
+ * rows.
1040
+ */
1041
+ async getAssignedSystemIds(configurationId: string): Promise<string[]> {
1042
+ const rows = await this.db
1043
+ .select({ systemId: systemHealthChecks.systemId })
1044
+ .from(systemHealthChecks)
1045
+ .where(eq(systemHealthChecks.configurationId, configurationId));
1046
+ return rows.map((row) => row.systemId);
1047
+ }
1048
+
992
1049
  /**
993
1050
  * Count of health-check assignments for each of `systemIds`, keyed by
994
1051
  * systemId. ONE grouped query (no per-system fan-out) so the catalog manager
@@ -2659,25 +2716,37 @@ export class HealthCheckService {
2659
2716
 
2660
2717
  if (matchingAssociations.length === 0) return [];
2661
2718
 
2662
- // Resolve human-readable system names once per distinct systemId.
2663
- // Falls back to the systemId when no catalog client is wired or the
2664
- // lookup fails, mirroring the queue-executor's resolution behaviour.
2665
- const systemNameCache = new Map<string, string>();
2666
- const resolveSystemName = async (systemId: string): Promise<string> => {
2667
- const cached = systemNameCache.get(systemId);
2719
+ // Resolve human-readable system name + free-form metadata once per distinct
2720
+ // systemId. Falls back to the systemId (and empty metadata) when no catalog
2721
+ // client is wired or the lookup fails, mirroring the queue-executor's
2722
+ // resolution behaviour. The metadata rides the assignment so satellite runs
2723
+ // template `{{ system.metadata.<key> }}` identically to local runs.
2724
+ const systemCache = new Map<
2725
+ string,
2726
+ { name: string; metadata: Record<string, unknown> }
2727
+ >();
2728
+ const resolveSystem = async (
2729
+ systemId: string,
2730
+ ): Promise<{ name: string; metadata: Record<string, unknown> }> => {
2731
+ const cached = systemCache.get(systemId);
2668
2732
  if (cached !== undefined) return cached;
2669
2733
 
2670
- let systemName = systemId;
2734
+ let resolved: { name: string; metadata: Record<string, unknown> } = {
2735
+ name: systemId,
2736
+ metadata: {},
2737
+ };
2671
2738
  if (this.catalogClient) {
2672
2739
  try {
2673
2740
  const system = await this.catalogClient.getSystem({ systemId });
2674
- if (system) systemName = system.name;
2741
+ if (system) {
2742
+ resolved = { name: system.name, metadata: system.metadata ?? {} };
2743
+ }
2675
2744
  } catch {
2676
2745
  // Fall back to systemId if catalog lookup fails.
2677
2746
  }
2678
2747
  }
2679
- systemNameCache.set(systemId, systemName);
2680
- return systemName;
2748
+ systemCache.set(systemId, resolved);
2749
+ return resolved;
2681
2750
  };
2682
2751
 
2683
2752
  // Get configurations for each matching association
@@ -2690,6 +2759,7 @@ export class HealthCheckService {
2690
2759
 
2691
2760
  if (!config || config.paused) continue;
2692
2761
 
2762
+ const system = await resolveSystem(assoc.systemId);
2693
2763
  assignments.push({
2694
2764
  configId: config.id,
2695
2765
  systemId: assoc.systemId,
@@ -2699,7 +2769,8 @@ export class HealthCheckService {
2699
2769
  intervalSeconds: config.intervalSeconds,
2700
2770
  // Curated run-context metadata exposed to satellite collectors.
2701
2771
  configName: config.name,
2702
- systemName: await resolveSystemName(assoc.systemId),
2772
+ systemName: system.name,
2773
+ systemMetadata: system.metadata,
2703
2774
  });
2704
2775
  }
2705
2776