@checkstack/catalog-backend 0.4.4 → 0.5.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,11 @@
1
1
  # @checkstack/catalog-backend
2
2
 
3
+ ## 0.5.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
+
3
9
  ## 0.4.4
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/catalog-backend",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "checkstack": {
package/src/index.ts CHANGED
@@ -18,7 +18,7 @@ import { resolveRoute, type InferClient, extractErrorMessage} from "@checkstack/
18
18
  import { registerSearchProvider } from "@checkstack/command-backend";
19
19
  import { EntityService } from "./services/entity-service";
20
20
  import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
21
- import { CHECKSTACK_API_VERSION, entityRefSchema } from "@checkstack/gitops-common";
21
+ import { CHECKSTACK_API_VERSION, entityRefSchema, GitOpsApi } from "@checkstack/gitops-common";
22
22
  import { z } from "zod";
23
23
 
24
24
  // Database schema is still needed for types in creating the router
@@ -193,12 +193,14 @@ export default createBackendPlugin({
193
193
  // Get notification client for group management and sending notifications
194
194
  const notificationClient = rpcClient.forPlugin(NotificationApi);
195
195
  const authClient = rpcClient.forPlugin(AuthApi);
196
+ const gitOpsClient = rpcClient.forPlugin(GitOpsApi);
196
197
 
197
198
  // Register oRPC router with notification client and auth client
198
199
  const catalogRouter = createCatalogRouter({
199
200
  database: typedDb,
200
201
  notificationClient,
201
202
  authClient,
203
+ gitOpsClient,
202
204
  pluginId: pluginMetadata.pluginId,
203
205
  });
204
206
  rpc.registerRouter(catalogRouter, catalogContract);
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { createCatalogRouter } from "./router";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import { call } from "@orpc/server";
5
+
6
+ describe("Catalog Router - GitOps Provenance Enforcement", () => {
7
+ const mockUser = {
8
+ type: "user" as const,
9
+ id: "test-user",
10
+ accessRules: ["*"],
11
+ roles: ["admin"],
12
+ };
13
+
14
+ const mockDb = {
15
+ select: mock(() => ({
16
+ from: mock(() => ({
17
+ where: mock(() => Promise.resolve([])),
18
+ })),
19
+ })),
20
+ } as unknown;
21
+
22
+ const mockNotificationClient = {
23
+ createGroup: mock(() => Promise.resolve()),
24
+ deleteGroup: mock(() => Promise.resolve()),
25
+ notifyGroups: mock(() => Promise.resolve({ notifiedCount: 0 })),
26
+ };
27
+
28
+ const mockAuthClient = {
29
+ getUserById: mock(() => Promise.resolve(null)),
30
+ };
31
+
32
+ const mockGitOpsClient = {
33
+ getProvenance: mock<any>(() => Promise.resolve(null)),
34
+ };
35
+
36
+ const router = createCatalogRouter({
37
+ database: mockDb as never,
38
+ notificationClient: mockNotificationClient as never,
39
+ authClient: mockAuthClient as never,
40
+ gitOpsClient: mockGitOpsClient as never,
41
+ pluginId: "test-catalog",
42
+ });
43
+
44
+ it("allows deleteSystem when GitOps lock is not present", async () => {
45
+ mockGitOpsClient.getProvenance.mockResolvedValueOnce(null);
46
+ const context = createMockRpcContext({ user: mockUser, emitHook: mock(() => Promise.resolve()) });
47
+
48
+ try {
49
+ await call(router.deleteSystem, "sys-1", { context });
50
+ } catch (e: any) {
51
+ expect(e.code).not.toBe("FORBIDDEN");
52
+ }
53
+
54
+ expect(mockGitOpsClient.getProvenance).toHaveBeenCalledWith({
55
+ kind: "System",
56
+ entityId: "sys-1",
57
+ });
58
+ });
59
+
60
+ it("throws FORBIDDEN when deleting a GitOps locked system", async () => {
61
+ mockGitOpsClient.getProvenance.mockResolvedValueOnce({
62
+ id: "prov-1", kind: "System", entityId: "sys-1",
63
+ providerId: "prov", entityName: "sys1", status: "synced",
64
+ lastSyncedAt: new Date(), createdAt: new Date(), updatedAt: new Date(),
65
+ repository: "", filePath: "", fileSha: ""
66
+ });
67
+ const context = createMockRpcContext({ user: mockUser });
68
+
69
+ let error;
70
+ try {
71
+ await call(router.deleteSystem, "sys-1", { context });
72
+ } catch (e) {
73
+ error = e;
74
+ }
75
+ expect(error).toBeDefined();
76
+ expect((error as any).code).toBe("FORBIDDEN");
77
+ expect((error as any).message).toContain("managed by GitOps");
78
+ });
79
+
80
+ it("throws FORBIDDEN when updating a GitOps locked group", async () => {
81
+ mockGitOpsClient.getProvenance.mockResolvedValueOnce({
82
+ id: "prov-1", kind: "Group", entityId: "grp-1",
83
+ providerId: "prov", entityName: "grp1", status: "synced",
84
+ lastSyncedAt: new Date(), createdAt: new Date(), updatedAt: new Date(),
85
+ repository: "", filePath: "", fileSha: ""
86
+ });
87
+ const context = createMockRpcContext({ user: mockUser });
88
+
89
+ let error;
90
+ try {
91
+ await call(router.updateGroup, { id: "grp-1", data: { name: "New Name" } }, { context });
92
+ } catch (e) {
93
+ error = e;
94
+ }
95
+ expect(error).toBeDefined();
96
+ expect((error as any).code).toBe("FORBIDDEN");
97
+ });
98
+
99
+ it("throws FORBIDDEN when adding a locked system to a group", async () => {
100
+ mockGitOpsClient.getProvenance.mockResolvedValueOnce({
101
+ id: "prov-1", kind: "System", entityId: "sys-1",
102
+ providerId: "prov", entityName: "sys1", status: "synced",
103
+ lastSyncedAt: new Date(), createdAt: new Date(), updatedAt: new Date(),
104
+ repository: "", filePath: "", fileSha: ""
105
+ });
106
+
107
+ const context = createMockRpcContext({ user: mockUser });
108
+
109
+ let error;
110
+ try {
111
+ await call(router.addSystemToGroup, { systemId: "sys-1", groupId: "grp-1" }, { context });
112
+ } catch (e) {
113
+ error = e;
114
+ }
115
+ expect(error).toBeDefined();
116
+ expect((error as any).code).toBe("FORBIDDEN");
117
+ });
118
+
119
+ it("allows adding an unlocked system to a group, even if the group is locked", async () => {
120
+ mockGitOpsClient.getProvenance.mockResolvedValueOnce(null);
121
+
122
+ const context = createMockRpcContext({ user: mockUser });
123
+
124
+ try {
125
+ await call(router.addSystemToGroup, { systemId: "sys-1", groupId: "grp-1" }, { context });
126
+ } catch (e: any) {
127
+ expect(e.code).not.toBe("FORBIDDEN");
128
+ }
129
+ });
130
+ });
package/src/router.ts CHANGED
@@ -12,6 +12,7 @@ import { AuthApi } from "@checkstack/auth-common";
12
12
  import type { InferClient } from "@checkstack/common";
13
13
  import { catalogHooks } from "./hooks";
14
14
  import { eq } from "drizzle-orm";
15
+ import { GitOpsApi } from "@checkstack/gitops-common";
15
16
 
16
17
  /**
17
18
  * Creates the catalog router using contract-based implementation.
@@ -27,6 +28,7 @@ export interface CatalogRouterDeps {
27
28
  database: SafeDatabase<typeof schema>;
28
29
  notificationClient: InferClient<typeof NotificationApi>;
29
30
  authClient: InferClient<typeof AuthApi>;
31
+ gitOpsClient: InferClient<typeof GitOpsApi>;
30
32
  pluginId: string;
31
33
  }
32
34
 
@@ -34,10 +36,23 @@ export const createCatalogRouter = ({
34
36
  database,
35
37
  notificationClient,
36
38
  authClient,
39
+ gitOpsClient,
37
40
  pluginId,
38
41
  }: CatalogRouterDeps) => {
39
42
  const entityService = new EntityService(database);
40
43
 
44
+ const enforceNotGitOpsLocked = async (kind: string, entityId: string) => {
45
+ const provenance = await gitOpsClient.getProvenance({
46
+ kind,
47
+ entityId,
48
+ });
49
+ if (provenance) {
50
+ throw new ORPCError("FORBIDDEN", {
51
+ message: `${kind} is managed by GitOps and cannot be modified manually.`,
52
+ });
53
+ }
54
+ };
55
+
41
56
  // Helper to create notification group for an entity
42
57
  const createNotificationGroup = async (
43
58
  type: "system" | "group",
@@ -136,6 +151,7 @@ export const createCatalogRouter = ({
136
151
  });
137
152
 
138
153
  const updateSystem = os.updateSystem.handler(async ({ input }) => {
154
+ await enforceNotGitOpsLocked("System", input.id);
139
155
  // Convert null to undefined and filter out fields
140
156
  const cleanData: Partial<{
141
157
  name: string;
@@ -160,6 +176,7 @@ export const createCatalogRouter = ({
160
176
  });
161
177
 
162
178
  const deleteSystem = os.deleteSystem.handler(async ({ input, context }) => {
179
+ await enforceNotGitOpsLocked("System", input);
163
180
  await entityService.deleteSystem(input);
164
181
 
165
182
  // Delete the notification group for this system
@@ -189,6 +206,7 @@ export const createCatalogRouter = ({
189
206
  });
190
207
 
191
208
  const updateGroup = os.updateGroup.handler(async ({ input }) => {
209
+ await enforceNotGitOpsLocked("Group", input.id);
192
210
  // Convert null to undefined for optional fields
193
211
  const cleanData = {
194
212
  ...input.data,
@@ -214,6 +232,7 @@ export const createCatalogRouter = ({
214
232
  });
215
233
 
216
234
  const deleteGroup = os.deleteGroup.handler(async ({ input, context }) => {
235
+ await enforceNotGitOpsLocked("Group", input);
217
236
  await entityService.deleteGroup(input);
218
237
 
219
238
  // Delete the notification group for this catalog group
@@ -226,12 +245,20 @@ export const createCatalogRouter = ({
226
245
  });
227
246
 
228
247
  const addSystemToGroup = os.addSystemToGroup.handler(async ({ input }) => {
248
+ // Note: We only enforce the lock on the System, not the Group.
249
+ // This is because system-group associations are reconciled as a kindExtension
250
+ // of the System kind. The Group reconciler does not touch associations.
251
+ // Thus, it is perfectly safe (and intended) to manually add an unlocked System
252
+ // to a GitOps-managed Group.
253
+ await enforceNotGitOpsLocked("System", input.systemId);
229
254
  await entityService.addSystemToGroup(input);
230
255
  return { success: true };
231
256
  });
232
257
 
233
258
  const removeSystemFromGroup = os.removeSystemFromGroup.handler(
234
259
  async ({ input }) => {
260
+ // See addSystemToGroup for why we only check the System provenance lock.
261
+ await enforceNotGitOpsLocked("System", input.systemId);
235
262
  await entityService.removeSystemFromGroup(input);
236
263
  return { success: true };
237
264
  },
@@ -286,6 +313,7 @@ export const createCatalogRouter = ({
286
313
  });
287
314
 
288
315
  const addSystemContact = os.addSystemContact.handler(async ({ input }) => {
316
+ await enforceNotGitOpsLocked("System", input.systemId);
289
317
  // Validate input based on type
290
318
  if (input.type === "user" && !input.userId) {
291
319
  throw new ORPCError("BAD_REQUEST", {
@@ -333,6 +361,10 @@ export const createCatalogRouter = ({
333
361
 
334
362
  const removeSystemContact = os.removeSystemContact.handler(
335
363
  async ({ input }) => {
364
+ const contacts = await database.select().from(schema.systemContacts).where(eq(schema.systemContacts.id, input));
365
+ if (contacts[0]) {
366
+ await enforceNotGitOpsLocked("System", contacts[0].systemId);
367
+ }
336
368
  await entityService.removeContact(input);
337
369
  return { success: true };
338
370
  },