@checkstack/healthcheck-backend 0.15.1 → 0.16.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 80cbc51: Enforce GitOps provenance lock on backend API endpoints to prevent manual configuration drift for synchronized resources.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [80cbc51]
12
+ - @checkstack/catalog-backend@0.5.0
13
+ - @checkstack/satellite-backend@0.2.8
14
+
3
15
  ## 0.15.1
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import { satelliteHooks } from "@checkstack/satellite-backend";
30
30
  import { CatalogApi } from "@checkstack/catalog-common";
31
31
  import { MaintenanceApi } from "@checkstack/maintenance-common";
32
32
  import { IncidentApi } from "@checkstack/incident-common";
33
+ import { GitOpsApi } from "@checkstack/gitops-common";
33
34
  import { healthCheckHooks } from "./hooks";
34
35
  import { registerSearchProvider } from "@checkstack/command-backend";
35
36
  import { resolveRoute } from "@checkstack/common";
@@ -164,6 +165,9 @@ export default createBackendPlugin({
164
165
  // Create incident client for notification suppression checks
165
166
  const incidentClient = rpcClient.forPlugin(IncidentApi);
166
167
 
168
+ // Create gitops client for provenance lock checks
169
+ const gitOpsClient = rpcClient.forPlugin(GitOpsApi);
170
+
167
171
  // Setup queue-based health check worker
168
172
  await setupHealthCheckWorker({
169
173
  db: database,
@@ -189,6 +193,7 @@ export default createBackendPlugin({
189
193
  database: database as SafeDatabase<typeof schema>,
190
194
  registry: healthCheckRegistry,
191
195
  collectorRegistry,
196
+ gitOpsClient,
192
197
  getEmitHook: () => storedEmitHook,
193
198
  });
194
199
  rpc.registerRouter(healthCheckRouter, healthCheckContract);
@@ -50,10 +50,15 @@ describe("HealthCheck Router", () => {
50
50
  getCollectorsForPlugin: mock(() => []),
51
51
  };
52
52
 
53
+ const mockGitOpsClient = {
54
+ getProvenance: mock<any>(() => Promise.resolve(null)),
55
+ };
56
+
53
57
  const router = createHealthCheckRouter({
54
58
  database: mockDb as never,
55
59
  registry: mockRegistry,
56
60
  collectorRegistry: mockCollectorRegistry as never,
61
+ gitOpsClient: mockGitOpsClient as never,
57
62
  getEmitHook: () => undefined,
58
63
  });
59
64
 
@@ -169,4 +174,63 @@ describe("HealthCheck Router", () => {
169
174
  );
170
175
  expect(result).toHaveLength(0);
171
176
  });
177
+
178
+ describe("GitOps Provenance Enforcement", () => {
179
+ it("allows deleteConfiguration when GitOps lock is not present", async () => {
180
+ mockGitOpsClient.getProvenance.mockResolvedValueOnce(null);
181
+ const context = createMockRpcContext({ user: mockUser });
182
+
183
+ try {
184
+ await call(router.deleteConfiguration, "config-1", { context });
185
+ } catch (e: any) {
186
+ // If it throws anything other than FORBIDDEN, it passed the lock check
187
+ expect(e.code).not.toBe("FORBIDDEN");
188
+ }
189
+
190
+ expect(mockGitOpsClient.getProvenance).toHaveBeenCalledWith({
191
+ kind: "Healthcheck",
192
+ entityId: "config-1"
193
+ });
194
+ });
195
+
196
+ it("throws FORBIDDEN when deleting a GitOps locked configuration", async () => {
197
+ mockGitOpsClient.getProvenance.mockResolvedValueOnce({
198
+ id: "prov-1", kind: "Healthcheck", entityId: "config-1",
199
+ providerId: "prov", entityName: "c1", status: "synced",
200
+ lastSyncedAt: new Date(), createdAt: new Date(), updatedAt: new Date(),
201
+ repository: "", filePath: "", fileSha: ""
202
+ });
203
+ const context = createMockRpcContext({ user: mockUser });
204
+
205
+ let error;
206
+ try {
207
+ await call(router.deleteConfiguration, "config-1", { context });
208
+ } catch (e) {
209
+ error = e;
210
+ }
211
+ expect(error).toBeDefined();
212
+ expect((error as any).code).toBe("FORBIDDEN");
213
+ expect((error as any).message).toContain("managed by GitOps");
214
+ });
215
+
216
+ it("throws FORBIDDEN when associating a system that is GitOps locked", async () => {
217
+ mockGitOpsClient.getProvenance.mockResolvedValueOnce({
218
+ id: "prov-1", kind: "System", entityId: "sys-1",
219
+ providerId: "prov", entityName: "s1", status: "synced",
220
+ lastSyncedAt: new Date(), createdAt: new Date(), updatedAt: new Date(),
221
+ repository: "", filePath: "", fileSha: ""
222
+ });
223
+ const context = createMockRpcContext({ user: mockUser });
224
+
225
+ let error;
226
+ try {
227
+ await call(router.associateSystem, { systemId: "sys-1", body: { configurationId: "12345678-1234-4234-8234-123456789012", enabled: true, satelliteIds: [], includeLocal: false } }, { context });
228
+ } catch (e: any) {
229
+ error = e;
230
+ if (e.code !== "FORBIDDEN") console.log(e.issues || e.message);
231
+ }
232
+ expect(error).toBeDefined();
233
+ expect((error as any).code).toBe("FORBIDDEN");
234
+ });
235
+ });
172
236
  });
package/src/router.ts CHANGED
@@ -13,6 +13,8 @@ import { HealthCheckService } from "./service";
13
13
  import { healthCheckHooks } from "./hooks";
14
14
  import * as schema from "./schema";
15
15
  import { toJsonSchemaWithChartMeta } from "./schema-utils";
16
+ import type { InferClient } from "@checkstack/common";
17
+ import { GitOpsApi } from "@checkstack/gitops-common";
16
18
 
17
19
  /**
18
20
  * Creates the healthcheck router using contract-based implementation.
@@ -24,6 +26,7 @@ export const createHealthCheckRouter = (opts: {
24
26
  database: SafeDatabase<typeof schema>;
25
27
  registry: HealthCheckRegistry;
26
28
  collectorRegistry: CollectorRegistry;
29
+ gitOpsClient: InferClient<typeof GitOpsApi>;
27
30
  getEmitHook: () => ((hook: { id: string }, payload: Record<string, unknown>) => Promise<void>) | undefined;
28
31
  }) => {
29
32
  const { database, registry, collectorRegistry, getEmitHook } = opts;
@@ -35,6 +38,18 @@ export const createHealthCheckRouter = (opts: {
35
38
  .$context<RpcContext>()
36
39
  .use(autoAuthMiddleware);
37
40
 
41
+ const enforceNotGitOpsLocked = async (kind: string, entityId: string) => {
42
+ const provenance = await opts.gitOpsClient.getProvenance({
43
+ kind,
44
+ entityId,
45
+ });
46
+ if (provenance) {
47
+ throw new ORPCError("FORBIDDEN", {
48
+ message: `${kind} is managed by GitOps and cannot be modified manually.`,
49
+ });
50
+ }
51
+ };
52
+
38
53
  return os.router({
39
54
  getStrategies: os.getStrategies.handler(async ({ context }) => {
40
55
  return context.healthCheckRegistry.getStrategiesWithMeta().map((r) => ({
@@ -101,6 +116,7 @@ export const createHealthCheckRouter = (opts: {
101
116
  }),
102
117
 
103
118
  updateConfiguration: os.updateConfiguration.handler(async ({ input }) => {
119
+ await enforceNotGitOpsLocked("Healthcheck", input.id);
104
120
  const config = await service.updateConfiguration(input.id, input.body);
105
121
  if (!config) {
106
122
  throw new ORPCError("NOT_FOUND", {
@@ -111,14 +127,17 @@ export const createHealthCheckRouter = (opts: {
111
127
  }),
112
128
 
113
129
  deleteConfiguration: os.deleteConfiguration.handler(async ({ input }) => {
130
+ await enforceNotGitOpsLocked("Healthcheck", input);
114
131
  await service.deleteConfiguration(input);
115
132
  }),
116
133
 
117
134
  pauseConfiguration: os.pauseConfiguration.handler(async ({ input }) => {
135
+ await enforceNotGitOpsLocked("Healthcheck", input);
118
136
  await service.pauseConfiguration(input);
119
137
  }),
120
138
 
121
139
  resumeConfiguration: os.resumeConfiguration.handler(async ({ input }) => {
140
+ await enforceNotGitOpsLocked("Healthcheck", input);
122
141
  await service.resumeConfiguration(input);
123
142
  }),
124
143
 
@@ -135,6 +154,7 @@ export const createHealthCheckRouter = (opts: {
135
154
  ),
136
155
 
137
156
  associateSystem: os.associateSystem.handler(async ({ input, context }) => {
157
+ await enforceNotGitOpsLocked("System", input.systemId);
138
158
  await service.associateSystem({
139
159
  systemId: input.systemId,
140
160
  configurationId: input.body.configurationId,
@@ -173,6 +193,7 @@ export const createHealthCheckRouter = (opts: {
173
193
  }),
174
194
 
175
195
  disassociateSystem: os.disassociateSystem.handler(async ({ input }) => {
196
+ await enforceNotGitOpsLocked("System", input.systemId);
176
197
  await service.disassociateSystem(input.systemId, input.configId);
177
198
 
178
199
  // Notify subscribers that assignments changed
@@ -191,6 +212,7 @@ export const createHealthCheckRouter = (opts: {
191
212
 
192
213
  updateRetentionConfig: os.updateRetentionConfig.handler(
193
214
  async ({ input }) => {
215
+ await enforceNotGitOpsLocked("System", input.systemId);
194
216
  await service.updateRetentionConfig(
195
217
  input.systemId,
196
218
  input.configurationId,