@checkstack/backend 0.14.0 → 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 +224 -0
- package/package.json +16 -16
- package/src/db.ts +109 -2
- package/src/index.ts +102 -6
- package/src/plugin-manager/api-router.ts +47 -8
- package/src/plugin-manager/app-principal-authz.test.ts +178 -0
- package/src/plugin-manager/auth-passthrough.test.ts +259 -0
- package/src/plugin-manager/core-services.ts +87 -6
- package/src/plugin-manager/pg-http-errors.test.ts +65 -0
- package/src/plugin-manager/pg-http-errors.ts +85 -0
- package/src/plugin-manager/plugin-loader.getservice.test.ts +38 -0
- package/src/plugin-manager/plugin-loader.skip-naming.test.ts +115 -0
- package/src/plugin-manager/plugin-loader.ts +64 -5
- package/src/plugin-manager.ts +5 -1
- package/src/services/collector-registry.ts +9 -0
- package/src/services/dev-auth.test.ts +71 -9
- package/src/services/dev-auth.ts +42 -5
- package/src/services/health-check-registry.test.ts +34 -0
- package/src/services/health-check-registry.ts +7 -0
- package/src/services/service-registry.test.ts +60 -0
- package/src/utils/plugin-discovery.test.ts +29 -4
|
@@ -24,6 +24,7 @@ import type { EventBus } from "@checkstack/backend-api";
|
|
|
24
24
|
import type { PluginMetadata } from "@checkstack/common";
|
|
25
25
|
import { rootLogger } from "../logger";
|
|
26
26
|
import { extractErrorMessage } from "@checkstack/common";
|
|
27
|
+
import { mapPgErrorToHttp } from "./pg-http-errors";
|
|
27
28
|
|
|
28
29
|
interface RouteHandlerDeps {
|
|
29
30
|
registry: ServiceRegistry;
|
|
@@ -139,14 +140,20 @@ async function resolveRequestContext({
|
|
|
139
140
|
deps.pluginMetadataRegistry.get(pluginId);
|
|
140
141
|
|
|
141
142
|
if (!pluginMetadata) {
|
|
142
|
-
|
|
143
|
+
// No metadata for this pluginId. The common cause is a client requesting an
|
|
144
|
+
// unknown plugin (typo / probing) - a 404, not a 500. A genuine
|
|
145
|
+
// misconfiguration (a core router that forgot
|
|
146
|
+
// pluginManager.registerCorePluginMetadata()) surfaces the same way, so we
|
|
147
|
+
// log it for diagnosis, but at warn level since the dominant case is a
|
|
148
|
+
// client error and erroring on every bad path would be noise.
|
|
149
|
+
(logger as Logger).warn(
|
|
143
150
|
`${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
|
|
144
|
-
`
|
|
145
|
-
`pluginManager.registerCorePluginMetadata().`,
|
|
151
|
+
`Either the plugin id is unknown (client typo / probe), or a core ` +
|
|
152
|
+
`router did not call pluginManager.registerCorePluginMetadata().`,
|
|
146
153
|
);
|
|
147
154
|
return {
|
|
148
155
|
ok: false,
|
|
149
|
-
response: c.json({ error: "
|
|
156
|
+
response: c.json({ error: "Not Found" }, 404),
|
|
150
157
|
};
|
|
151
158
|
}
|
|
152
159
|
|
|
@@ -163,6 +170,11 @@ async function resolveRequestContext({
|
|
|
163
170
|
cachePluginRegistry: cachePluginRegistry as CachePluginRegistry,
|
|
164
171
|
cacheManager: cacheManager as CacheManager,
|
|
165
172
|
user,
|
|
173
|
+
// The incoming request's headers, so a handler can forward the caller's OWN
|
|
174
|
+
// auth (session cookie / bearer) when it re-enters the router as the same
|
|
175
|
+
// user - e.g. an AI tool's user-scoped rpcClient (proposeTool/applyTool).
|
|
176
|
+
// Also read by correlationMiddleware for the inbound correlation id.
|
|
177
|
+
requestHeaders: c.req.raw.headers,
|
|
166
178
|
emitHook,
|
|
167
179
|
};
|
|
168
180
|
|
|
@@ -203,6 +215,35 @@ function logHandlerError({
|
|
|
203
215
|
}
|
|
204
216
|
}
|
|
205
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Shared interceptor catch path for both the RPC and REST handlers. A Postgres
|
|
220
|
+
* driver error caused by bad client input (bad uuid, out-of-range int, over-long
|
|
221
|
+
* string, constraint violation) is mapped to the correct 4xx `ORPCError` and
|
|
222
|
+
* logged at warn (it is a client mistake, not a server fault). Everything else
|
|
223
|
+
* keeps the existing error-level logging + rethrow so genuine 500s stay loud.
|
|
224
|
+
*/
|
|
225
|
+
function rethrowAsHttpError({
|
|
226
|
+
error,
|
|
227
|
+
pathname,
|
|
228
|
+
logger,
|
|
229
|
+
protocolLabel,
|
|
230
|
+
}: {
|
|
231
|
+
error: unknown;
|
|
232
|
+
pathname: string;
|
|
233
|
+
logger: Logger | undefined;
|
|
234
|
+
protocolLabel: string;
|
|
235
|
+
}): never {
|
|
236
|
+
const mapped = mapPgErrorToHttp(error);
|
|
237
|
+
if (mapped) {
|
|
238
|
+
(logger ?? rootLogger).warn(
|
|
239
|
+
`${protocolLabel} ${pathname}: ${mapped.code} (${extractErrorMessage(error)})`,
|
|
240
|
+
);
|
|
241
|
+
throw mapped;
|
|
242
|
+
}
|
|
243
|
+
logHandlerError({ error, pathname, logger, protocolLabel });
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
|
|
206
247
|
/**
|
|
207
248
|
* Creates the API route handler for Hono.
|
|
208
249
|
* Serves oRPC's native RPC wire protocol at /api/:pluginId/*.
|
|
@@ -242,13 +283,12 @@ export function createApiRouteHandler({
|
|
|
242
283
|
try {
|
|
243
284
|
return await next(rest);
|
|
244
285
|
} catch (error) {
|
|
245
|
-
|
|
286
|
+
rethrowAsHttpError({
|
|
246
287
|
error,
|
|
247
288
|
pathname,
|
|
248
289
|
logger,
|
|
249
290
|
protocolLabel: "RPC",
|
|
250
291
|
});
|
|
251
|
-
throw error;
|
|
252
292
|
}
|
|
253
293
|
},
|
|
254
294
|
],
|
|
@@ -321,13 +361,12 @@ export function createRestRouteHandler({
|
|
|
321
361
|
try {
|
|
322
362
|
return await next(rest);
|
|
323
363
|
} catch (error) {
|
|
324
|
-
|
|
364
|
+
rethrowAsHttpError({
|
|
325
365
|
error,
|
|
326
366
|
pathname,
|
|
327
367
|
logger,
|
|
328
368
|
protocolLabel: "REST",
|
|
329
369
|
});
|
|
330
|
-
throw error;
|
|
331
370
|
}
|
|
332
371
|
},
|
|
333
372
|
],
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { implement } from "@orpc/server";
|
|
5
|
+
import { createORPCClient } from "@orpc/client";
|
|
6
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
7
|
+
import type { ContractRouterClient } from "@orpc/contract";
|
|
8
|
+
import { access, proc } from "@checkstack/common";
|
|
9
|
+
import {
|
|
10
|
+
coreServices,
|
|
11
|
+
createMockRpcContext,
|
|
12
|
+
autoAuthMiddleware,
|
|
13
|
+
correlationMiddleware,
|
|
14
|
+
type AuthService,
|
|
15
|
+
type AuthUser,
|
|
16
|
+
type EventBus,
|
|
17
|
+
type RpcContext,
|
|
18
|
+
} from "@checkstack/backend-api";
|
|
19
|
+
import { ServiceRegistry } from "../services/service-registry";
|
|
20
|
+
import { createApiRouteHandler, registerApiRoute } from "./api-router";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hardening test for the automation `runAs` SERVICE-ACCOUNT principal.
|
|
24
|
+
*
|
|
25
|
+
* The whole design rests on ONE invariant: an automation's app-principal token
|
|
26
|
+
* resolves to an `application` principal that is subject to the FULL access-rule
|
|
27
|
+
* enforcement - it must NEVER be treated as a trusted `service` (which
|
|
28
|
+
* short-circuits all checks = god mode). If a future change made the
|
|
29
|
+
* app-principal a `service`, a prompt-injected AI Action could mutate any
|
|
30
|
+
* team's data. This test pins the contrast over real HTTP (real dispatcher,
|
|
31
|
+
* real `autoAuthMiddleware`, real loopback), so that regression goes red.
|
|
32
|
+
*
|
|
33
|
+
* It does not exercise the JWT mint/verify plumbing (that is mechanical); it
|
|
34
|
+
* guards the security-relevant outcome: application => enforced, service =>
|
|
35
|
+
* bypassed.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const manageRule = access("thing", "manage", "Manage things");
|
|
39
|
+
|
|
40
|
+
const targetContract = {
|
|
41
|
+
// Gated by `thing.manage`. autoAuthMiddleware qualifies it to
|
|
42
|
+
// `target.thing.manage` for the "target" plugin.
|
|
43
|
+
manageThing: proc({
|
|
44
|
+
operationType: "mutation",
|
|
45
|
+
userType: "authenticated",
|
|
46
|
+
access: [manageRule],
|
|
47
|
+
}).output(z.object({ ok: z.boolean(), kind: z.string() })),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type RootClient = ContractRouterClient<{ target: typeof targetContract }>;
|
|
51
|
+
|
|
52
|
+
function buildTargetRouter() {
|
|
53
|
+
const os = implement(targetContract)
|
|
54
|
+
.$context<RpcContext>()
|
|
55
|
+
.use(correlationMiddleware)
|
|
56
|
+
.use(autoAuthMiddleware);
|
|
57
|
+
return os.router({
|
|
58
|
+
manageThing: os.manageThing.handler(async ({ context }) => ({
|
|
59
|
+
ok: true,
|
|
60
|
+
kind: context.user?.type ?? "anonymous",
|
|
61
|
+
})),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const noopEventBus: EventBus = {
|
|
66
|
+
subscribe: async () => async () => {},
|
|
67
|
+
emit: async () => {},
|
|
68
|
+
emitLocal: async () => {},
|
|
69
|
+
shutdown: async () => {},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Maps bearer tokens to the principal kinds we want to contrast:
|
|
74
|
+
* - `app-ok` -> application HOLDING the qualified rule
|
|
75
|
+
* - `app-deny` -> application WITHOUT the rule (must be refused)
|
|
76
|
+
* - `svc` -> trusted service (short-circuits all checks)
|
|
77
|
+
*/
|
|
78
|
+
const realAuth: AuthService = {
|
|
79
|
+
async authenticate(request) {
|
|
80
|
+
const header = request.headers.get("authorization");
|
|
81
|
+
if (header === "Bearer app-ok") {
|
|
82
|
+
return {
|
|
83
|
+
type: "application",
|
|
84
|
+
id: "app-ok",
|
|
85
|
+
name: "Bounded App",
|
|
86
|
+
accessRules: ["target.thing.manage"],
|
|
87
|
+
} satisfies AuthUser;
|
|
88
|
+
}
|
|
89
|
+
if (header === "Bearer app-deny") {
|
|
90
|
+
return {
|
|
91
|
+
type: "application",
|
|
92
|
+
id: "app-deny",
|
|
93
|
+
name: "Under-privileged App",
|
|
94
|
+
accessRules: [],
|
|
95
|
+
} satisfies AuthUser;
|
|
96
|
+
}
|
|
97
|
+
if (header === "Bearer svc") {
|
|
98
|
+
return { type: "service", pluginId: "automation" } satisfies AuthUser;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
},
|
|
102
|
+
async getCredentials() {
|
|
103
|
+
return { headers: {} };
|
|
104
|
+
},
|
|
105
|
+
async getAnonymousAccessRules() {
|
|
106
|
+
return [];
|
|
107
|
+
},
|
|
108
|
+
async checkResourceTeamAccess() {
|
|
109
|
+
return { hasAccess: false };
|
|
110
|
+
},
|
|
111
|
+
async getAccessibleResourceIds({ resourceIds }) {
|
|
112
|
+
return resourceIds;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function buildRegistry(): ServiceRegistry {
|
|
117
|
+
const stub = createMockRpcContext();
|
|
118
|
+
const registry = new ServiceRegistry();
|
|
119
|
+
registry.register(coreServices.logger, stub.logger);
|
|
120
|
+
registry.register(coreServices.database, stub.db);
|
|
121
|
+
registry.register(coreServices.fetch, stub.fetch);
|
|
122
|
+
registry.register(coreServices.healthCheckRegistry, stub.healthCheckRegistry);
|
|
123
|
+
registry.register(coreServices.collectorRegistry, stub.collectorRegistry);
|
|
124
|
+
registry.register(coreServices.queuePluginRegistry, stub.queuePluginRegistry);
|
|
125
|
+
registry.register(coreServices.queueManager, stub.queueManager);
|
|
126
|
+
registry.register(coreServices.cachePluginRegistry, stub.cachePluginRegistry);
|
|
127
|
+
registry.register(coreServices.cacheManager, stub.cacheManager);
|
|
128
|
+
registry.register(coreServices.eventBus, noopEventBus);
|
|
129
|
+
registry.register(coreServices.auth, realAuth);
|
|
130
|
+
return registry;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const app = new Hono();
|
|
134
|
+
const apiHandler = createApiRouteHandler({
|
|
135
|
+
registry: buildRegistry(),
|
|
136
|
+
pluginRpcRouters: new Map<string, unknown>([["target", buildTargetRouter()]]),
|
|
137
|
+
pluginHttpHandlers: new Map(),
|
|
138
|
+
pluginMetadataRegistry: new Map([["target", { pluginId: "target" }]]),
|
|
139
|
+
});
|
|
140
|
+
registerApiRoute(app, apiHandler);
|
|
141
|
+
|
|
142
|
+
const listening = Bun.serve({ port: 0, fetch: app.fetch });
|
|
143
|
+
const baseUrl = `http://localhost:${listening.port}`;
|
|
144
|
+
|
|
145
|
+
afterAll(() => {
|
|
146
|
+
listening.stop(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
function clientWith(headers: Record<string, string>): RootClient {
|
|
150
|
+
return createORPCClient<RootClient>(
|
|
151
|
+
new RPCLink({ url: `${baseUrl}/api`, headers }),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("app-principal authorization (real HTTP)", () => {
|
|
156
|
+
test("an application HOLDING the rule is allowed (enforced, not bypassed)", async () => {
|
|
157
|
+
const result = await clientWith({
|
|
158
|
+
authorization: "Bearer app-ok",
|
|
159
|
+
}).target.manageThing();
|
|
160
|
+
expect(result).toEqual({ ok: true, kind: "application" });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("an application WITHOUT the rule is FORBIDDEN - the security crux", async () => {
|
|
164
|
+
// Proves the app-principal flows through full access-rule enforcement and is
|
|
165
|
+
// NOT short-circuited like a service. This is the guarantee that an
|
|
166
|
+
// automation can never exceed its service account's permissions.
|
|
167
|
+
await expect(
|
|
168
|
+
clientWith({ authorization: "Bearer app-deny" }).target.manageThing(),
|
|
169
|
+
).rejects.toThrow(/Missing access|FORBIDDEN/i);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a trusted service bypasses the check (the contrast we must NOT grant apps)", async () => {
|
|
173
|
+
const result = await clientWith({
|
|
174
|
+
authorization: "Bearer svc",
|
|
175
|
+
}).target.manageThing();
|
|
176
|
+
expect(result).toEqual({ ok: true, kind: "service" });
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { implement, ORPCError } from "@orpc/server";
|
|
5
|
+
import { createORPCClient } from "@orpc/client";
|
|
6
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
7
|
+
import type { ContractRouterClient } from "@orpc/contract";
|
|
8
|
+
import { proc } from "@checkstack/common";
|
|
9
|
+
import {
|
|
10
|
+
coreServices,
|
|
11
|
+
createMockRpcContext,
|
|
12
|
+
autoAuthMiddleware,
|
|
13
|
+
correlationMiddleware,
|
|
14
|
+
type AuthService,
|
|
15
|
+
type AuthUser,
|
|
16
|
+
type EventBus,
|
|
17
|
+
type RpcContext,
|
|
18
|
+
} from "@checkstack/backend-api";
|
|
19
|
+
import { ServiceRegistry } from "../services/service-registry";
|
|
20
|
+
import { createApiRouteHandler, registerApiRoute } from "./api-router";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* REAL end-to-end auth-passthrough test for the `/api/:pluginId/*` dispatcher.
|
|
24
|
+
*
|
|
25
|
+
* This guards the regression where `createApiRouteHandler` built the per-request
|
|
26
|
+
* `RpcContext` WITHOUT `requestHeaders`, so a handler that re-enters the router
|
|
27
|
+
* as the originating user (an AI tool's user-scoped rpcClient via
|
|
28
|
+
* `proposeTool`/`applyTool`) forwarded NO auth and the loopback failed with
|
|
29
|
+
* "Authentication required". It exercises the actual wiring over real HTTP - a
|
|
30
|
+
* real `Bun.serve`, the real dispatcher, real `autoAuthMiddleware`, and a real
|
|
31
|
+
* loopback oRPC client - with NO fakes for the path under test. The only stubs
|
|
32
|
+
* are unrelated core-service collaborators (db, registries, queue, cache), which
|
|
33
|
+
* none of these handlers touch.
|
|
34
|
+
*
|
|
35
|
+
* `caller.run` mirrors exactly what `proposeTool`/`applyTool` do: read
|
|
36
|
+
* `context.requestHeaders`, forward the caller's own cookie/bearer, and call a
|
|
37
|
+
* gated procedure on another plugin AS THAT USER. With the bug present,
|
|
38
|
+
* `caller.run` would throw "Authentication required" instead of returning the
|
|
39
|
+
* caller's id - so this test goes red without the fix and green with it.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// ─── Contracts (a "target" plugin gated by auth, a "caller" that loops back) ──
|
|
43
|
+
|
|
44
|
+
const targetContract = {
|
|
45
|
+
// Requires an authenticated user; echoes WHO the router authenticated.
|
|
46
|
+
whoami: proc({
|
|
47
|
+
operationType: "query",
|
|
48
|
+
userType: "authenticated",
|
|
49
|
+
access: [],
|
|
50
|
+
}).output(z.object({ id: z.string(), kind: z.string() })),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const callerContract = {
|
|
54
|
+
// PUBLIC so it is reachable without outer auth - that lets us prove the
|
|
55
|
+
// LOOPBACK itself enforces auth (an unauthenticated run still fails downstream).
|
|
56
|
+
run: proc({ operationType: "query", userType: "public", access: [] }).output(
|
|
57
|
+
z.object({ id: z.string(), kind: z.string() }),
|
|
58
|
+
),
|
|
59
|
+
// Directly surfaces whether the dispatcher populated `context.requestHeaders`.
|
|
60
|
+
echo: proc({ operationType: "query", userType: "public", access: [] }).output(
|
|
61
|
+
z.object({ seenAuthHeader: z.string().nullable() }),
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const rootContract = { target: targetContract, caller: callerContract };
|
|
66
|
+
type RootClient = ContractRouterClient<typeof rootContract>;
|
|
67
|
+
|
|
68
|
+
// Set once the server is listening; the caller handler reads it at call time.
|
|
69
|
+
const server = { baseUrl: "" };
|
|
70
|
+
|
|
71
|
+
// ─── Routers ──────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function buildTargetRouter() {
|
|
74
|
+
const os = implement(targetContract)
|
|
75
|
+
.$context<RpcContext>()
|
|
76
|
+
.use(correlationMiddleware)
|
|
77
|
+
.use(autoAuthMiddleware);
|
|
78
|
+
return os.router({
|
|
79
|
+
whoami: os.whoami.handler(async ({ context }) => {
|
|
80
|
+
// autoAuthMiddleware already rejected an unauthenticated caller; this guard
|
|
81
|
+
// also narrows the AuthUser union to the `user` variant (which carries id).
|
|
82
|
+
const user = context.user;
|
|
83
|
+
if (!user || user.type !== "user") {
|
|
84
|
+
throw new ORPCError("UNAUTHORIZED", {
|
|
85
|
+
message: "Authentication required",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return { id: user.id, kind: user.type };
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildCallerRouter() {
|
|
94
|
+
const os = implement(callerContract)
|
|
95
|
+
.$context<RpcContext>()
|
|
96
|
+
.use(correlationMiddleware)
|
|
97
|
+
.use(autoAuthMiddleware);
|
|
98
|
+
return os.router({
|
|
99
|
+
run: os.run.handler(async ({ context }) => {
|
|
100
|
+
// EXACTLY the proposeTool/applyTool pattern: forward the caller's own auth
|
|
101
|
+
// headers and re-enter the router as that user.
|
|
102
|
+
const forward: Record<string, string> = {};
|
|
103
|
+
const auth = context.requestHeaders?.get("authorization");
|
|
104
|
+
if (auth) forward.authorization = auth;
|
|
105
|
+
const cookie = context.requestHeaders?.get("cookie");
|
|
106
|
+
if (cookie) forward.cookie = cookie;
|
|
107
|
+
const link = new RPCLink({
|
|
108
|
+
url: `${server.baseUrl}/api`,
|
|
109
|
+
headers: forward,
|
|
110
|
+
});
|
|
111
|
+
const client = createORPCClient<RootClient>(link);
|
|
112
|
+
return client.target.whoami();
|
|
113
|
+
}),
|
|
114
|
+
echo: os.echo.handler(async ({ context }) => ({
|
|
115
|
+
seenAuthHeader: context.requestHeaders?.get("authorization") ?? null,
|
|
116
|
+
})),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Real auth: maps `Authorization: Bearer u-<id>` to a user principal ──────
|
|
121
|
+
|
|
122
|
+
// A typed no-op event bus: these handlers never emit, so the bus only needs to
|
|
123
|
+
// exist (the dispatcher requires it non-null). No fakes are needed for the path
|
|
124
|
+
// under test.
|
|
125
|
+
const noopEventBus: EventBus = {
|
|
126
|
+
subscribe: async () => async () => {},
|
|
127
|
+
emit: async () => {},
|
|
128
|
+
emitLocal: async () => {},
|
|
129
|
+
shutdown: async () => {},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const realAuth: AuthService = {
|
|
133
|
+
async authenticate(request) {
|
|
134
|
+
const header = request.headers.get("authorization");
|
|
135
|
+
const match = header?.match(/^Bearer u-(.+)$/);
|
|
136
|
+
if (!match) return undefined;
|
|
137
|
+
return {
|
|
138
|
+
type: "user",
|
|
139
|
+
id: match[1],
|
|
140
|
+
accessRules: ["*"],
|
|
141
|
+
} satisfies AuthUser;
|
|
142
|
+
},
|
|
143
|
+
async getCredentials() {
|
|
144
|
+
return { headers: {} };
|
|
145
|
+
},
|
|
146
|
+
async getAnonymousAccessRules() {
|
|
147
|
+
return [];
|
|
148
|
+
},
|
|
149
|
+
async checkResourceTeamAccess() {
|
|
150
|
+
return { hasAccess: false };
|
|
151
|
+
},
|
|
152
|
+
async getAccessibleResourceIds({ resourceIds }) {
|
|
153
|
+
return resourceIds;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ─── Harness: real dispatcher on a real Bun.serve ────────────────────────────
|
|
158
|
+
|
|
159
|
+
function buildRegistry(): ServiceRegistry {
|
|
160
|
+
// Reuse the canonical stub shapes for the collaborators none of these handlers
|
|
161
|
+
// touch; override auth with the real header-reading strategy and event bus.
|
|
162
|
+
const stub = createMockRpcContext();
|
|
163
|
+
const registry = new ServiceRegistry();
|
|
164
|
+
registry.register(coreServices.logger, stub.logger);
|
|
165
|
+
registry.register(coreServices.database, stub.db);
|
|
166
|
+
registry.register(coreServices.fetch, stub.fetch);
|
|
167
|
+
registry.register(coreServices.healthCheckRegistry, stub.healthCheckRegistry);
|
|
168
|
+
registry.register(coreServices.collectorRegistry, stub.collectorRegistry);
|
|
169
|
+
registry.register(coreServices.queuePluginRegistry, stub.queuePluginRegistry);
|
|
170
|
+
registry.register(coreServices.queueManager, stub.queueManager);
|
|
171
|
+
registry.register(coreServices.cachePluginRegistry, stub.cachePluginRegistry);
|
|
172
|
+
registry.register(coreServices.cacheManager, stub.cacheManager);
|
|
173
|
+
registry.register(coreServices.eventBus, noopEventBus);
|
|
174
|
+
registry.register(coreServices.auth, realAuth);
|
|
175
|
+
return registry;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const app = new Hono();
|
|
179
|
+
const apiHandler = createApiRouteHandler({
|
|
180
|
+
registry: buildRegistry(),
|
|
181
|
+
pluginRpcRouters: new Map<string, unknown>([
|
|
182
|
+
["target", buildTargetRouter()],
|
|
183
|
+
["caller", buildCallerRouter()],
|
|
184
|
+
]),
|
|
185
|
+
pluginHttpHandlers: new Map(),
|
|
186
|
+
pluginMetadataRegistry: new Map([
|
|
187
|
+
["target", { pluginId: "target" }],
|
|
188
|
+
["caller", { pluginId: "caller" }],
|
|
189
|
+
]),
|
|
190
|
+
});
|
|
191
|
+
registerApiRoute(app, apiHandler);
|
|
192
|
+
|
|
193
|
+
const listening = Bun.serve({ port: 0, fetch: app.fetch });
|
|
194
|
+
server.baseUrl = `http://localhost:${listening.port}`;
|
|
195
|
+
|
|
196
|
+
afterAll(() => {
|
|
197
|
+
listening.stop(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
function clientWith(headers: Record<string, string>): RootClient {
|
|
201
|
+
return createORPCClient<RootClient>(
|
|
202
|
+
new RPCLink({ url: `${server.baseUrl}/api`, headers }),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
describe("RPC dispatcher auth passthrough (real HTTP)", () => {
|
|
207
|
+
test("the dispatcher populates context.requestHeaders from the request", async () => {
|
|
208
|
+
const client = clientWith({ authorization: "Bearer u-alice" });
|
|
209
|
+
const echo = await client.caller.echo();
|
|
210
|
+
// The exact regression: without requestHeaders on the context this is null.
|
|
211
|
+
expect(echo.seenAuthHeader).toBe("Bearer u-alice");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("a request for an UNKNOWN plugin id returns 404, not 500", async () => {
|
|
215
|
+
// No metadata is registered for 'nope', so the dispatcher must treat it as a
|
|
216
|
+
// not-found resource (client error), not an internal server error.
|
|
217
|
+
const res = await fetch(`${server.baseUrl}/api/nope/whatever`, {
|
|
218
|
+
method: "POST",
|
|
219
|
+
headers: { "content-type": "application/json" },
|
|
220
|
+
body: "{}",
|
|
221
|
+
});
|
|
222
|
+
expect(res.status).toBe(404);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a loopback call re-enters the router authenticated AS the originating user", async () => {
|
|
226
|
+
const client = clientWith({ authorization: "Bearer u-alice" });
|
|
227
|
+
const result = await client.caller.run();
|
|
228
|
+
// caller.run forwarded alice's bearer to target.whoami, which authenticated
|
|
229
|
+
// as alice. With the bug (requestHeaders undefined) this throws
|
|
230
|
+
// "Authentication required" instead.
|
|
231
|
+
expect(result).toEqual({ id: "alice", kind: "user" });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("a different user's auth is forwarded as THAT user (no cross-user bleed)", async () => {
|
|
235
|
+
const result = await clientWith({ authorization: "Bearer u-bob" }).caller.run();
|
|
236
|
+
expect(result).toEqual({ id: "bob", kind: "user" });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("an unauthenticated loopback is refused downstream (auth still enforced)", async () => {
|
|
240
|
+
// caller.run is public, so the OUTER call proceeds; the loopback forwards no
|
|
241
|
+
// auth, so target.whoami refuses - proving the loopback does NOT bypass authz.
|
|
242
|
+
await expect(clientWith({}).caller.run()).rejects.toThrow(
|
|
243
|
+
/Authentication required|UNAUTHORIZED/i,
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("calling the gated procedure directly with auth works (sanity)", async () => {
|
|
248
|
+
const result = await clientWith({
|
|
249
|
+
authorization: "Bearer u-carol",
|
|
250
|
+
}).target.whoami();
|
|
251
|
+
expect(result).toEqual({ id: "carol", kind: "user" });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("calling the gated procedure directly WITHOUT auth is refused", async () => {
|
|
255
|
+
await expect(clientWith({}).target.whoami()).rejects.toThrow(
|
|
256
|
+
/Authentication required|UNAUTHORIZED/i,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { AuthApi } from "@checkstack/auth-common";
|
|
15
15
|
import type { ServiceRegistry } from "../services/service-registry";
|
|
16
16
|
import { rootLogger } from "../logger";
|
|
17
|
-
import { db } from "../db";
|
|
17
|
+
import { db, lockPool } from "../db";
|
|
18
18
|
import { jwtService } from "../services/jwt";
|
|
19
19
|
import {
|
|
20
20
|
CoreHealthCheckRegistry,
|
|
@@ -98,11 +98,14 @@ export function registerCoreServices({
|
|
|
98
98
|
return createScopedDb(db, assignedSchema);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
// 1b. Advisory Lock Factory (server-global, backed by the
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
101
|
+
// 1b. Advisory Lock Factory (server-global, backed by the DEDICATED
|
|
102
|
+
// `lockPool`, NOT `adminPool`). Both session locks (`tryAcquire`) and the
|
|
103
|
+
// transaction-scoped `withXactLock` HOLD a connection for the lock's whole
|
|
104
|
+
// lifetime while the locked work runs on `adminPool`. Drawing the lock
|
|
105
|
+
// connection from a separate pool keeps the acquire graph acyclic
|
|
106
|
+
// (lockPool -> adminPool, never back), so a held lock can never starve the
|
|
107
|
+
// work pool into the `idle in transaction` deadlock. See `db.ts`.
|
|
108
|
+
const advisoryLockService = createAdvisoryLockService(lockPool);
|
|
106
109
|
registry.registerFactory(
|
|
107
110
|
coreServices.advisoryLock,
|
|
108
111
|
() => advisoryLockService,
|
|
@@ -136,6 +139,38 @@ export function registerCoreServices({
|
|
|
136
139
|
pluginId: payload.service as string,
|
|
137
140
|
};
|
|
138
141
|
}
|
|
142
|
+
|
|
143
|
+
// App-principal token: minted by `rpcClientAs` so an automation can
|
|
144
|
+
// run as its configured service account. Resolve the application's
|
|
145
|
+
// CURRENT rules/teams LIVE (never trust frozen claims) and return an
|
|
146
|
+
// `application` principal, so it flows through the full access-rule
|
|
147
|
+
// and team-scope enforcement - NOT the trusted service short-circuit.
|
|
148
|
+
if (payload && typeof payload.appPrincipal === "string") {
|
|
149
|
+
try {
|
|
150
|
+
const rpcClient = await registry.get(coreServices.rpcClient, {
|
|
151
|
+
pluginId: "core",
|
|
152
|
+
});
|
|
153
|
+
const authClient = rpcClient.forPlugin(AuthApi);
|
|
154
|
+
const enriched = await authClient.enrichApplicationPrincipal({
|
|
155
|
+
applicationId: payload.appPrincipal,
|
|
156
|
+
});
|
|
157
|
+
if (!enriched) return; // app no longer exists -> unauthenticated
|
|
158
|
+
return {
|
|
159
|
+
type: "application" as const,
|
|
160
|
+
id: enriched.id,
|
|
161
|
+
name: enriched.name,
|
|
162
|
+
roles: enriched.roles,
|
|
163
|
+
accessRules: enriched.accessRules,
|
|
164
|
+
teamIds: enriched.teamIds,
|
|
165
|
+
};
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// SECURITY: Fail-Closed - never fall back to a broader principal.
|
|
168
|
+
rootLogger.error(
|
|
169
|
+
`[auth] app-principal enrichment failed for ${payload.appPrincipal}; denying. Error: ${error}`,
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
139
174
|
}
|
|
140
175
|
|
|
141
176
|
// Strategy B: User Token (via registered strategy)
|
|
@@ -312,6 +347,52 @@ export function registerCoreServices({
|
|
|
312
347
|
return rpcClient;
|
|
313
348
|
});
|
|
314
349
|
|
|
350
|
+
// 5b. Application-scoped RPC Client Factory.
|
|
351
|
+
// Returns a builder that mints a short-lived app-principal token and returns
|
|
352
|
+
// a client re-entering the live router AS THAT APPLICATION. The receiving
|
|
353
|
+
// `authenticate` (Strategy A) resolves the token to an `application`
|
|
354
|
+
// principal, so the call runs through the full access-rule + team-scope
|
|
355
|
+
// enforcement - NOT the trusted service short-circuit. The automation
|
|
356
|
+
// dispatch engine uses this to run an automation as its `runAs` account.
|
|
357
|
+
registry.registerFactory(coreServices.rpcClientAs, () => {
|
|
358
|
+
const apiBaseUrl = process.env.INTERNAL_URL || "http://localhost:3000";
|
|
359
|
+
return async (applicationId: string): Promise<RpcClient> => {
|
|
360
|
+
// Mint a FRESH short-lived token PER REQUEST (not once at client
|
|
361
|
+
// construction). An automation run can stay live far longer than the
|
|
362
|
+
// token TTL within a single un-suspended stretch - a long AI agent loop
|
|
363
|
+
// making many tool calls, or many sequential actions - and a client that
|
|
364
|
+
// baked one token would start failing with 401s once it expired. Minting
|
|
365
|
+
// per request (mirroring the trusted client's `getCredentials`) means the
|
|
366
|
+
// TTL only has to outlive a single in-flight request. (Across a
|
|
367
|
+
// suspend/resume the engine rebuilds the client, so waits are unaffected
|
|
368
|
+
// either way.)
|
|
369
|
+
const authedFetch = async (
|
|
370
|
+
input: RequestInfo | URL,
|
|
371
|
+
init?: RequestInit,
|
|
372
|
+
): Promise<Response> => {
|
|
373
|
+
const token = await jwtService.sign(
|
|
374
|
+
{ appPrincipal: applicationId },
|
|
375
|
+
"5m",
|
|
376
|
+
);
|
|
377
|
+
const headers = new Headers(init?.headers);
|
|
378
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
379
|
+
return fetch(input, { ...init, headers });
|
|
380
|
+
};
|
|
381
|
+
const link = new RPCLink({
|
|
382
|
+
url: `${apiBaseUrl}/api`,
|
|
383
|
+
fetch: authedFetch,
|
|
384
|
+
});
|
|
385
|
+
const appClient = createORPCClient(link);
|
|
386
|
+
return {
|
|
387
|
+
forPlugin(def) {
|
|
388
|
+
return (appClient as Record<string, unknown>)[
|
|
389
|
+
def.pluginId
|
|
390
|
+
] as never;
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
|
|
315
396
|
// 6. Health Check Registry (Scoped Factory - auto-prefixes strategy IDs with pluginId)
|
|
316
397
|
const globalHealthCheckRegistry = new CoreHealthCheckRegistry();
|
|
317
398
|
registry.registerFactory(coreServices.healthCheckRegistry, (metadata) =>
|