History

feat: enhance secret management in SDK

- Introduced `SecretRef` class to represent stored secrets without their values. - Updated `SecretStore` to manage `SecretRef` instances, allowing for better handling of secret resolution and status checks. - Added `resolve` and `status` methods to `SecretStore` for retrieving secret values and checking their availability. - Refactored in-memory secret store to support the new structure and added a default in-memory secret provider. - Updated OpenAPI plugin to resolve secret-backed headers during invocation, improving security handling in API requests. - Enhanced tests to cover new secret management features and ensure proper resolution of secrets in headers. All changes are aimed at improving the flexibility and security of secret handling within the SDK.

Rhys Sullivan committed 7 months ago

Commit 8d3cddf1 parent 673821a11 files · +441 −87Browse files

Showing the changes to packages/plugins/openapi/src (4 of 11 files). Show all

Unified · Split

4 files
  1. packages/plugins/openapi/src/invoke.ts +68 −19
  2. packages/plugins/openapi/src/plugin.test.ts +96 −1
  3. packages/plugins/openapi/src/plugin.ts +11 −3
  4. packages/plugins/openapi/src/types.ts +15 −2
packages/plugins/openapi/src/invoke.ts+68−19
@@ -9,11 +9,14 @@ import { · 8 unmodified lines
9 type ToolInvoker,9 type ToolInvoker,
10 ToolInvocationResult,10 ToolInvocationResult,
11 ToolInvocationError,11 ToolInvocationError,
12 type ScopeId,
13 type SecretId,
12} from "@executor/sdk";14} from "@executor/sdk";
13 15
14import { OpenApiInvocationError } from "./errors";16import { OpenApiInvocationError } from "./errors";
15import type { OpenApiOperationStore } from "./operation-store";17import type { OpenApiOperationStore } from "./operation-store";
16import {18import {
19 type HeaderValue,
17 type OperationBinding,20 type OperationBinding,
18 InvocationConfig,21 InvocationConfig,
19 InvocationResult,22 InvocationResult,
@@ -116,9 +119,35 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 96 unmodified lines
116});119});
117 120
118// ---------------------------------------------------------------------------121// ---------------------------------------------------------------------------
119// Header application122// Header resolution — resolves secret refs at invocation time
120// ---------------------------------------------------------------------------123// ---------------------------------------------------------------------------
121 124
125const resolveHeaders = (
126 headers: Record<string, HeaderValue>,
127 secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> },
128 scopeId: ScopeId,
129): Effect.Effect<Record<string, string>, ToolInvocationError> =>
130 Effect.gen(function* () {
131 const resolved: Record<string, string> = {};
132 for (const [name, value] of Object.entries(headers)) {
133 if (typeof value === "string") {
134 resolved[name] = value;
135 } else {
136 const secret = yield* secrets.resolve(value.secretId as SecretId, scopeId).pipe(
137 Effect.mapError(() =>
138 new ToolInvocationError({
139 toolId: "" as ToolId,
140 message: `Failed to resolve secret "${value.secretId}" for header "${name}"`,
141 cause: undefined,
142 }),
143 ),
144 );
145 resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret;
146 }
147 }
148 return resolved;
149 });
150
122const applyHeaders = (151const applyHeaders = (
123 request: HttpClientRequest.HttpClientRequest,152 request: HttpClientRequest.HttpClientRequest,
124 headers: Record<string, string>,153 headers: Record<string, string>,
@@ -153,6 +182,8 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 28 unmodified lines
153 operation: OperationBinding,182 operation: OperationBinding,
154 args: Record<string, unknown>,183 args: Record<string, unknown>,
155 config: InvocationConfig,184 config: InvocationConfig,
185 /** Pre-resolved headers (secrets already resolved) */
186 resolvedHeaders?: Record<string, string>,
156) {187) {
157 const client = yield* HttpClient.HttpClient;188 const client = yield* HttpClient.HttpClient;
158 189
@@ -204,8 +235,8 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 45 unmodified lines
204 }235 }
205 }236 }
206 237
207 // Static headers (auth, custom headers, etc.)238 // Static headers (auth, custom headers, etc.) — use pre-resolved if available
208 request = applyHeaders(request, config.headers);239 request = applyHeaders(request, resolvedHeaders ?? {});
209 240
210 // Execute241 // Execute
211 const response = yield* client.execute(request).pipe(242 const response = yield* client.execute(request).pipe(
@@ -247,13 +278,15 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 35 unmodified lines
247// ToolInvoker — bridges operation store + HTTP client into SDK invoker278// ToolInvoker — bridges operation store + HTTP client into SDK invoker
248// ---------------------------------------------------------------------------279// ---------------------------------------------------------------------------
249 280
250export const makeOpenApiInvoker = (281export const makeOpenApiInvoker = (opts: {
251 operationStore: OpenApiOperationStore,282 readonly operationStore: OpenApiOperationStore;
252 httpClientLayer: Layer.Layer<HttpClient.HttpClient>,283 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>;
253): ToolInvoker => ({284 readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> };
285 readonly scopeId: ScopeId;
286}): ToolInvoker => ({
254 invoke: (toolId: ToolId, args: unknown, _options) =>287 invoke: (toolId: ToolId, args: unknown, _options) =>
255 Effect.gen(function* () {288 Effect.gen(function* () {
256 const entry = yield* operationStore.get(toolId);289 const entry = yield* opts.operationStore.get(toolId);
257 if (!entry) {290 if (!entry) {
258 return yield* new ToolInvocationError({291 return yield* new ToolInvocationError({
259 toolId,292 toolId,
@@ -265,6 +298,13 @@ export const makeOpenApiInvoker = ( · 5 unmodified lines
265 const { binding, config } = entry;298 const { binding, config } = entry;
266 const baseUrl = config.baseUrl;299 const baseUrl = config.baseUrl;
267 300
301 // Resolve secret-backed headers
302 const resolvedHeaders = yield* resolveHeaders(
303 config.headers,
304 opts.secrets,
305 opts.scopeId,
306 );
307
268 const clientWithBaseUrl = baseUrl308 const clientWithBaseUrl = baseUrl
269 ? Layer.effect(309 ? Layer.effect(
270 HttpClient.HttpClient,310 HttpClient.HttpClient,
@@ -274,13 +314,14 @@ export const makeOpenApiInvoker = ( · 3 unmodified lines
274 HttpClientRequest.prependUrl(baseUrl),314 HttpClientRequest.prependUrl(baseUrl),
275 ),315 ),
276 ),316 ),
277 ).pipe(Layer.provide(httpClientLayer))317 ).pipe(Layer.provide(opts.httpClientLayer))
278 : httpClientLayer;318 : opts.httpClientLayer;
279 319
280 const result = yield* invoke(320 const result = yield* invoke(
281 binding,321 binding,
282 (args ?? {}) as Record<string, unknown>,322 (args ?? {}) as Record<string, unknown>,
283 config,323 config,
324 resolvedHeaders,
284 ).pipe(Effect.provide(clientWithBaseUrl));325 ).pipe(Effect.provide(clientWithBaseUrl));
285 326
286 return new ToolInvocationResult({327 return new ToolInvocationResult({
@@ -289,14 +330,22 @@ export const makeOpenApiInvoker = ( · 2 unmodified lines
289 status: result.status,330 status: result.status,
290 });331 });
291 }).pipe(332 }).pipe(
292 Effect.mapError((err) =>333 Effect.catchAll((err) => {
293 err._tag === "ToolInvocationError"334 if (
294 ? err335 typeof err === "object" &&
295 : new ToolInvocationError({336 err !== null &&
296 toolId,337 "_tag" in err &&
297 message: `OpenAPI invocation failed: ${err.message}`,338 (err as { _tag: string })._tag === "ToolInvocationError"
298 cause: err,339 ) {
299 }),340 return Effect.fail(err as ToolInvocationError);
300 ),341 }
342 return Effect.fail(
343 new ToolInvocationError({
344 toolId,
345 message: `OpenAPI invocation failed: ${err instanceof Error ? err.message : String(err)}`,
346 cause: err,
347 }),
348 );
349 }),
301 ),350 ),
302});351});
packages/plugins/openapi/src/plugin.test.ts+96−1
@@ -6,11 +6,12 @@ import { · 5 unmodified lines
6 HttpApiEndpoint,6 HttpApiEndpoint,
7 HttpApiGroup,7 HttpApiGroup,
8 HttpClient,8 HttpClient,
9 HttpServerRequest,
9 OpenApi,10 OpenApi,
10} from "@effect/platform";11} from "@effect/platform";
11import { NodeHttpServer } from "@effect/platform-node";12import { NodeHttpServer } from "@effect/platform-node";
12 13
13import { createExecutor, makeTestConfig } from "@executor/sdk";14import { createExecutor, makeTestConfig, SecretId } from "@executor/sdk";
14import { openApiPlugin } from "./plugin";15import { openApiPlugin } from "./plugin";
15 16
16// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
@@ -22,6 +23,11 @@ class Item extends Schema.Class<Item>("Item")({ · 5 unmodified lines
22 name: Schema.String,23 name: Schema.String,
23}) {}24}) {}
24 25
26class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({
27 authorization: Schema.optional(Schema.String),
28 "x-static": Schema.optional(Schema.String),
29}) {}
30
25const ItemsGroup = HttpApiGroup.make("items")31const ItemsGroup = HttpApiGroup.make("items")
26 .add(32 .add(
27 HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)),33 HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)),
@@ -30,6 +36,9 @@ const ItemsGroup = HttpApiGroup.make("items") · 2 unmodified lines
30 HttpApiEndpoint.get("getItem", "/items/:itemId")36 HttpApiEndpoint.get("getItem", "/items/:itemId")
31 .setPath(Schema.Struct({ itemId: Schema.NumberFromString }))37 .setPath(Schema.Struct({ itemId: Schema.NumberFromString }))
32 .addSuccess(Item),38 .addSuccess(Item),
39 )
40 .add(
41 HttpApiEndpoint.get("echoHeaders", "/echo-headers").addSuccess(EchoHeaders),
33 );42 );
34 43
35const TestApi = HttpApi.make("testApi").add(ItemsGroup);44const TestApi = HttpApi.make("testApi").add(ItemsGroup);
@@ -57,6 +66,15 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => · 21 unmodified lines
57 name: "Unknown",66 name: "Unknown",
58 },67 },
59 ),68 ),
69 )
70 .handle("echoHeaders", () =>
71 Effect.gen(function* () {
72 const req = yield* HttpServerRequest.HttpServerRequest;
73 return new EchoHeaders({
74 authorization: req.headers["authorization"],
75 "x-static": req.headers["x-static"],
76 });
77 }),
60 ),78 ),
61);79);
62 80
@@ -98,6 +116,83 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 35 unmodified lines
98 }),116 }),
99 );117 );
100 118
119 it.effect("resolves secret-backed headers at invocation time", () =>
120 Effect.gen(function* () {
121 const httpClient = yield* HttpClient.HttpClient;
122 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
123
124 const executor = yield* createExecutor(
125 makeTestConfig({
126 plugins: [
127 openApiPlugin({ httpClientLayer: clientLayer }),
128 ] as const,
129 }),
130 );
131
132 // Store a secret
133 yield* executor.secrets.set({
134 id: SecretId.make("test-api-token"),
135 name: "Test API Token",
136 value: "secret-value-123",
137 });
138
139 // Add spec with secret-backed header
140 yield* executor.openapi.addSpec({
141 spec: specJson,
142 namespace: "authed",
143 baseUrl: "",
144 headers: {
145 "Authorization": { secretId: "test-api-token", prefix: "Bearer " },
146 "X-Static": "hello",
147 },
148 });
149
150 // Invoke the echo endpoint — verifies secret was resolved and sent
151 const result = yield* executor.tools.invoke(
152 "authed.items.echoHeaders",
153 {},
154 );
155
156 expect(result.error).toBeNull();
157 const data = result.data as { authorization?: string; "x-static"?: string };
158 expect(data.authorization).toBe("Bearer secret-value-123");
159 expect(data["x-static"]).toBe("hello");
160 }),
161 );
162
163 it.effect("fails clearly when a secret is missing", () =>
164 Effect.gen(function* () {
165 const httpClient = yield* HttpClient.HttpClient;
166 const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
167
168 const executor = yield* createExecutor(
169 makeTestConfig({
170 plugins: [
171 openApiPlugin({ httpClientLayer: clientLayer }),
172 ] as const,
173 }),
174 );
175
176 // Add spec with secret-backed header but DON'T store the secret
177 yield* executor.openapi.addSpec({
178 spec: specJson,
179 namespace: "noauth",
180 baseUrl: "",
181 headers: {
182 "Authorization": { secretId: "missing-token", prefix: "Bearer " },
183 },
184 });
185
186 // Invoke — should fail with a clear error about the missing secret
187 const error = yield* Effect.flip(
188 executor.tools.invoke("noauth.items.listItems", {}),
189 );
190
191 expect(error._tag).toBe("ToolInvocationError");
192 expect((error as { message: string }).message).toContain("missing-token");
193 }),
194 );
195
101 it.effect("registers tools from an OpenAPI spec", () =>196 it.effect("registers tools from an OpenAPI spec", () =>
102 Effect.gen(function* () {197 Effect.gen(function* () {
103 const httpClient = yield* HttpClient.HttpClient;198 const httpClient = yield* HttpClient.HttpClient;
packages/plugins/openapi/src/plugin.ts+11−3
@@ -29,12 +29,15 @@ import { · 28 unmodified lines
29// Plugin config29// Plugin config
30// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
31 31
32/** A header value — either a static string or a reference to a secret */
33export type HeaderValue = string | { readonly secretId: string; readonly prefix?: string };
34
32export interface OpenApiSpecConfig {35export interface OpenApiSpecConfig {
33 readonly spec: string;36 readonly spec: string;
34 readonly baseUrl?: string;37 readonly baseUrl?: string;
35 readonly namespace?: string;38 readonly namespace?: string;
36 /** Static headers applied to every request for this spec */39 /** Headers applied to every request. Values can reference secrets. */
37 readonly headers?: Record<string, string>;40 readonly headers?: Record<string, HeaderValue>;
38}41}
39 42
40// ---------------------------------------------------------------------------43// ---------------------------------------------------------------------------
@@ -105,7 +108,12 @@ export const openApiPlugin = (options?: { · 64 unmodified lines
105 Effect.gen(function* () {108 Effect.gen(function* () {
106 yield* ctx.tools.registerInvoker(109 yield* ctx.tools.registerInvoker(
107 "openapi",110 "openapi",
108 makeOpenApiInvoker(operationStore, httpClientLayer),111 makeOpenApiInvoker({
112 operationStore,
113 httpClientLayer,
114 secrets: ctx.secrets,
115 scopeId: ctx.scope.id,
116 }),
109 );117 );
110 118
111 return {119 return {
packages/plugins/openapi/src/types.ts+15−2
@@ -94,13 +94,26 @@ export class OperationBinding extends Schema.Class<OperationBinding>( · 93 unmodified lines
94// Invocation94// Invocation
95// ---------------------------------------------------------------------------95// ---------------------------------------------------------------------------
96 96
97/**
98 * A header value — either a static string or a reference to a secret.
99 * Stored as JSON-serializable data.
100 */
101export const HeaderValue = Schema.Union(
102 Schema.String,
103 Schema.Struct({
104 secretId: Schema.String,
105 prefix: Schema.optional(Schema.String),
106 }),
107);
108export type HeaderValue = typeof HeaderValue.Type;
109
97export class InvocationConfig extends Schema.Class<InvocationConfig>(110export class InvocationConfig extends Schema.Class<InvocationConfig>(
98 "InvocationConfig",111 "InvocationConfig",
99)({112)({
100 baseUrl: Schema.String,113 baseUrl: Schema.String,
101 /** Static headers applied to every request (auth, custom headers, etc.) */114 /** Headers applied to every request. Values can reference secrets. */
102 headers: Schema.optionalWith(115 headers: Schema.optionalWith(
103 Schema.Record({ key: Schema.String, value: Schema.String }),116 Schema.Record({ key: Schema.String, value: HeaderValue }),
104 { default: () => ({}) },117 { default: () => ({}) },
105 ),118 ),
106}) {}119}) {}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t