@checkstack/backend 0.15.0 → 0.16.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 +182 -0
- package/package.json +22 -22
- 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 +78 -0
- 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
|
@@ -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
|
+
});
|
|
@@ -139,6 +139,38 @@ export function registerCoreServices({
|
|
|
139
139
|
pluginId: payload.service as string,
|
|
140
140
|
};
|
|
141
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
|
+
}
|
|
142
174
|
}
|
|
143
175
|
|
|
144
176
|
// Strategy B: User Token (via registered strategy)
|
|
@@ -315,6 +347,52 @@ export function registerCoreServices({
|
|
|
315
347
|
return rpcClient;
|
|
316
348
|
});
|
|
317
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
|
+
|
|
318
396
|
// 6. Health Check Registry (Scoped Factory - auto-prefixes strategy IDs with pluginId)
|
|
319
397
|
const globalHealthCheckRegistry = new CoreHealthCheckRegistry();
|
|
320
398
|
registry.registerFactory(coreServices.healthCheckRegistry, (metadata) =>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ORPCError } from "@orpc/server";
|
|
3
|
+
import { mapPgErrorToHttp } from "./pg-http-errors";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Guards the regression where a client-caused Postgres fault (bad uuid,
|
|
7
|
+
* out-of-range int, over-long string, FK/unique violation) surfaced as a 500
|
|
8
|
+
* instead of a 4xx. The fuzzing pass found that `where id = $1` with a non-uuid
|
|
9
|
+
* `$1` reaches the driver and throws `22P02`, which oRPC reported as 500.
|
|
10
|
+
*/
|
|
11
|
+
describe("mapPgErrorToHttp", () => {
|
|
12
|
+
test("maps invalid_text_representation (bad uuid/int) to 400", () => {
|
|
13
|
+
const mapped = mapPgErrorToHttp({ code: "22P02" });
|
|
14
|
+
expect(mapped).toBeInstanceOf(ORPCError);
|
|
15
|
+
expect(mapped?.code).toBe("BAD_REQUEST");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("maps numeric_value_out_of_range to 400", () => {
|
|
19
|
+
expect(mapPgErrorToHttp({ code: "22003" })?.code).toBe("BAD_REQUEST");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("maps string_data_right_truncation to 400", () => {
|
|
23
|
+
expect(mapPgErrorToHttp({ code: "22001" })?.code).toBe("BAD_REQUEST");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("maps foreign_key_violation to 400", () => {
|
|
27
|
+
expect(mapPgErrorToHttp({ code: "23503" })?.code).toBe("BAD_REQUEST");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("maps unique_violation to 409 CONFLICT", () => {
|
|
31
|
+
expect(mapPgErrorToHttp({ code: "23505" })?.code).toBe("CONFLICT");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("unwraps a Drizzle-wrapped driver error via cause", () => {
|
|
35
|
+
const mapped = mapPgErrorToHttp({
|
|
36
|
+
message: "wrapped",
|
|
37
|
+
cause: { code: "22P02" },
|
|
38
|
+
});
|
|
39
|
+
expect(mapped?.code).toBe("BAD_REQUEST");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("returns undefined for an unknown SQLSTATE (genuine 500 stays a 500)", () => {
|
|
43
|
+
// 40001 = serialization_failure: a real server fault, not client input.
|
|
44
|
+
expect(mapPgErrorToHttp({ code: "40001" })).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("returns undefined for a non-Postgres error", () => {
|
|
48
|
+
expect(mapPgErrorToHttp(new Error("boom"))).toBeUndefined();
|
|
49
|
+
expect(mapPgErrorToHttp("nope")).toBeUndefined();
|
|
50
|
+
expect(mapPgErrorToHttp(undefined)).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("passes an already-mapped ORPCError through untouched (no double-map)", () => {
|
|
54
|
+
const original = new ORPCError("CONFLICT", { message: "dup" });
|
|
55
|
+
expect(mapPgErrorToHttp(original)).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("does not leak the raw driver message to the client", () => {
|
|
59
|
+
const mapped = mapPgErrorToHttp({
|
|
60
|
+
code: "22001",
|
|
61
|
+
message: 'value too long for column "secret_token"',
|
|
62
|
+
});
|
|
63
|
+
expect(mapped?.message).not.toContain("secret_token");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ORPCError } from "@orpc/server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Postgres SQLSTATE codes for the error classes a *client* can trigger with bad
|
|
6
|
+
* input. These must surface as 4xx, not 500 - a malformed uuid, an out-of-range
|
|
7
|
+
* integer, an over-long string, or a constraint violation is the caller's
|
|
8
|
+
* mistake, not an internal server error.
|
|
9
|
+
*
|
|
10
|
+
* Without this mapping a `where id = $1` with `$1 = "not-a-uuid"` reaches the
|
|
11
|
+
* driver and throws `22P02`, which oRPC reports as a 500 - making routine
|
|
12
|
+
* fuzzing/probing look like the server is broken and burying genuine 500s.
|
|
13
|
+
*
|
|
14
|
+
* @see https://www.postgresql.org/docs/current/errcodes-appendix.html
|
|
15
|
+
*/
|
|
16
|
+
const PG_CLIENT_ERROR_CODES: Record<string, { code: "BAD_REQUEST" | "CONFLICT"; message: string }> = {
|
|
17
|
+
// Class 22 — data exception (the caller sent a value the type can't hold).
|
|
18
|
+
"22P02": {
|
|
19
|
+
code: "BAD_REQUEST",
|
|
20
|
+
message: "Invalid input: a value was malformed (e.g. not a valid id).",
|
|
21
|
+
},
|
|
22
|
+
"22003": {
|
|
23
|
+
code: "BAD_REQUEST",
|
|
24
|
+
message: "Invalid input: a numeric value is out of the allowed range.",
|
|
25
|
+
},
|
|
26
|
+
"22001": {
|
|
27
|
+
code: "BAD_REQUEST",
|
|
28
|
+
message: "Invalid input: a text value exceeds the allowed length.",
|
|
29
|
+
},
|
|
30
|
+
"22007": {
|
|
31
|
+
code: "BAD_REQUEST",
|
|
32
|
+
message: "Invalid input: a date/time value is malformed.",
|
|
33
|
+
},
|
|
34
|
+
// Class 23 — integrity-constraint violation.
|
|
35
|
+
"23502": {
|
|
36
|
+
code: "BAD_REQUEST",
|
|
37
|
+
message: "Invalid input: a required value is missing.",
|
|
38
|
+
},
|
|
39
|
+
"23503": {
|
|
40
|
+
code: "BAD_REQUEST",
|
|
41
|
+
message: "Invalid input: a referenced record does not exist.",
|
|
42
|
+
},
|
|
43
|
+
"23505": {
|
|
44
|
+
code: "CONFLICT",
|
|
45
|
+
message: "That record already exists.",
|
|
46
|
+
},
|
|
47
|
+
"23514": {
|
|
48
|
+
code: "BAD_REQUEST",
|
|
49
|
+
message: "Invalid input: a value failed a validation constraint.",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// A Postgres driver error carries a SQLSTATE string in `code`. Drizzle may
|
|
54
|
+
// rethrow it wrapped, exposing the original on `cause`, so we check both shapes
|
|
55
|
+
// (mirrors the catalog-backend `pg-errors` matcher).
|
|
56
|
+
const pgErrorSchema = z.object({ code: z.string() });
|
|
57
|
+
const wrappedPgErrorSchema = z.object({ cause: pgErrorSchema });
|
|
58
|
+
|
|
59
|
+
function extractSqlState(error: unknown): string | undefined {
|
|
60
|
+
const direct = pgErrorSchema.safeParse(error);
|
|
61
|
+
if (direct.success) return direct.data.code;
|
|
62
|
+
const wrapped = wrappedPgErrorSchema.safeParse(error);
|
|
63
|
+
if (wrapped.success) return wrapped.data.cause.code;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Maps a caught Postgres driver error to an `ORPCError` with the right 4xx
|
|
69
|
+
* status when the SQLSTATE indicates a client-caused fault. Returns `undefined`
|
|
70
|
+
* for anything else (genuine 500s, application errors), so callers fall through
|
|
71
|
+
* to their existing error-logging + rethrow path.
|
|
72
|
+
*
|
|
73
|
+
* The original error is preserved as `cause` for diagnostics; the client-facing
|
|
74
|
+
* message is deliberately generic so we never leak column/constraint names.
|
|
75
|
+
*/
|
|
76
|
+
export function mapPgErrorToHttp(error: unknown): ORPCError<string, unknown> | undefined {
|
|
77
|
+
// An already-mapped oRPC error (e.g. a handler's own CONFLICT/NOT_FOUND) must
|
|
78
|
+
// pass through untouched.
|
|
79
|
+
if (error instanceof ORPCError) return undefined;
|
|
80
|
+
const sqlState = extractSqlState(error);
|
|
81
|
+
if (!sqlState) return undefined;
|
|
82
|
+
const mapping = PG_CLIENT_ERROR_CODES[sqlState];
|
|
83
|
+
if (!mapping) return undefined;
|
|
84
|
+
return new ORPCError(mapping.code, { message: mapping.message, cause: error });
|
|
85
|
+
}
|