@checkstack/catalog-backend 0.4.4 → 0.5.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 +15 -0
- package/package.json +4 -4
- package/src/index.ts +3 -1
- package/src/router.test.ts +130 -0
- package/src/router.ts +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @checkstack/catalog-backend
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [889dd8c]
|
|
8
|
+
- @checkstack/auth-common@0.6.2
|
|
9
|
+
- @checkstack/auth-backend@0.4.19
|
|
10
|
+
- @checkstack/catalog-common@1.4.1
|
|
11
|
+
|
|
12
|
+
## 0.5.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- 80cbc51: Enforce GitOps provenance lock on backend API endpoints to prevent manual configuration drift for synchronized resources.
|
|
17
|
+
|
|
3
18
|
## 0.4.4
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/catalog-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"checkstack": {
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@checkstack/backend-api": "0.12.0",
|
|
17
17
|
"@checkstack/auth-common": "0.6.1",
|
|
18
|
-
"@checkstack/catalog-common": "1.
|
|
18
|
+
"@checkstack/catalog-common": "1.4.0",
|
|
19
19
|
"@checkstack/command-backend": "0.1.19",
|
|
20
20
|
"@checkstack/auth-backend": "0.4.18",
|
|
21
|
-
"@checkstack/gitops-backend": "0.
|
|
22
|
-
"@checkstack/gitops-common": "0.
|
|
21
|
+
"@checkstack/gitops-backend": "0.2.0",
|
|
22
|
+
"@checkstack/gitops-common": "0.2.0",
|
|
23
23
|
"@checkstack/notification-common": "0.2.8",
|
|
24
24
|
"@orpc/server": "^1.13.2",
|
|
25
25
|
"drizzle-orm": "^0.45.0",
|
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
|
},
|