@checkstack/healthcheck-backend 1.12.0 → 1.13.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,73 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.13.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [b4e0832]
8
+ - @checkstack/ai-backend@0.10.4
9
+ - @checkstack/automation-backend@0.10.6
10
+ - @checkstack/catalog-backend@1.6.4
11
+ - @checkstack/incident-backend@1.9.2
12
+ - @checkstack/satellite-backend@0.7.7
13
+
14
+ ## 1.13.0
15
+
16
+ ### Minor Changes
17
+
18
+ - 0cac684: Align the health-check run-history gates end to end. The history surfaces had a
19
+ three-way drift: the route allowed `configuration.read`, the page required
20
+ manage capability, and the procedures required the standalone
21
+ `healthcheck.details` rule - so global read-rule holders reached a page that
22
+ denied them, and team-scoped managers passed the page gate but got 403s from
23
+ every data call.
24
+
25
+ Detailed run history is now a MANAGER surface everywhere, with system owners
26
+ included: access requires global `configuration.manage`, a team manage grant
27
+ on the CONFIGURATION, or manage access to the SYSTEM - a system's owning team
28
+ sees every run of that system, whoever owns the configuration.
29
+
30
+ - Routes, pages, drawer links, and the anomaly/health signals gate on the
31
+ manage capability (with `catalog.system` as the parent type); the drawer and
32
+ chart hook check the caller's grant on the specific configuration OR system.
33
+ - All three history procedures (`getDetailedHistory`,
34
+ `getDetailedAggregatedHistory`, `getRunById`) are authorized in the handler
35
+ via a shared fail-closed module (`history-access.ts`) - the triple-OR is not
36
+ expressible with the declarative instanceAccess modes. `getRunById`
37
+ authorizes against the fetched run's own configuration/system, and answers
38
+ `undefined` for unauthorized callers so run ids don't leak existence.
39
+ - The feed (`getDetailedHistory`) scopes team callers to runs of their
40
+ configurations UNION runs of their systems, with correct pagination totals.
41
+
42
+ BREAKING CHANGES:
43
+
44
+ - The standalone `healthcheck.details` access rule is REMOVED. Roles that held
45
+ `details` without `configuration.manage` lose access to detailed run data;
46
+ grant them the manage rule (or a team grant on the configuration/system)
47
+ instead. Stale role rows referencing the removed rule are inert.
48
+ - `getDetailedAggregatedHistory` is `authenticated` (was `public`); anonymous
49
+ callers could never pass its access rule anyway.
50
+
51
+ ### Patch Changes
52
+
53
+ - Updated dependencies [0cac684]
54
+ - Updated dependencies [0cac684]
55
+ - Updated dependencies [0cac684]
56
+ - @checkstack/ai-backend@0.10.3
57
+ - @checkstack/gitops-common@0.7.0
58
+ - @checkstack/healthcheck-common@1.11.0
59
+ - @checkstack/automation-backend@0.10.5
60
+ - @checkstack/catalog-backend@1.6.3
61
+ - @checkstack/incident-backend@1.9.1
62
+ - @checkstack/sdk@0.119.1
63
+ - @checkstack/gitops-backend@0.5.16
64
+ - @checkstack/satellite-backend@0.7.6
65
+ - @checkstack/backend-api@0.27.1
66
+ - @checkstack/script-packages-backend@0.3.20
67
+ - @checkstack/command-backend@0.2.16
68
+ - @checkstack/secrets-backend@0.2.16
69
+ - @checkstack/status-page-backend@0.4.3
70
+
3
71
  ## 1.12.0
4
72
 
5
73
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,31 +14,31 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.27.0",
18
- "@checkstack/ai-backend": "0.10.2",
17
+ "@checkstack/backend-api": "0.27.1",
18
+ "@checkstack/ai-backend": "0.10.4",
19
19
  "@checkstack/ai-common": "0.6.3",
20
- "@checkstack/script-packages-backend": "0.3.19",
20
+ "@checkstack/script-packages-backend": "0.3.20",
21
21
  "@checkstack/cache-api": "0.3.16",
22
22
  "@checkstack/cache-utils": "0.2.21",
23
- "@checkstack/catalog-backend": "1.6.2",
23
+ "@checkstack/catalog-backend": "1.6.4",
24
24
  "@checkstack/catalog-common": "2.6.0",
25
- "@checkstack/command-backend": "0.2.15",
25
+ "@checkstack/command-backend": "0.2.16",
26
26
  "@checkstack/common": "0.19.0",
27
- "@checkstack/gitops-backend": "0.5.15",
28
- "@checkstack/gitops-common": "0.6.8",
29
- "@checkstack/healthcheck-common": "1.10.0",
27
+ "@checkstack/gitops-backend": "0.5.16",
28
+ "@checkstack/gitops-common": "0.7.0",
29
+ "@checkstack/healthcheck-common": "1.11.0",
30
30
  "@checkstack/secrets-common": "0.2.8",
31
- "@checkstack/secrets-backend": "0.2.15",
32
- "@checkstack/incident-backend": "1.9.0",
31
+ "@checkstack/secrets-backend": "0.2.16",
32
+ "@checkstack/incident-backend": "1.9.2",
33
33
  "@checkstack/incident-common": "1.7.0",
34
- "@checkstack/automation-backend": "0.10.4",
34
+ "@checkstack/automation-backend": "0.10.6",
35
35
  "@checkstack/maintenance-common": "1.8.0",
36
36
  "@checkstack/notification-common": "1.5.0",
37
37
  "@checkstack/queue-api": "0.3.16",
38
- "@checkstack/satellite-backend": "0.7.5",
39
- "@checkstack/sdk": "0.118.1",
38
+ "@checkstack/satellite-backend": "0.7.7",
39
+ "@checkstack/sdk": "0.120.0",
40
40
  "@checkstack/signal-common": "0.2.14",
41
- "@checkstack/status-page-backend": "0.4.2",
41
+ "@checkstack/status-page-backend": "0.4.3",
42
42
  "@checkstack/status-page-common": "0.5.0",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "drizzle-orm": "^0.45.0",
@@ -52,7 +52,7 @@
52
52
  "devDependencies": {
53
53
  "@checkstack/drizzle-helper": "0.0.6",
54
54
  "@checkstack/scripts": "0.7.0",
55
- "@checkstack/test-utils-backend": "0.1.49",
55
+ "@checkstack/test-utils-backend": "0.1.50",
56
56
  "@checkstack/tsconfig": "0.0.7",
57
57
  "@types/bun": "^1.0.0",
58
58
  "@types/tdigest": "^0.1.5",
@@ -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/router.ts CHANGED
@@ -20,6 +20,13 @@ import {
20
20
  resolveScriptPackagesDir,
21
21
  } from "@checkstack/script-packages-backend";
22
22
  import { HealthCheckService } from "./service";
23
+ import {
24
+ canReadRunScope,
25
+ hasGlobalHistoryAccess,
26
+ listManageableSystemIds,
27
+ listTeamManageableConfigurationIds,
28
+ resolveHistoryScope,
29
+ } from "./history-access";
23
30
  import { collectConfigurationIssues } from "./validate-configuration";
24
31
  import { runCollectorScriptTest } from "./collector-script-test";
25
32
  import { healthCheckHooks } from "./hooks";
@@ -482,12 +489,76 @@ export const createHealthCheckRouter = (opts: {
482
489
  return service.getRunStats(input);
483
490
  }),
484
491
 
485
- getDetailedHistory: os.getDetailedHistory.handler(async ({ input }) => {
486
- return service.getDetailedHistory(input);
487
- }),
492
+ getDetailedHistory: os.getDetailedHistory.handler(
493
+ async ({ input, context }) => {
494
+ // Handler-side authorization (the contract's `access` is deliberately
495
+ // empty - see the contract doc): global `configuration.manage` (or a
496
+ // trusted service) gets the full feed; a team-scoped caller gets the
497
+ // feed filtered to the configurations their teams manage PLUS all
498
+ // runs of the systems they manage (a system's owning team sees every
499
+ // run of that system); everyone else is forbidden. Fail-closed via
500
+ // history-access.ts.
501
+ const user = context.user;
502
+ let accessibleConfigurationIds: string[] = [];
503
+ let accessibleSystemIds: string[] = [];
504
+ if (
505
+ user &&
506
+ (user.type === "user" || user.type === "application") &&
507
+ !hasGlobalHistoryAccess(user)
508
+ ) {
509
+ const configs = await service.getConfigurations();
510
+ [accessibleConfigurationIds, accessibleSystemIds] =
511
+ await Promise.all([
512
+ listTeamManageableConfigurationIds({
513
+ auth: context.auth,
514
+ user,
515
+ allConfigurationIds: configs.map((c) => c.id),
516
+ }),
517
+ listManageableSystemIds({
518
+ auth: context.auth,
519
+ user,
520
+ allSystemIds: await service.getRunSystemIds(),
521
+ }),
522
+ ]);
523
+ }
524
+ const scope = resolveHistoryScope({
525
+ user,
526
+ accessibleConfigurationIds,
527
+ accessibleSystemIds,
528
+ });
529
+ if (scope.kind === "forbidden") {
530
+ throw new ORPCError("FORBIDDEN", {
531
+ message:
532
+ "Run history requires health check manage access (globally, or via a team grant on a configuration or system)",
533
+ });
534
+ }
535
+ if (scope.kind === "all") {
536
+ return service.getDetailedHistory(input);
537
+ }
538
+ return service.getDetailedHistory({
539
+ ...input,
540
+ teamScope: {
541
+ configurationIds: scope.configurationIds,
542
+ systemIds: scope.systemIds,
543
+ },
544
+ });
545
+ },
546
+ ),
488
547
 
489
- getRunById: os.getRunById.handler(async ({ input }) => {
490
- return service.getRunById(input);
548
+ getRunById: os.getRunById.handler(async ({ input, context }) => {
549
+ // Authorized against the FETCHED run's own configuration/system (the
550
+ // anchor cannot be spoofed via input). An unauthorized caller gets the
551
+ // same `undefined` as a missing run, so run ids don't leak existence.
552
+ const run = await service.getRunById({ runId: input.runId });
553
+ if (!run) return;
554
+ const allowed = await canReadRunScope({
555
+ auth: context.auth,
556
+ user: context.user,
557
+ configurationId: run.configurationId,
558
+ systemId: run.systemId,
559
+ });
560
+ if (!allowed) return;
561
+ return run;
491
562
  }),
492
563
 
493
564
  getAggregatedHistory: os.getAggregatedHistory.handler(async ({ input }) => {
@@ -497,7 +568,21 @@ export const createHealthCheckRouter = (opts: {
497
568
  }),
498
569
 
499
570
  getDetailedAggregatedHistory: os.getDetailedAggregatedHistory.handler(
500
- async ({ input }) => {
571
+ async ({ input, context }) => {
572
+ // Authorized on the (configurationId, systemId) input pair - exactly
573
+ // the slice returned. See the contract doc / history-access.ts.
574
+ const allowed = await canReadRunScope({
575
+ auth: context.auth,
576
+ user: context.user,
577
+ configurationId: input.configurationId,
578
+ systemId: input.systemId,
579
+ });
580
+ if (!allowed) {
581
+ throw new ORPCError("FORBIDDEN", {
582
+ message:
583
+ "Detailed run data requires health check manage access (globally, or via a team grant on this configuration or system)",
584
+ });
585
+ }
501
586
  return service.getAggregatedHistory(input, {
502
587
  includeAggregatedResult: true,
503
588
  });
package/src/service.ts CHANGED
@@ -33,6 +33,7 @@ import * as schema from "./schema";
33
33
  import {
34
34
  eq,
35
35
  and,
36
+ or,
36
37
  InferSelectModel,
37
38
  desc,
38
39
  gte,
@@ -1268,9 +1269,31 @@ export class HealthCheckService {
1268
1269
  * Restricted to users with manage access.
1269
1270
  * @param sortOrder - 'asc' for chronological (oldest first), 'desc' for reverse (newest first)
1270
1271
  */
1272
+ /**
1273
+ * Distinct system ids that have at least one recorded run - the candidate
1274
+ * set for scoping the run-history feed by SYSTEM manage access (a system's
1275
+ * owning team sees every run of that system).
1276
+ */
1277
+ async getRunSystemIds(): Promise<string[]> {
1278
+ const rows = await this.db
1279
+ .selectDistinct({ systemId: healthCheckRuns.systemId })
1280
+ .from(healthCheckRuns);
1281
+ return rows.map((r) => r.systemId);
1282
+ }
1283
+
1271
1284
  async getDetailedHistory(props: {
1272
1285
  systemId?: string;
1273
1286
  configurationId?: string;
1287
+ /**
1288
+ * Restrict the feed to a team-scoped caller's slice: runs of their
1289
+ * configurations OR runs belonging to their systems (a system's owning
1290
+ * team sees every run of that system). Applied on TOP of the other
1291
+ * filters; both `total` and the page respect it, so pagination stays
1292
+ * correct for a filtered feed. At least one array must be non-empty
1293
+ * (an entirely-empty scope is the caller's "forbidden" case, decided
1294
+ * before the query).
1295
+ */
1296
+ teamScope?: { configurationIds: string[]; systemIds: string[] };
1274
1297
  startDate?: Date;
1275
1298
  endDate?: Date;
1276
1299
  sourceFilter?: string;
@@ -1283,6 +1306,7 @@ export class HealthCheckService {
1283
1306
  const {
1284
1307
  systemId,
1285
1308
  configurationId,
1309
+ teamScope,
1286
1310
  startDate,
1287
1311
  endDate,
1288
1312
  sourceFilter,
@@ -1297,6 +1321,28 @@ export class HealthCheckService {
1297
1321
  if (systemId) conditions.push(eq(healthCheckRuns.systemId, systemId));
1298
1322
  if (configurationId)
1299
1323
  conditions.push(eq(healthCheckRuns.configurationId, configurationId));
1324
+ if (teamScope) {
1325
+ // drizzle's inArray rejects empty arrays, so only include non-empty
1326
+ // branches of the OR.
1327
+ const scopeBranches = [];
1328
+ if (teamScope.configurationIds.length > 0) {
1329
+ scopeBranches.push(
1330
+ inArray(healthCheckRuns.configurationId, teamScope.configurationIds),
1331
+ );
1332
+ }
1333
+ if (teamScope.systemIds.length > 0) {
1334
+ scopeBranches.push(
1335
+ inArray(healthCheckRuns.systemId, teamScope.systemIds),
1336
+ );
1337
+ }
1338
+ if (scopeBranches.length === 0) {
1339
+ // Defensive: an empty scope must never widen to the full feed.
1340
+ return { runs: [], total: 0 };
1341
+ }
1342
+ conditions.push(
1343
+ scopeBranches.length === 1 ? scopeBranches[0] : or(...scopeBranches),
1344
+ );
1345
+ }
1300
1346
  if (startDate) conditions.push(gte(healthCheckRuns.timestamp, startDate));
1301
1347
  if (endDate) conditions.push(lte(healthCheckRuns.timestamp, endDate));
1302
1348