@checkstack/healthcheck-backend 1.20.0 → 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,205 @@
1
+ import { qualifyAccessRuleId } from "@checkstack/common";
2
+ import type { AuthUser, AuthService } from "@checkstack/backend-api";
3
+ import {
4
+ healthCheckAccess,
5
+ healthCheckResourceTypes,
6
+ pluginMetadata,
7
+ } from "@checkstack/healthcheck-common";
8
+ import { catalogResourceTypes } from "@checkstack/catalog-common";
9
+
10
+ /**
11
+ * Authorization for the configuration-centric assignment reads
12
+ * (`getConfigurationAssignments`, and the relaxed `getConfiguration`). Their
13
+ * contract `access` is deliberately empty - the rule is an OR across TWO grant
14
+ * planes (a team grant on the CONFIGURATION, or read access to an assigned
15
+ * SYSTEM) that the middleware's instanceAccess modes cannot express - so THIS
16
+ * module is the authorization for them. Sibling of `history-access.ts`, which
17
+ * does the same for the run-history procs at manage level.
18
+ */
19
+
20
+ const QUALIFIED_READ_RULE = qualifyAccessRuleId(
21
+ pluginMetadata,
22
+ healthCheckAccess.configuration.read,
23
+ );
24
+ const QUALIFIED_MANAGE_RULE = qualifyAccessRuleId(
25
+ pluginMetadata,
26
+ healthCheckAccess.configuration.manage,
27
+ );
28
+
29
+ /**
30
+ * The parent (catalog.system) global rules. Follows the middleware's
31
+ * parentScope convention: authorizing via a parent consults the parent's own
32
+ * grants AND its global `{resourceType}.{action}` rule. Manage implies read.
33
+ */
34
+ const QUALIFIED_SYSTEM_READ_RULE = `${catalogResourceTypes.system}.read`;
35
+ const QUALIFIED_SYSTEM_MANAGE_RULE = `${catalogResourceTypes.system}.manage`;
36
+
37
+ /** A user/application principal (the only kinds that can hold team grants). */
38
+ type GrantHolder = AuthUser & { type: "user" | "application" };
39
+
40
+ const isGrantHolder = (user: AuthUser): user is GrantHolder =>
41
+ user.type === "user" || user.type === "application";
42
+
43
+ /**
44
+ * Does the caller hold UNRESTRICTED configuration read access? True for
45
+ * wildcard admins, global `configuration.read` OR `configuration.manage`
46
+ * holders (manage implies read), and trusted services (same stance as the
47
+ * middleware).
48
+ */
49
+ export function hasGlobalConfigurationRead(
50
+ user: AuthUser | undefined,
51
+ ): boolean {
52
+ if (!user) return false;
53
+ if (user.type === "service") return true;
54
+ const rules = user.accessRules ?? [];
55
+ return (
56
+ rules.includes("*") ||
57
+ rules.includes(QUALIFIED_READ_RULE) ||
58
+ rules.includes(QUALIFIED_MANAGE_RULE)
59
+ );
60
+ }
61
+
62
+ /** The outcome of authorizing the assignment-row read for the caller. */
63
+ export type AssignmentRowScope =
64
+ /** Global read (or wildcard/service/config grant): every row is visible. */
65
+ | { kind: "all" }
66
+ /**
67
+ * System-scoped caller: restrict rows to the systems their teams may READ
68
+ * (a system's team sees which checks run on their system).
69
+ */
70
+ | { kind: "scoped"; systemIds: string[] }
71
+ /** No global rule and no grant of either kind: the read is forbidden. */
72
+ | { kind: "forbidden" };
73
+
74
+ /**
75
+ * Pure decision half of the row authorization: given the caller, whether they
76
+ * hold a team grant on the configuration, and the system ids their teams may
77
+ * read, decide the row scope. Split from the S2S lookups so the branching is
78
+ * unit-testable without an auth service.
79
+ */
80
+ export function resolveAssignmentRowScope({
81
+ user,
82
+ hasConfigurationGrant,
83
+ readableSystemIds,
84
+ }: {
85
+ user: AuthUser | undefined;
86
+ hasConfigurationGrant: boolean;
87
+ readableSystemIds: string[];
88
+ }): AssignmentRowScope {
89
+ if (!user) return { kind: "forbidden" };
90
+ if (hasGlobalConfigurationRead(user)) return { kind: "all" };
91
+ if (hasConfigurationGrant) return { kind: "all" };
92
+ if (readableSystemIds.length === 0) return { kind: "forbidden" };
93
+ return { kind: "scoped", systemIds: readableSystemIds };
94
+ }
95
+
96
+ /**
97
+ * Does the caller's team hold a READ grant on the configuration itself?
98
+ * FAILS CLOSED: an S2S error yields `false`, i.e. narrower access, never
99
+ * wider.
100
+ */
101
+ export async function hasConfigurationReadGrant({
102
+ auth,
103
+ user,
104
+ configurationId,
105
+ }: {
106
+ auth: AuthService;
107
+ user: AuthUser;
108
+ configurationId: string;
109
+ }): Promise<boolean> {
110
+ if (!isGrantHolder(user)) return false;
111
+ try {
112
+ const result = await auth.check({
113
+ userId: user.id,
114
+ userType: user.type,
115
+ objectType: healthCheckResourceTypes.configuration,
116
+ objectId: configurationId,
117
+ action: "read",
118
+ hasGlobalAccess: false,
119
+ });
120
+ return result.hasAccess;
121
+ } catch {
122
+ // SECURITY: fail closed - an auth outage must not widen access.
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Resolve which of `allSystemIds` the caller may READ - via a team grant on
129
+ * the system, or via the global `catalog.system.read`/`.manage` rule (the
130
+ * parentScope convention). FAILS CLOSED on S2S errors.
131
+ */
132
+ export async function listReadableSystemIds({
133
+ auth,
134
+ user,
135
+ allSystemIds,
136
+ }: {
137
+ auth: AuthService;
138
+ user: AuthUser;
139
+ allSystemIds: string[];
140
+ }): Promise<string[]> {
141
+ if (!isGrantHolder(user)) return [];
142
+ if (allSystemIds.length === 0) return [];
143
+ const rules = user.accessRules ?? [];
144
+ if (
145
+ rules.includes(QUALIFIED_SYSTEM_READ_RULE) ||
146
+ rules.includes(QUALIFIED_SYSTEM_MANAGE_RULE)
147
+ ) {
148
+ return allSystemIds;
149
+ }
150
+ try {
151
+ return await auth.listAccessibleObjectIds({
152
+ userId: user.id,
153
+ userType: user.type,
154
+ objectType: catalogResourceTypes.system,
155
+ objectIds: allSystemIds,
156
+ action: "read",
157
+ hasGlobalAccess: false,
158
+ });
159
+ } catch {
160
+ // SECURITY: fail closed - an auth outage must not widen access.
161
+ return [];
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Per-object check for the relaxed single-configuration read: the caller may
167
+ * read the configuration iff they hold global configuration read, a team READ
168
+ * grant on the configuration, or READ access to at least one system the
169
+ * configuration is assigned to (a system's team may inspect the checks that
170
+ * run on their system - the same exposure `getSystemConfigurations` already
171
+ * allows). `getAssignedSystemIds` is lazy so the assignment lookup only runs
172
+ * when the cheaper checks did not already decide. FAILS CLOSED on S2S errors.
173
+ */
174
+ export async function canReadConfigurationScope({
175
+ auth,
176
+ user,
177
+ configurationId,
178
+ getAssignedSystemIds,
179
+ }: {
180
+ auth: AuthService;
181
+ user: AuthUser | undefined;
182
+ configurationId: string;
183
+ getAssignedSystemIds: () => Promise<string[]>;
184
+ }): Promise<boolean> {
185
+ if (!user) return false;
186
+ if (hasGlobalConfigurationRead(user)) return true;
187
+ if (!isGrantHolder(user)) return false;
188
+
189
+ if (await hasConfigurationReadGrant({ auth, user, configurationId })) {
190
+ return true;
191
+ }
192
+
193
+ try {
194
+ const assignedSystemIds = await getAssignedSystemIds();
195
+ const readable = await listReadableSystemIds({
196
+ auth,
197
+ user,
198
+ allSystemIds: assignedSystemIds,
199
+ });
200
+ return readable.length > 0;
201
+ } catch {
202
+ // SECURITY: fail closed - an auth outage must not widen access.
203
+ return false;
204
+ }
205
+ }
@@ -83,6 +83,29 @@ describe("buildShellRunContextEnv", () => {
83
83
  });
84
84
  expect(env.CHECKSTACK_ENV_BASE_URL).toBe("first");
85
85
  });
86
+
87
+ test("emits CHECKSTACK_SYSTEM_<FIELD> vars for the system's custom fields", () => {
88
+ const env = buildShellRunContextEnv({
89
+ system: {
90
+ id: "s1",
91
+ name: "web-1",
92
+ metadata: { baseUrl: "https://sys.example.com", tier: "1" },
93
+ },
94
+ });
95
+ expect(env).toEqual({
96
+ CHECKSTACK_SYSTEM_ID: "s1",
97
+ CHECKSTACK_SYSTEM_NAME: "web-1",
98
+ CHECKSTACK_SYSTEM_BASE_URL: "https://sys.example.com",
99
+ CHECKSTACK_SYSTEM_TIER: "1",
100
+ });
101
+ });
102
+
103
+ test("a system field named id/name cannot clobber the structural var", () => {
104
+ const env = buildShellRunContextEnv({
105
+ system: { id: "s1", name: "web-1", metadata: { name: "nope" } },
106
+ });
107
+ expect(env.CHECKSTACK_SYSTEM_NAME).toBe("web-1");
108
+ });
86
109
  });
87
110
 
88
111
  describe("buildCollectorContext", () => {
@@ -126,6 +149,20 @@ describe("buildCollectorContext", () => {
126
149
  },
127
150
  });
128
151
  });
152
+
153
+ test("passes the system's metadata through to context.system", () => {
154
+ const ctx = buildCollectorContext({
155
+ config: {},
156
+ runContext: {
157
+ system: { id: "s1", name: "web-1", metadata: { baseUrl: "https://s" } },
158
+ },
159
+ });
160
+ expect(ctx.system).toEqual({
161
+ id: "s1",
162
+ name: "web-1",
163
+ metadata: { baseUrl: "https://s" },
164
+ });
165
+ });
129
166
  });
130
167
 
131
168
  describe("runCollectorScriptTest — typescript", () => {
@@ -32,7 +32,13 @@ export type CollectorScriptTestKind = "typescript" | "shell";
32
32
  /** Curated check/system/environment metadata a collector script can read. */
33
33
  export interface CollectorTestRunContext {
34
34
  check?: { id: string; name: string; intervalSeconds: number };
35
- system?: { id: string; name: string };
35
+ /**
36
+ * The system being checked. `metadata` is its free-form custom fields,
37
+ * mirroring the runtime `CollectorRunContext.system` so the test panel
38
+ * previews the `CHECKSTACK_SYSTEM_*` / `context.system.metadata` surface the
39
+ * real run exposes.
40
+ */
41
+ system?: { id: string; name: string; metadata?: Record<string, unknown> };
36
42
  /**
37
43
  * The resolved environment for the previewed run. `fields` is the
38
44
  * environment's free-form custom metadata. Mirrors the runtime
@@ -85,22 +91,49 @@ export interface CollectorScriptTestDeps {
85
91
  }
86
92
 
87
93
  const CHECKSTACK_ENV_PREFIX = "CHECKSTACK_ENV_";
94
+ const CHECKSTACK_SYSTEM_PREFIX = "CHECKSTACK_SYSTEM_";
88
95
 
89
96
  /**
90
- * Derive the `CHECKSTACK_ENV_<KEY>` shell var name for a custom field key.
91
- * Mirrors `toEnvFieldShellKey` in `@checkstack/healthcheck-script-backend`
92
- * (kept local - we don't import across plugins) so the test panel and the
93
- * real run produce identical var names. Splits camelCase, uppercases,
94
- * collapses non-alphanumeric runs to `_`, trims leading/trailing `_` using a
95
- * ReDoS-safe negative look-behind.
97
+ * Derive the `<prefix><KEY>` shell var name for a custom field key. Mirrors
98
+ * `toFieldShellKey` in `@checkstack/healthcheck-script-backend` (kept local -
99
+ * we don't import across plugins) so the test panel and the real run produce
100
+ * identical var names. Splits camelCase, uppercases, collapses non-alphanumeric
101
+ * runs to `_`, trims leading/trailing `_` using a ReDoS-safe negative
102
+ * look-behind.
96
103
  */
97
- function toEnvFieldShellKey(key: string): string {
104
+ function toFieldShellKey(key: string, prefix: string): string {
98
105
  const normalized = key
99
106
  .replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
100
107
  .toUpperCase()
101
108
  .replaceAll(/[^A-Z0-9]+/g, "_")
102
109
  .replaceAll(/^_+|(?<!_)_+$/g, "");
103
- return `${CHECKSTACK_ENV_PREFIX}${normalized}`;
110
+ return `${prefix}${normalized}`;
111
+ }
112
+
113
+ /**
114
+ * Expand a set of custom fields into `<prefix><KEY>` shell vars, mirroring
115
+ * `buildFieldShellEnv` in the script plugin: skip empty-normalized names, skip
116
+ * the reserved structural id/name vars, first-key-wins on collisions.
117
+ */
118
+ function addFieldShellVars({
119
+ env,
120
+ fields,
121
+ prefix,
122
+ }: {
123
+ env: Record<string, string>;
124
+ fields: Record<string, unknown>;
125
+ prefix: string;
126
+ }): void {
127
+ const reserved = new Set([`${prefix}ID`, `${prefix}NAME`]);
128
+ const claimed = new Set<string>();
129
+ for (const [key, value] of Object.entries(fields)) {
130
+ const shellKey = toFieldShellKey(key, prefix);
131
+ if (shellKey === prefix || reserved.has(shellKey) || claimed.has(shellKey)) {
132
+ continue;
133
+ }
134
+ env[shellKey] = stringifyFieldValue(value);
135
+ claimed.add(shellKey);
136
+ }
104
137
  }
105
138
 
106
139
  /** Stringify a custom-field value for a shell env var. */
@@ -135,17 +168,22 @@ export function buildShellRunContextEnv(
135
168
  if (runContext?.system) {
136
169
  env.CHECKSTACK_SYSTEM_ID = runContext.system.id;
137
170
  env.CHECKSTACK_SYSTEM_NAME = runContext.system.name;
171
+ if (runContext.system.metadata) {
172
+ addFieldShellVars({
173
+ env,
174
+ fields: runContext.system.metadata,
175
+ prefix: CHECKSTACK_SYSTEM_PREFIX,
176
+ });
177
+ }
138
178
  }
139
179
  if (runContext?.environment) {
140
180
  env.CHECKSTACK_ENV_ID = runContext.environment.id;
141
181
  env.CHECKSTACK_ENV_NAME = runContext.environment.name;
142
- const claimed = new Set<string>();
143
- for (const [key, value] of Object.entries(runContext.environment.fields)) {
144
- const shellKey = toEnvFieldShellKey(key);
145
- if (shellKey === CHECKSTACK_ENV_PREFIX || claimed.has(shellKey)) continue;
146
- env[shellKey] = stringifyFieldValue(value);
147
- claimed.add(shellKey);
148
- }
182
+ addFieldShellVars({
183
+ env,
184
+ fields: runContext.environment.fields,
185
+ prefix: CHECKSTACK_ENV_PREFIX,
186
+ });
149
187
  }
150
188
  return env;
151
189
  }
@@ -347,10 +347,11 @@ describe("Queue-Based Health Check Executor", () => {
347
347
  const mockIncidentClient = createMockIncidentClient();
348
348
  const mockSignalService = createMockSignalService();
349
349
 
350
- // Catalog resolves the system name.
350
+ // Catalog resolves the system name + free-form metadata.
351
351
  (mockCatalogClient.getSystem as any) = mock(async () => ({
352
352
  id: "system-1",
353
353
  name: "web-01",
354
+ metadata: { baseUrl: "https://web-01.example.com" },
354
355
  }));
355
356
 
356
357
  // configName is null -> run-context check.name must fall back to id.
@@ -470,7 +471,11 @@ describe("Queue-Based Health Check Executor", () => {
470
471
  expect(collectorExecute).toHaveBeenCalled();
471
472
  expect(capturedRunContext).toEqual({
472
473
  check: { id: "config-1", name: "config-1", intervalSeconds: 45 },
473
- system: { id: "system-1", name: "web-01" },
474
+ system: {
475
+ id: "system-1",
476
+ name: "web-01",
477
+ metadata: { baseUrl: "https://web-01.example.com" },
478
+ },
474
479
  });
475
480
  });
476
481
 
@@ -694,6 +699,7 @@ describe("Queue-Based Health Check Executor", () => {
694
699
  collectorConfig = {},
695
700
  collectorConfigSchema = z.object({}),
696
701
  failCatalogResolution = false,
702
+ systemMetadata = {},
697
703
  }: {
698
704
  /** The env this job runs (`null` = an env-less job). */
699
705
  payloadEnvironmentId: string | null;
@@ -710,9 +716,11 @@ describe("Queue-Based Health Check Executor", () => {
710
716
  collectorConfigSchema?: z.ZodType<unknown>;
711
717
  /** When true, the catalog membership read REJECTS (fail-open path). */
712
718
  failCatalogResolution?: boolean;
719
+ /** The system's free-form catalog metadata (`{{ system.metadata.* }}`). */
720
+ systemMetadata?: Record<string, unknown>;
713
721
  }): Promise<{
714
722
  /** Run-context captured for the single run (empty when skipped). */
715
- runs: Array<{ environment?: unknown; config?: unknown }>;
723
+ runs: Array<{ environment?: unknown; system?: unknown; config?: unknown }>;
716
724
  /** Payloads broadcast on `healthcheck.run.completed`, in order. */
717
725
  runCompletedPayloads: Array<Record<string, unknown>>;
718
726
  }> {
@@ -728,6 +736,7 @@ describe("Queue-Based Health Check Executor", () => {
728
736
  (mockCatalogClient.getSystem as any) = mock(async () => ({
729
737
  id: "system-1",
730
738
  name: "web-01",
739
+ metadata: systemMetadata,
731
740
  }));
732
741
  (mockCatalogClient as any).resolveSystemEnvironments = mock(async () => {
733
742
  if (failCatalogResolution) throw new Error("catalog unavailable");
@@ -972,6 +981,51 @@ describe("Queue-Based Health Check Executor", () => {
972
981
  expect((captured[0]?.config as { url: string }).url).toBe("/healthz");
973
982
  });
974
983
 
984
+ it("renders {{ system.metadata.<key> }} from the system's catalog custom fields", async () => {
985
+ const { runs: captured } = await runSingleEnv({
986
+ payloadEnvironmentId: null,
987
+ environmentIds: [],
988
+ membership: [{ id: "prod", name: "Production", metadata: {} }],
989
+ systemMetadata: { baseUrl: "https://sys.example.com" },
990
+ collectorConfig: { url: "{{ system.metadata.baseUrl }}/healthz" },
991
+ collectorConfigSchema: z.object({
992
+ url: configString({ "x-templatable": true }),
993
+ }),
994
+ });
995
+
996
+ expect(captured).toHaveLength(1);
997
+ expect((captured[0]?.config as { url: string }).url).toBe(
998
+ "https://sys.example.com/healthz",
999
+ );
1000
+ });
1001
+
1002
+ it("namespaces metadata under .metadata so a custom field cannot shadow system.name", async () => {
1003
+ // A metadata key literally named `name` must NOT override the structural
1004
+ // `{{ system.name }}`; it is only reachable at `{{ system.metadata.name }}`.
1005
+ const { runs: captured } = await runSingleEnv({
1006
+ payloadEnvironmentId: null,
1007
+ environmentIds: [],
1008
+ membership: [{ id: "prod", name: "Production", metadata: {} }],
1009
+ systemMetadata: { name: "shadow-attempt" },
1010
+ collectorConfig: {
1011
+ structural: "{{ system.name }}",
1012
+ custom: "{{ system.metadata.name }}",
1013
+ },
1014
+ collectorConfigSchema: z.object({
1015
+ structural: configString({ "x-templatable": true }),
1016
+ custom: configString({ "x-templatable": true }),
1017
+ }),
1018
+ });
1019
+
1020
+ expect(captured).toHaveLength(1);
1021
+ const config = captured[0]?.config as {
1022
+ structural: string;
1023
+ custom: string;
1024
+ };
1025
+ expect(config.structural).toBe("web-01");
1026
+ expect(config.custom).toBe("shadow-attempt");
1027
+ });
1028
+
975
1029
  it("runs the explicit-subset environment the payload targets", async () => {
976
1030
  const { runs: captured } = await runSingleEnv({
977
1031
  payloadEnvironmentId: "staging",
@@ -767,12 +767,16 @@ async function executeHealthCheckJob(props: {
767
767
  return;
768
768
  }
769
769
 
770
- // Fetch system name for signal payload
770
+ // Fetch system name + metadata for signal payload and run-context. The
771
+ // metadata is the system's free-form catalog custom fields, surfaced to
772
+ // config templating as `{{ system.metadata.<key> }}`.
771
773
  let systemName = systemId;
774
+ let systemMetadata: Record<string, unknown> = {};
772
775
  try {
773
776
  const system = await catalogClient.getSystem({ systemId });
774
777
  if (system) {
775
778
  systemName = system.name;
779
+ systemMetadata = system.metadata ?? {};
776
780
  }
777
781
  } catch {
778
782
  // Fall back to systemId if catalog lookup fails
@@ -980,7 +984,7 @@ async function executeHealthCheckJob(props: {
980
984
  name: configRow.configName || configId,
981
985
  intervalSeconds: configRow.interval,
982
986
  },
983
- system: { id: systemId, name: systemName },
987
+ system: { id: systemId, name: systemName, metadata: systemMetadata },
984
988
  ...(environment
985
989
  ? {
986
990
  environment: {