@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 +68 -0
- package/package.json +16 -16
- package/src/history-access.test.ts +283 -0
- package/src/history-access.ts +203 -0
- package/src/router.ts +91 -6
- package/src/service.ts +46 -0
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.
|
|
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.
|
|
18
|
-
"@checkstack/ai-backend": "0.10.
|
|
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.
|
|
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.
|
|
23
|
+
"@checkstack/catalog-backend": "1.6.4",
|
|
24
24
|
"@checkstack/catalog-common": "2.6.0",
|
|
25
|
-
"@checkstack/command-backend": "0.2.
|
|
25
|
+
"@checkstack/command-backend": "0.2.16",
|
|
26
26
|
"@checkstack/common": "0.19.0",
|
|
27
|
-
"@checkstack/gitops-backend": "0.5.
|
|
28
|
-
"@checkstack/gitops-common": "0.
|
|
29
|
-
"@checkstack/healthcheck-common": "1.
|
|
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.
|
|
32
|
-
"@checkstack/incident-backend": "1.9.
|
|
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.
|
|
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.
|
|
39
|
-
"@checkstack/sdk": "0.
|
|
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.
|
|
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.
|
|
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(
|
|
486
|
-
|
|
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
|
-
|
|
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
|
|