@checkstack/healthcheck-backend 1.11.1 → 1.13.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,283 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { AuthService, AuthUser } from "@checkstack/backend-api";
3
+ import {
4
+ canReadRunScope,
5
+ hasGlobalHistoryAccess,
6
+ listManageableSystemIds,
7
+ listTeamManageableConfigurationIds,
8
+ resolveHistoryScope,
9
+ } from "./history-access";
10
+
11
+ /**
12
+ * Regression guard for the run-history authorization: global
13
+ * `configuration.manage` (or wildcard/service) yields unrestricted access, a
14
+ * team-scoped caller is restricted to runs of their granted CONFIGURATIONS
15
+ * plus all runs of the SYSTEMS they manage (a system's owning team sees every
16
+ * run of that system, whoever owns the configuration), anyone else is
17
+ * forbidden, and any auth (S2S) failure fails CLOSED.
18
+ */
19
+
20
+ const QUALIFIED_MANAGE = "healthcheck.healthcheck.manage";
21
+ const QUALIFIED_SYSTEM_MANAGE = "catalog.system.manage";
22
+
23
+ const realUser = (accessRules: string[]): AuthUser => ({
24
+ type: "user",
25
+ id: "u1",
26
+ accessRules,
27
+ });
28
+
29
+ // Cast is unavoidable for a partial mock: the helpers only touch
30
+ // `listAccessibleObjectIds` / `check`, and implementing the full AuthService
31
+ // here would just be noise.
32
+ const authWith = (impl: Partial<AuthService>): AuthService =>
33
+ impl as unknown as AuthService;
34
+
35
+ describe("hasGlobalHistoryAccess", () => {
36
+ test("true for global manage, wildcard, and services; false otherwise", () => {
37
+ expect(hasGlobalHistoryAccess(realUser([QUALIFIED_MANAGE]))).toBe(true);
38
+ expect(hasGlobalHistoryAccess(realUser(["*"]))).toBe(true);
39
+ expect(hasGlobalHistoryAccess({ type: "service", pluginId: "slo" })).toBe(
40
+ true,
41
+ );
42
+ expect(hasGlobalHistoryAccess(realUser(["healthcheck.healthcheck.read"]))).toBe(
43
+ false,
44
+ );
45
+ expect(hasGlobalHistoryAccess(undefined)).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe("resolveHistoryScope", () => {
50
+ test("global manage rule yields the unfiltered feed", () => {
51
+ expect(
52
+ resolveHistoryScope({
53
+ user: realUser([QUALIFIED_MANAGE]),
54
+ accessibleConfigurationIds: [],
55
+ accessibleSystemIds: [],
56
+ }),
57
+ ).toEqual({ kind: "all" });
58
+ });
59
+
60
+ test("configuration grants scope the feed", () => {
61
+ expect(
62
+ resolveHistoryScope({
63
+ user: realUser([]),
64
+ accessibleConfigurationIds: ["cfg-1"],
65
+ accessibleSystemIds: [],
66
+ }),
67
+ ).toEqual({ kind: "scoped", configurationIds: ["cfg-1"], systemIds: [] });
68
+ });
69
+
70
+ test("system manage alone scopes the feed (owning team sees its runs)", () => {
71
+ expect(
72
+ resolveHistoryScope({
73
+ user: realUser([]),
74
+ accessibleConfigurationIds: [],
75
+ accessibleSystemIds: ["sys-1"],
76
+ }),
77
+ ).toEqual({ kind: "scoped", configurationIds: [], systemIds: ["sys-1"] });
78
+ });
79
+
80
+ test("no global rule and no grant of either kind is forbidden", () => {
81
+ expect(
82
+ resolveHistoryScope({
83
+ user: realUser(["healthcheck.healthcheck.read"]),
84
+ accessibleConfigurationIds: [],
85
+ accessibleSystemIds: [],
86
+ }),
87
+ ).toEqual({ kind: "forbidden" });
88
+ });
89
+
90
+ test("missing user is forbidden", () => {
91
+ expect(
92
+ resolveHistoryScope({
93
+ user: undefined,
94
+ accessibleConfigurationIds: [],
95
+ accessibleSystemIds: [],
96
+ }),
97
+ ).toEqual({ kind: "forbidden" });
98
+ });
99
+
100
+ test("a manage rule from ANOTHER plugin does not unlock the feed", () => {
101
+ expect(
102
+ resolveHistoryScope({
103
+ user: realUser(["incident.incident.manage"]),
104
+ accessibleConfigurationIds: [],
105
+ accessibleSystemIds: [],
106
+ }),
107
+ ).toEqual({ kind: "forbidden" });
108
+ });
109
+ });
110
+
111
+ describe("listTeamManageableConfigurationIds", () => {
112
+ test("resolves the manage-granted subset via the auth S2S", async () => {
113
+ const auth = authWith({
114
+ listAccessibleObjectIds: async ({ objectType, action, objectIds }) => {
115
+ expect(objectType).toBe("healthcheck.healthcheck");
116
+ expect(action).toBe("manage");
117
+ return objectIds.filter((id) => id === "cfg-2");
118
+ },
119
+ });
120
+
121
+ await expect(
122
+ listTeamManageableConfigurationIds({
123
+ auth,
124
+ user: realUser([]),
125
+ allConfigurationIds: ["cfg-1", "cfg-2"],
126
+ }),
127
+ ).resolves.toEqual(["cfg-2"]);
128
+ });
129
+
130
+ test("fails CLOSED when the auth S2S errors", async () => {
131
+ const auth = authWith({
132
+ listAccessibleObjectIds: async () => {
133
+ throw new Error("auth down");
134
+ },
135
+ });
136
+
137
+ await expect(
138
+ listTeamManageableConfigurationIds({
139
+ auth,
140
+ user: realUser([]),
141
+ allConfigurationIds: ["cfg-1"],
142
+ }),
143
+ ).resolves.toEqual([]);
144
+ });
145
+ });
146
+
147
+ describe("listManageableSystemIds", () => {
148
+ test("resolves the team-granted subset keyed on catalog.system manage", async () => {
149
+ const auth = authWith({
150
+ listAccessibleObjectIds: async ({ objectType, action, objectIds }) => {
151
+ expect(objectType).toBe("catalog.system");
152
+ expect(action).toBe("manage");
153
+ return objectIds.filter((id) => id === "sys-1");
154
+ },
155
+ });
156
+
157
+ await expect(
158
+ listManageableSystemIds({
159
+ auth,
160
+ user: realUser([]),
161
+ allSystemIds: ["sys-1", "sys-2"],
162
+ }),
163
+ ).resolves.toEqual(["sys-1"]);
164
+ });
165
+
166
+ test("the global catalog.system.manage rule grants every system (parentScope convention)", async () => {
167
+ const auth = authWith({
168
+ listAccessibleObjectIds: async () => {
169
+ throw new Error("must not be called");
170
+ },
171
+ });
172
+
173
+ await expect(
174
+ listManageableSystemIds({
175
+ auth,
176
+ user: realUser([QUALIFIED_SYSTEM_MANAGE]),
177
+ allSystemIds: ["sys-1", "sys-2"],
178
+ }),
179
+ ).resolves.toEqual(["sys-1", "sys-2"]);
180
+ });
181
+
182
+ test("fails CLOSED when the auth S2S errors", async () => {
183
+ const auth = authWith({
184
+ listAccessibleObjectIds: async () => {
185
+ throw new Error("auth down");
186
+ },
187
+ });
188
+
189
+ await expect(
190
+ listManageableSystemIds({
191
+ auth,
192
+ user: realUser([]),
193
+ allSystemIds: ["sys-1"],
194
+ }),
195
+ ).resolves.toEqual([]);
196
+ });
197
+ });
198
+
199
+ describe("canReadRunScope", () => {
200
+ const scope = { configurationId: "cfg-1", systemId: "sys-1" };
201
+
202
+ test("global healthcheck manage allows without any S2S call", async () => {
203
+ const auth = authWith({
204
+ check: async () => {
205
+ throw new Error("must not be called");
206
+ },
207
+ });
208
+
209
+ await expect(
210
+ canReadRunScope({ auth, user: realUser([QUALIFIED_MANAGE]), ...scope }),
211
+ ).resolves.toBe(true);
212
+ });
213
+
214
+ test("a team grant on the CONFIGURATION allows", async () => {
215
+ const auth = authWith({
216
+ check: async ({ objectType, objectId }) => ({
217
+ hasAccess:
218
+ objectType === "healthcheck.healthcheck" && objectId === "cfg-1",
219
+ }),
220
+ });
221
+
222
+ await expect(
223
+ canReadRunScope({ auth, user: realUser([]), ...scope }),
224
+ ).resolves.toBe(true);
225
+ });
226
+
227
+ test("a team grant on the SYSTEM allows even without a configuration grant", async () => {
228
+ const auth = authWith({
229
+ check: async ({ objectType, objectId }) => ({
230
+ hasAccess: objectType === "catalog.system" && objectId === "sys-1",
231
+ }),
232
+ });
233
+
234
+ await expect(
235
+ canReadRunScope({ auth, user: realUser([]), ...scope }),
236
+ ).resolves.toBe(true);
237
+ });
238
+
239
+ test("the global catalog.system.manage rule allows (parentScope convention)", async () => {
240
+ const auth = authWith({
241
+ check: async () => {
242
+ throw new Error("must not be called");
243
+ },
244
+ });
245
+
246
+ await expect(
247
+ canReadRunScope({
248
+ auth,
249
+ user: realUser([QUALIFIED_SYSTEM_MANAGE]),
250
+ ...scope,
251
+ }),
252
+ ).resolves.toBe(true);
253
+ });
254
+
255
+ test("no grant of either kind denies", async () => {
256
+ const auth = authWith({
257
+ check: async () => ({ hasAccess: false }),
258
+ });
259
+
260
+ await expect(
261
+ canReadRunScope({ auth, user: realUser([]), ...scope }),
262
+ ).resolves.toBe(false);
263
+ });
264
+
265
+ test("missing user denies", async () => {
266
+ const auth = authWith({});
267
+ await expect(
268
+ canReadRunScope({ auth, user: undefined, ...scope }),
269
+ ).resolves.toBe(false);
270
+ });
271
+
272
+ test("fails CLOSED when the auth S2S errors", async () => {
273
+ const auth = authWith({
274
+ check: async () => {
275
+ throw new Error("auth down");
276
+ },
277
+ });
278
+
279
+ await expect(
280
+ canReadRunScope({ auth, user: realUser([]), ...scope }),
281
+ ).resolves.toBe(false);
282
+ });
283
+ });
@@ -0,0 +1,203 @@
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
+ * The qualified global rule that unlocks the UNFILTERED run-history feed.
12
+ * Kept in lock-step with the history procs' contract docs: their `access` is
13
+ * deliberately empty (the OR below is not expressible declaratively), so THIS
14
+ * module is the authorization for them.
15
+ */
16
+ const QUALIFIED_MANAGE_RULE = qualifyAccessRuleId(
17
+ pluginMetadata,
18
+ healthCheckAccess.configuration.manage,
19
+ );
20
+
21
+ /**
22
+ * The parent (catalog.system) global manage rule. Follows the middleware's
23
+ * parentScope convention: authorizing via a parent consults the parent's own
24
+ * grants AND its global `{resourceType}.{action}` rule.
25
+ */
26
+ const QUALIFIED_SYSTEM_MANAGE_RULE = `${catalogResourceTypes.system}.manage`;
27
+
28
+ /** A user/application principal (the only kinds that can hold team grants). */
29
+ type GrantHolder = AuthUser & { type: "user" | "application" };
30
+
31
+ const isGrantHolder = (user: AuthUser): user is GrantHolder =>
32
+ user.type === "user" || user.type === "application";
33
+
34
+ /**
35
+ * Does the caller hold UNRESTRICTED run-history access? True for wildcard
36
+ * admins, global `healthcheck.healthcheck.manage` holders, and trusted
37
+ * services (same stance as the middleware).
38
+ */
39
+ export function hasGlobalHistoryAccess(user: AuthUser | undefined): boolean {
40
+ if (!user) return false;
41
+ if (user.type === "service") return true;
42
+ const rules = user.accessRules ?? [];
43
+ return rules.includes("*") || rules.includes(QUALIFIED_MANAGE_RULE);
44
+ }
45
+
46
+ /** The outcome of authorizing the run-history FEED for the current caller. */
47
+ export type HistoryScope =
48
+ /** Global manage (or wildcard/service): the caller sees every run. */
49
+ | { kind: "all" }
50
+ /**
51
+ * Team-scoped caller: restrict rows to runs of these configurations OR runs
52
+ * belonging to these systems (a system's owning team sees ALL of its runs,
53
+ * regardless of who owns the configuration).
54
+ */
55
+ | { kind: "scoped"; configurationIds: string[]; systemIds: string[] }
56
+ /** No global rule and no team grant of either kind: the read is forbidden. */
57
+ | { kind: "forbidden" };
58
+
59
+ /**
60
+ * Pure decision half of the feed authorization: given the caller and their
61
+ * team-accessible configuration/system ids, decide the feed scope. Split from
62
+ * the S2S lookups so the branching is unit-testable without an auth service.
63
+ */
64
+ export function resolveHistoryScope({
65
+ user,
66
+ accessibleConfigurationIds,
67
+ accessibleSystemIds,
68
+ }: {
69
+ user: AuthUser | undefined;
70
+ accessibleConfigurationIds: string[];
71
+ accessibleSystemIds: string[];
72
+ }): HistoryScope {
73
+ if (!user) return { kind: "forbidden" };
74
+ if (hasGlobalHistoryAccess(user)) return { kind: "all" };
75
+
76
+ if (
77
+ accessibleConfigurationIds.length === 0 &&
78
+ accessibleSystemIds.length === 0
79
+ ) {
80
+ return { kind: "forbidden" };
81
+ }
82
+ return {
83
+ kind: "scoped",
84
+ configurationIds: accessibleConfigurationIds,
85
+ systemIds: accessibleSystemIds,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Resolve which of `allConfigurationIds` the caller may MANAGE via team
91
+ * grants on the CONFIGURATION itself. FAILS CLOSED: an S2S error yields an
92
+ * empty set, i.e. narrower access, never wider.
93
+ */
94
+ export async function listTeamManageableConfigurationIds({
95
+ auth,
96
+ user,
97
+ allConfigurationIds,
98
+ }: {
99
+ auth: AuthService;
100
+ user: AuthUser;
101
+ allConfigurationIds: string[];
102
+ }): Promise<string[]> {
103
+ if (!isGrantHolder(user)) return [];
104
+ if (allConfigurationIds.length === 0) return [];
105
+ try {
106
+ return await auth.listAccessibleObjectIds({
107
+ userId: user.id,
108
+ userType: user.type,
109
+ objectType: healthCheckResourceTypes.configuration,
110
+ objectIds: allConfigurationIds,
111
+ action: "manage",
112
+ hasGlobalAccess: false,
113
+ });
114
+ } catch {
115
+ // SECURITY: fail closed - an auth outage must not widen the feed.
116
+ return [];
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Resolve which of `allSystemIds` the caller may MANAGE - via a team grant on
122
+ * the system, or via the global `catalog.system.manage` rule (the parentScope
123
+ * convention). A system's owning team sees every run of that system, even for
124
+ * configurations owned elsewhere. FAILS CLOSED on S2S errors.
125
+ */
126
+ export async function listManageableSystemIds({
127
+ auth,
128
+ user,
129
+ allSystemIds,
130
+ }: {
131
+ auth: AuthService;
132
+ user: AuthUser;
133
+ allSystemIds: string[];
134
+ }): Promise<string[]> {
135
+ if (!isGrantHolder(user)) return [];
136
+ if (allSystemIds.length === 0) return [];
137
+ const rules = user.accessRules ?? [];
138
+ if (rules.includes(QUALIFIED_SYSTEM_MANAGE_RULE)) return allSystemIds;
139
+ try {
140
+ return await auth.listAccessibleObjectIds({
141
+ userId: user.id,
142
+ userType: user.type,
143
+ objectType: catalogResourceTypes.system,
144
+ objectIds: allSystemIds,
145
+ action: "manage",
146
+ hasGlobalAccess: false,
147
+ });
148
+ } catch {
149
+ // SECURITY: fail closed - an auth outage must not widen the feed.
150
+ return [];
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Per-object check for the single-configuration history surfaces
156
+ * (`getRunById`, `getDetailedAggregatedHistory`): the caller may read runs of
157
+ * (`configurationId`, `systemId`) iff they hold global history access, a team
158
+ * manage grant on the CONFIGURATION, or manage access to the SYSTEM (team
159
+ * grant or global catalog rule). FAILS CLOSED on S2S errors.
160
+ */
161
+ export async function canReadRunScope({
162
+ auth,
163
+ user,
164
+ configurationId,
165
+ systemId,
166
+ }: {
167
+ auth: AuthService;
168
+ user: AuthUser | undefined;
169
+ configurationId: string;
170
+ systemId: string;
171
+ }): Promise<boolean> {
172
+ if (!user) return false;
173
+ if (hasGlobalHistoryAccess(user)) return true;
174
+ if (!isGrantHolder(user)) return false;
175
+
176
+ const rules = user.accessRules ?? [];
177
+ if (rules.includes(QUALIFIED_SYSTEM_MANAGE_RULE)) return true;
178
+
179
+ try {
180
+ const configCheck = await auth.check({
181
+ userId: user.id,
182
+ userType: user.type,
183
+ objectType: healthCheckResourceTypes.configuration,
184
+ objectId: configurationId,
185
+ action: "manage",
186
+ hasGlobalAccess: false,
187
+ });
188
+ if (configCheck.hasAccess) return true;
189
+
190
+ const systemCheck = await auth.check({
191
+ userId: user.id,
192
+ userType: user.type,
193
+ objectType: catalogResourceTypes.system,
194
+ objectId: systemId,
195
+ action: "manage",
196
+ hasGlobalAccess: false,
197
+ });
198
+ return systemCheck.hasAccess;
199
+ } catch {
200
+ // SECURITY: fail closed - an auth outage must not widen access.
201
+ return false;
202
+ }
203
+ }
package/src/hooks.ts CHANGED
@@ -46,6 +46,12 @@ export const healthCheckHooks = {
46
46
  latencyMs: number | undefined;
47
47
  result: Record<string, unknown> | undefined;
48
48
  timestamp: string;
49
+ /**
50
+ * Environment the run was executed for. null = the env-less slice (no
51
+ * environment membership). Forwarded by the anomaly plugin so its inline
52
+ * detector resolves the per-env baseline rather than a cross-env one.
53
+ */
54
+ environmentId: string | null;
49
55
  }>("healthcheck.check.completed"),
50
56
 
51
57
  /**
@@ -64,5 +70,6 @@ export const healthCheckHooks = {
64
70
  latencyMs: number | undefined;
65
71
  result: Record<string, unknown> | undefined;
66
72
  timestamp: string;
73
+ environmentId: string | null;
67
74
  }>("healthcheck.check.failed"),
68
75
  } as const;
package/src/index.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  setupHealthCheckWorker,
3
3
  bootstrapHealthChecks,
4
+ recomputeSystemRollupHealth,
4
5
  } from "./queue-executor";
5
6
  import { setupRetentionJob } from "./retention-job";
6
7
  import * as schema from "./schema";
7
8
  import {
8
9
  healthCheckAccessRules,
9
10
  healthCheckAccess,
11
+ healthCheckResourceTypes,
10
12
  pluginMetadata,
11
13
  healthCheckContract,
12
14
  healthcheckRoutes,
@@ -242,10 +244,14 @@ export default createBackendPlugin({
242
244
  const typedDb = database as SafeDatabase<typeof schema>;
243
245
 
244
246
  // Resolve/search health-check configurations by name for the Teams admin
245
- // UI (team grants are stored as opaque healthcheck.configuration:<id>
246
- // rows). Lets the auth backend render grants by name and power the grant
247
- // picker without depending on healthcheck internals.
248
- resourceResolverRegistry.register("healthcheck.configuration", {
247
+ // UI (team grants are stored as opaque `<type>:<configId>` rows, where
248
+ // <type> is `healthCheckResourceTypes.configuration` — i.e.
249
+ // `healthcheck.healthcheck`, the key the RPC middleware derives from the
250
+ // configuration access rule's resource). This MUST match that grant key,
251
+ // or grant names never resolve. Lets the auth backend render grants by
252
+ // name and power the grant picker without depending on healthcheck
253
+ // internals.
254
+ resourceResolverRegistry.register(healthCheckResourceTypes.configuration, {
249
255
  resolveNames: async (ids) => {
250
256
  if (ids.length === 0) return new Map();
251
257
  const rows = await typedDb
@@ -480,6 +486,17 @@ export default createBackendPlugin({
480
486
  maintenanceClient,
481
487
  logger,
482
488
  signalService,
489
+ recomputeSystemRollupHealth: (systemId) =>
490
+ recomputeSystemRollupHealth({
491
+ systemId,
492
+ // Reuse the COMPUTE-ON-READ service instance bound to the
493
+ // `health` entity read accessor — it's the same db/registry
494
+ // the rollup write inside `executeHealthCheckJob` uses.
495
+ service,
496
+ getHealthEntity: () => healthEntity,
497
+ advisoryLock,
498
+ logger,
499
+ }),
483
500
  });
484
501
  rpc.registerRouter(healthCheckRouter, healthCheckContract);
485
502