Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/cloud/src/api/layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CoreHandlers } from "@executor/api/server";
import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";
import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";
import { GraphqlGroup, GraphqlHandlers } from "@executor/plugin-graphql/api";
import { SecretsUsageApi } from "@executor/react/api/secrets-usage";

import { OrgAuth } from "../auth/middleware";
import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live";
Expand All @@ -21,12 +22,14 @@ import { OrgHttpApi } from "../org/compose";
import { OrgHandlers } from "../org/handlers";

import { CoreSharedServices } from "./core-shared-services";
import { SecretsUsageHandlers } from "./secrets-usage";

export { CoreSharedServices };

const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
.add(McpGroup)
.add(GraphqlGroup)
.add(SecretsUsageApi)
.middleware(OrgAuth);

const DbLive = DbService.Live;
Expand All @@ -49,6 +52,7 @@ export const ProtectedCloudApiLive = HttpApiBuilder.api(ProtectedCloudApi).pipe(
OpenApiHandlers,
McpHandlers,
GraphqlHandlers,
SecretsUsageHandlers,
OrgAuthLive,
),
),
Expand Down
4 changes: 4 additions & 0 deletions apps/cloud/src/api/protected-layers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CoreHandlers } from "@executor/api/server";
import { OpenApiGroup, OpenApiHandlers } from "@executor/plugin-openapi/api";
import { McpGroup, McpHandlers } from "@executor/plugin-mcp/api";
import { GraphqlGroup, GraphqlHandlers } from "@executor/plugin-graphql/api";
import { SecretsUsageApi } from "@executor/react/api/secrets-usage";

import { OrgAuth } from "../auth/middleware";
import { OrgAuthLive } from "../auth/middleware-live";
Expand All @@ -23,10 +24,12 @@ import { WorkOSAuth } from "../auth/workos";
import { AutumnService } from "../services/autumn";
import { DbService } from "../services/db";
import { ErrorCaptureLive } from "../observability";
import { SecretsUsageHandlers } from "./secrets-usage";

export const ProtectedCloudApi = CoreExecutorApi.add(OpenApiGroup)
.add(McpGroup)
.add(GraphqlGroup)
.add(SecretsUsageApi)
.addError(InternalError)
.middleware(OrgAuth);

Expand All @@ -53,6 +56,7 @@ export const ProtectedCloudApiHandlers = Layer.mergeAll(
OpenApiHandlers,
McpHandlers,
GraphqlHandlers,
SecretsUsageHandlers,
);

// `ErrorCaptureLive` is provided above the handler + middleware layers
Expand Down
48 changes: 48 additions & 0 deletions apps/cloud/src/api/secrets-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { HttpApiBuilder } from "@effect/platform";
import { Effect } from "effect";

import { addGroup, capture, InternalError } from "@executor/api";
import { ExecutorService } from "@executor/api/server";
import { buildSecretsUsage } from "@executor/api/secrets/usage";
import { SecretsUsageApi } from "@executor/react/api/secrets-usage";
import { OpenApiExtensionService } from "@executor/plugin-openapi/api";
import { McpExtensionService } from "@executor/plugin-mcp/api";
import { GraphqlExtensionService } from "@executor/plugin-graphql/api";
import { collectOpenApiSecretIds } from "@executor/plugin-openapi";
import { collectMcpSecretIds } from "@executor/plugin-mcp";
import { collectGraphqlSecretIds } from "@executor/plugin-graphql";

const CloudApiWithSecretsUsage = addGroup(SecretsUsageApi).addError(InternalError);

export const SecretsUsageHandlers = HttpApiBuilder.group(
CloudApiWithSecretsUsage,
"secretsUsage",
(handlers) =>
handlers.handle("list", () =>
capture(Effect.gen(function* () {
const executor = yield* ExecutorService;
const openapi = yield* OpenApiExtensionService;
const mcp = yield* McpExtensionService;
const graphql = yield* GraphqlExtensionService;

const sources = (yield* executor.sources.list().pipe(
Effect.catchAll(() => Effect.succeed([])),
));

return yield* buildSecretsUsage(sources, {
openapi: (sourceId, scopeId) =>
openapi
.getSource(sourceId, scopeId)
.pipe(Effect.map((stored) => (stored ? collectOpenApiSecretIds(stored) : []))),
mcp: (sourceId, scopeId) =>
mcp
.getSource(sourceId, scopeId)
.pipe(Effect.map((stored) => (stored ? collectMcpSecretIds(stored) : []))),
graphql: (sourceId, scopeId) =>
graphql
.getSource(sourceId, scopeId)
.pipe(Effect.map((stored) => (stored ? collectGraphqlSecretIds(stored) : []))),
});
})),
),
);
58 changes: 58 additions & 0 deletions apps/cloud/src/openapi-source-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ScopeId } from "@executor/sdk";

const mocks = vi.hoisted(() => ({
useAtomValue: vi.fn(),
useScope: vi.fn(),
openApiSourceAtom: vi.fn(),
}));

vi.mock("@effect-atom/atom-react", async () => {
const actual = await vi.importActual<typeof import("@effect-atom/atom-react")>(
"@effect-atom/atom-react",
);
return {
...actual,
useAtomValue: mocks.useAtomValue,
Result: {
...actual.Result,
isSuccess: (result: { _tag?: string }) => result?._tag === "Success",
},
};
});

vi.mock("@executor/react/api/scope-context", () => ({
useScope: mocks.useScope,
}));

vi.mock("../../../packages/plugins/openapi/src/react/atoms", () => ({
openApiSourceAtom: mocks.openApiSourceAtom,
}));

import OpenApiSourceSummary from "../../../packages/plugins/openapi/src/react/OpenApiSourceSummary";

describe("OpenApiSourceSummary", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("loads source config from the owner scope when provided", () => {
const sourceAtom = Symbol("source-atom");
mocks.useScope.mockReturnValue("user-scope");
mocks.openApiSourceAtom.mockReturnValue(sourceAtom);
mocks.useAtomValue.mockReturnValue({
_tag: "Success",
value: {
config: {
oauth2: {
accessTokenSecretId: "access_token",
},
},
},
});

OpenApiSourceSummary({ sourceId: "shared", sourceScopeId: ScopeId.make("org-scope") });

expect(mocks.openApiSourceAtom).toHaveBeenCalledWith("org-scope", "shared");
});
});
30 changes: 27 additions & 3 deletions apps/cloud/src/routes/secrets.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,37 @@
import { createFileRoute } from "@tanstack/react-router";
import { useAtomValue } from "@effect-atom/atom-react";
import { secretsAtom } from "@executor/react/api/atoms";
import { ReactivityKey } from "@executor/react/api/reactivity-keys";
import { useScope } from "@executor/react/api/scope-context";
import { SecretsPage } from "@executor/react/pages/secrets";
import { resolveSecretsRouteState } from "@executor/react/pages/secrets-route";
import { CloudApiClient } from "../web/client";

export const Route = createFileRoute("/secrets")({
component: () => (
const secretsUsageAtom = (scopeId: ReturnType<typeof useScope>) =>
CloudApiClient.query("secretsUsage", "list", {
path: { scopeId },
timeToLive: "30 seconds",
reactivityKeys: [ReactivityKey.sources],
});

function CloudSecretsRoute() {
const scopeId = useScope();
const secrets = useAtomValue(secretsAtom(scopeId));
const usage = useAtomValue(secretsUsageAtom(scopeId));
const merged = resolveSecretsRouteState(secrets, usage);

return (
<SecretsPage
secretProviderPlugins={[]}
addSecretDescription="Store a credential or API key for this organization."
showProviderInfo={false}
storageOptions={[{ value: "workos-vault", label: "WorkOS Vault" }]}
secretsLoadState={merged.state}
secrets={merged.secrets}
/>
),
);
}

export const Route = createFileRoute("/secrets")({
component: CloudSecretsRoute,
});
1 change: 1 addition & 0 deletions apps/cloud/src/services/secrets-api.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe("secrets api (HTTP)", () => {
client.secrets.list({ path: { scopeId: ScopeId.make(org) } }),
);
expect(list.find((s) => s.id === id)?.name).toBe("My API Token");
expect(list.find((s) => s.id === id)).not.toHaveProperty("usedBy");

const resolved = yield* asOrg(org, (client) =>
client.secrets.resolve({
Expand Down
148 changes: 148 additions & 0 deletions apps/cloud/src/services/secrets-usage-api.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import { ScopeId, SecretId } from "@executor/sdk";

import { asOrg, asUser } from "./__test-harness__/api-harness";

describe("secrets usage api (HTTP)", () => {
it.effect("lists source usage for secrets referenced by source config", () =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const secretId = `sec_${crypto.randomUUID().slice(0, 8)}`;
const namespace = `api_${crypto.randomUUID().slice(0, 8)}`;

yield* asOrg(org, (client) =>
client.secrets.set({
path: { scopeId: ScopeId.make(org) },
payload: { id: SecretId.make(secretId), name: "Shared token", value: "sk-test-usage" },
}),
);

yield* asOrg(org, (client) =>
client.openapi.addSpec({
path: { scopeId: ScopeId.make(org) },
payload: {
spec: "https://openapi.vercel.sh",
namespace,
baseUrl: "https://api.vercel.com",
headers: {
Authorization: {
secretId,
prefix: "Bearer ",
},
},
},
}),
);

const usage = yield* asOrg(org, (client) =>
client.secretsUsage.list({ path: { scopeId: ScopeId.make(org) } }),
);

expect(usage).toEqual([
{
secretId,
usedBy: [
{
sourceId: namespace,
sourceName: "Vercel API",
sourceKind: "openapi",
},
],
},
]);
}),
);

it.effect("does not leak usage across orgs", () =>
Effect.gen(function* () {
const orgA = `org_${crypto.randomUUID()}`;
const orgB = `org_${crypto.randomUUID()}`;
const secretId = `sec_${crypto.randomUUID().slice(0, 8)}`;
const namespace = `api_${crypto.randomUUID().slice(0, 8)}`;

yield* asOrg(orgA, (client) =>
client.secrets.set({
path: { scopeId: ScopeId.make(orgA) },
payload: { id: SecretId.make(secretId), name: "Shared token", value: "sk-test-usage" },
}),
);

yield* asOrg(orgA, (client) =>
client.openapi.addSpec({
path: { scopeId: ScopeId.make(orgA) },
payload: {
spec: "https://openapi.vercel.sh",
namespace,
baseUrl: "https://api.vercel.com",
headers: {
Authorization: {
secretId,
prefix: "Bearer ",
},
},
},
}),
);

const usage = yield* asOrg(orgB, (client) =>
client.secretsUsage.list({ path: { scopeId: ScopeId.make(orgB) } }),
);

expect(usage).toEqual([]);
}),
);

it.effect("includes inherited org-scoped source usage for a member's user scope", () =>
Effect.gen(function* () {
const org = `org_${crypto.randomUUID()}`;
const userId = `user_${crypto.randomUUID().slice(0, 8)}`;
const secretId = `sec_${crypto.randomUUID().slice(0, 8)}`;
const namespace = `api_${crypto.randomUUID().slice(0, 8)}`;

yield* asOrg(org, (client) =>
client.secrets.set({
path: { scopeId: ScopeId.make(org) },
payload: { id: SecretId.make(secretId), name: "Org shared token", value: "sk-shared" },
}),
);

yield* asOrg(org, (client) =>
client.openapi.addSpec({
path: { scopeId: ScopeId.make(org) },
payload: {
spec: "https://openapi.vercel.sh",
namespace,
baseUrl: "https://api.vercel.com",
headers: {
Authorization: {
secretId,
prefix: "Bearer ",
},
},
},
}),
);

const usage = yield* asUser(userId, org, (client) =>
client.secretsUsage.list({
path: { scopeId: ScopeId.make(`user-org:${userId}:${org}`) },
}),
);

expect(usage).toEqual([
{
secretId,
usedBy: [
{
sourceId: namespace,
sourceName: "Vercel API",
sourceKind: "openapi",
},
],
},
]);
}),
);
});
Loading
Loading