Skip to content

Commit 5a70675

Browse files
authored
Fix org OAuth connection token partitioning in encrypted-secrets (#1462)
* Add failing e2e repro for org OAuth token partitioning (#1453) * Partition encrypted-secrets rows by the owner embedded in the item id * Share the item-id owner grammar and harden the repartition migration
1 parent fc1e589 commit 5a70675

14 files changed

Lines changed: 809 additions & 93 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**Fix: org OAuth connections on self-host worked only for whoever ran the consent**
6+
7+
The encrypted-secrets credential provider (the writable provider on the self-hosted and Cloudflare hosts) filed token rows under the _acting user's_ private partition instead of the credential's own owner. An org-owned OAuth connection whose consent completed in one member's browser session therefore resolved only for that member — every other principal failed with `oauth_connection_missing`, while the UI showed the connection healthy. The provider now partitions by the owner embedded in the item id (`oauth:org:…` → org-shared), matching the WorkOS Vault provider, and a boot-time data migration re-files rows already written wrong. The encrypted value itself was never affected.

apps/host-cloudflare/src/db/data-migrations.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ describe("runCloudflareDataMigrations", () => {
241241
"2026-06-20-google-openapi-ownership",
242242
"2026-07-08-provider-service-split",
243243
"2026-07-09-openapi-ndjson-output-arrays",
244+
"2026-07-27-encrypted-secrets-owner-repartition",
244245
]);
245246
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
246247

@@ -285,6 +286,7 @@ describe("runCloudflareDataMigrations", () => {
285286
"2026-06-20-google-openapi-ownership",
286287
"2026-07-08-provider-service-split",
287288
"2026-07-09-openapi-ndjson-output-arrays",
289+
"2026-07-27-encrypted-secrets-owner-repartition",
288290
]);
289291
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
290292

@@ -336,6 +338,7 @@ describe("runCloudflareDataMigrations", () => {
336338
"2026-06-20-google-openapi-ownership",
337339
"2026-07-08-provider-service-split",
338340
"2026-07-09-openapi-ndjson-output-arrays",
341+
"2026-07-27-encrypted-secrets-owner-repartition",
339342
]);
340343
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);
341344

apps/host-cloudflare/src/db/data-migrations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "@executor-js/sdk";
1010
import { openApiNdjsonOutputDataMigration } from "@executor-js/plugin-openapi";
1111
import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
12+
import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-encrypted-secrets";
1213

1314
import {
1415
providerServiceSplitDataMigration,
@@ -246,6 +247,9 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite
246247
// Stale-mark connections whose operations return NDJSON so their tool rows
247248
// rebuild with array-wrapped output schemas (mirrors cloud's drizzle 0010).
248249
openApiNdjsonOutputDataMigration,
250+
// Re-file credential rows the pre-fix provider stored under the acting
251+
// caller's partition instead of the owner embedded in the item id (#1453).
252+
encryptedSecretsRepartitionDataMigration,
249253
];
250254

251255
export const runCloudflareDataMigrations = (

apps/host-selfhost/src/db/data-migrations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graph
1616
import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
1717

1818
import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split";
19+
import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-encrypted-secrets";
1920
import { authConfigTransforms } from "./auth-config-migration";
2021

2122
export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
@@ -37,4 +38,7 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
3738
// Stale-mark connections whose operations return NDJSON so their tool rows
3839
// rebuild with array-wrapped output schemas (mirrors cloud's drizzle 0010).
3940
openApiNdjsonOutputDataMigration,
41+
// Re-file credential rows the pre-fix provider stored under the acting
42+
// caller's partition instead of the owner embedded in the item id (#1453).
43+
encryptedSecretsRepartitionDataMigration,
4044
];

e2e/selfhost/mcp-browser-approval-ownership.test.ts

Lines changed: 4 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
// Selfhost-only: the browser-approval HTTP endpoints are session-scoped. A
22
// signed-in user who does not own the MCP session must not be able to read the
33
// paused execution or record the human decision for it.
4-
import { randomBytes } from "node:crypto";
5-
64
import { expect } from "@effect/vitest";
75
import { Effect } from "effect";
86
import { composePluginApi } from "@executor-js/api/server";
97

108
import { scenario } from "../src/scenario";
119
import { Api, Mcp, Target } from "../src/services";
1210
import { parseBrowserApproval } from "../src/surfaces/mcp";
13-
import type { Identity } from "../src/target";
14-
import { signInSession } from "../targets/selfhost";
11+
import { createInvitedIdentity } from "../targets/selfhost";
1512

1613
const coreApi = composePluginApi([] as const);
1714

@@ -21,46 +18,6 @@ const result = await tools.executor.coreTools.policies.list({});
2118
return JSON.stringify(result);
2219
`;
2320

24-
const createInvitedIdentity = async (baseUrl: string, admin: Identity): Promise<Identity> => {
25-
const cookie = admin.headers?.cookie;
26-
expect(typeof cookie, "bootstrap admin has a Better Auth session cookie").toBe("string");
27-
28-
const invite = await fetch(new URL("/api/admin/invites", baseUrl), {
29-
method: "POST",
30-
headers: {
31-
"content-type": "application/json",
32-
cookie: cookie!,
33-
origin: new URL(baseUrl).origin,
34-
},
35-
body: JSON.stringify({ role: "member" }),
36-
});
37-
expect(invite.status, `admin invite create response: ${await invite.clone().text()}`).toBe(200);
38-
const inviteBody = (await invite.json()) as { readonly code?: string };
39-
expect(typeof inviteBody.code, "invite response includes a redeemable code").toBe("string");
40-
41-
const email = `approval-cross-user-${randomBytes(5).toString("hex")}@e2e.test`;
42-
const password = "approval-cross-user-password-123";
43-
const signup = await fetch(new URL("/api/auth/sign-up/email", baseUrl), {
44-
method: "POST",
45-
headers: { "content-type": "application/json", origin: new URL(baseUrl).origin },
46-
body: JSON.stringify({
47-
email,
48-
password,
49-
name: email,
50-
inviteCode: inviteBody.code,
51-
}),
52-
});
53-
expect(signup.status, `invited signup response: ${await signup.clone().text()}`).toBe(200);
54-
55-
const session = await signInSession(baseUrl, { email, password });
56-
return {
57-
label: email,
58-
credentials: { email, password },
59-
headers: { cookie: session.cookieHeader },
60-
cookies: session.cookies,
61-
};
62-
};
63-
6421
const approvalEndpoint = (baseUrl: string, sessionId: string, executionId: string): URL =>
6522
new URL(
6623
`/api/mcp-sessions/${encodeURIComponent(sessionId)}/executions/${encodeURIComponent(
@@ -77,7 +34,9 @@ scenario(
7734
const api = yield* Api;
7835
const mcp = yield* Mcp;
7936
const owner = yield* target.newIdentity();
80-
const other = yield* Effect.promise(() => createInvitedIdentity(target.baseUrl, owner));
37+
const other = yield* Effect.promise(() =>
38+
createInvitedIdentity(target.baseUrl, owner, { emailPrefix: "approval-cross-user" }),
39+
);
8140
const client = yield* api.client(coreApi, owner);
8241

8342
const policy = yield* client.policies.create({

0 commit comments

Comments
 (0)