Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/clean-clouds-adopt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Adopt late legacy Microsoft integrations into OpenAPI ownership without renaming their scoped integration or OAuth connections, and preserve declared resource scopes in authorization requests.
83 changes: 83 additions & 0 deletions apps/host-cloudflare/src/db/data-migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ describe("runCloudflareDataMigrations", () => {
"2026-07-08-provider-service-split",
"2026-07-09-openapi-ndjson-output-arrays",
"2026-07-27-encrypted-secrets-owner-repartition",
"2026-08-05-microsoft-openapi-ownership",
]);
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);

Expand Down Expand Up @@ -287,6 +288,7 @@ describe("runCloudflareDataMigrations", () => {
"2026-07-08-provider-service-split",
"2026-07-09-openapi-ndjson-output-arrays",
"2026-07-27-encrypted-secrets-owner-repartition",
"2026-08-05-microsoft-openapi-ownership",
]);
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);

Expand Down Expand Up @@ -339,6 +341,7 @@ describe("runCloudflareDataMigrations", () => {
"2026-07-08-provider-service-split",
"2026-07-09-openapi-ndjson-output-arrays",
"2026-07-27-encrypted-secrets-owner-repartition",
"2026-08-05-microsoft-openapi-ownership",
]);
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);

Expand All @@ -353,4 +356,84 @@ describe("runCloudflareDataMigrations", () => {
yield* Effect.promise(() => db.close());
}),
);

it.effect("adopts a scoped Microsoft integration and copies its R2 serving state", () =>
Effect.gen(function* () {
const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() }));
const { bucket, objects } = makeFakeR2();

yield* Effect.promise(() =>
insertIntegration(db.client, {
rowId: "microsoft-graph-row",
tenant: "org_1",
slug: "microsoft_graph",
pluginId: "microsoft",
config: {
specHash: "graph-hash",
microsoftGraphPresetIds: ["profile"],
authenticationTemplate: [
{
slug: "azureAdDelegated",
kind: "oauth2",
authorizationUrl: "https://login.example/authorize",
tokenUrl: "https://login.example/token",
scopes: ["offline_access", "User.Read"],
},
],
},
}),
);
yield* Effect.promise(() =>
insertOperationStorage(db.client, {
tenant: "org_1",
pluginId: "microsoft",
integration: "microsoft_graph",
}),
);
objects.set("o:org_1/microsoft/spec/graph-hash", "graph spec");
objects.set("o:org_1/microsoft/defs/graph-hash", "graph defs");

yield* Effect.promise(() =>
db.client.execute(
"CREATE TABLE data_migration (name TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)",
),
);
for (const name of [
"2026-06-20-google-openapi-ownership",
"2026-07-08-provider-service-split",
"2026-07-09-openapi-ndjson-output-arrays",
"2026-07-27-encrypted-secrets-owner-repartition",
]) {
yield* Effect.promise(() =>
db.client.execute({
sql: "INSERT INTO data_migration (name, time_completed) VALUES (?, ?)",
args: [name, now],
}),
);
}

const d1 = makeFakeD1(db.client);
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([
"2026-08-05-microsoft-openapi-ownership",
]);
expect(yield* Effect.promise(() => runCloudflareDataMigrations(d1, bucket))).toEqual([]);

expect(objects.get("o:org_1/openapi/spec/graph-hash")).toBe("graph spec");
expect(objects.get("o:org_1/openapi/defs/graph-hash")).toBe("graph defs");

const integrations = yield* Effect.promise(() =>
db.client.execute("SELECT slug, plugin_id FROM integration"),
);
expect(integrations.rows).toEqual([{ slug: "microsoft_graph", plugin_id: "openapi" }]);

const storage = yield* Effect.promise(() =>
db.client.execute("SELECT plugin_id, key FROM plugin_storage"),
);
expect(storage.rows).toEqual([
{ plugin_id: "openapi", key: "microsoft_graph.calendar.events.list" },
]);

yield* Effect.promise(() => db.close());
}),
);
});
56 changes: 56 additions & 0 deletions apps/host-cloudflare/src/db/data-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import {
} from "@executor-js/sdk";
import { openApiNdjsonOutputDataMigration } from "@executor-js/plugin-openapi";
import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
import {
microsoftOpenApiOwnershipCandidate,
microsoftOpenApiOwnershipDataMigration,
runSqliteMicrosoftOpenApiOwnershipMigration,
} from "@executor-js/plugin-openapi/providers/microsoft";
import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-encrypted-secrets";

import {
Expand Down Expand Up @@ -224,6 +229,47 @@ const copyProviderServiceSplitBlobsToR2 = (
}),
});

const copyMicrosoftOpenApiOwnershipBlobsToR2 = (
client: SqliteDataMigrationClient,
bucket: R2Bucket,
): Effect.Effect<void, DataMigrationError> =>
Effect.gen(function* () {
const attempt = <A>(run: () => Promise<A>): Effect.Effect<A, DataMigrationError> =>
Effect.tryPromise({
try: run,
catch: (cause) =>
new DataMigrationError({
migration: microsoftOpenApiOwnershipDataMigration.name,
cause,
}),
});
const result = yield* attempt(() =>
client.execute(
`SELECT tenant, json_extract(config, '$.specHash') AS spec_hash
FROM integration
WHERE ${microsoftOpenApiOwnershipCandidate()}`,
),
);
for (const row of result.rows) {
if (typeof row.tenant !== "string" || typeof row.spec_hash !== "string") continue;
const tenant = row.tenant;
const specHash = row.spec_hash;
for (const key of [`spec/${specHash}`, `defs/${specHash}`]) {
const target = r2ObjectName(tenant, "openapi", key);
if ((yield* attempt(() => bucket.head(target))) != null) continue;
const source = yield* attempt(() => bucket.get(r2ObjectName(tenant, "microsoft", key)));
if (source == null) {
return yield* new DataMigrationError({
migration: microsoftOpenApiOwnershipDataMigration.name,
cause: `Missing Microsoft OpenAPI ownership source object ${key}`,
});
}
const value = yield* attempt(() => source.text());
yield* attempt(() => bucket.put(target, value));
}
}
});

const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly SqliteDataMigration[] => [
{
name: googleOpenApiOwnershipDataMigration.name,
Expand All @@ -250,6 +296,16 @@ const cloudflareDataMigrations = (bucket: R2Bucket | undefined): readonly Sqlite
// Re-file credential rows the pre-fix provider stored under the acting
// caller's partition instead of the owner embedded in the item id (#1453).
encryptedSecretsRepartitionDataMigration,
{
name: microsoftOpenApiOwnershipDataMigration.name,
run: (client) =>
Effect.gen(function* () {
if (bucket) yield* copyMicrosoftOpenApiOwnershipBlobsToR2(client, bucket);
yield* runSqliteMicrosoftOpenApiOwnershipMigration(client, {
blobBackend: bucket ? "external" : "database",
}).pipe(Effect.asVoid);
}),
},
];

export const runCloudflareDataMigrations = (
Expand Down
2 changes: 2 additions & 0 deletions apps/host-selfhost/src/db/data-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@executor-js/plugin-openapi";
import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
import { microsoftOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/microsoft";

import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split";
import { encryptedSecretsRepartitionDataMigration } from "@executor-js/plugin-encrypted-secrets";
Expand Down Expand Up @@ -41,4 +42,5 @@ export const selfHostDataMigrations: readonly SqliteDataMigration[] = [
// Re-file credential rows the pre-fix provider stored under the acting
// caller's partition instead of the owner embedded in the item id (#1453).
encryptedSecretsRepartitionDataMigration,
microsoftOpenApiOwnershipDataMigration,
];
2 changes: 2 additions & 0 deletions apps/local/src/db/data-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "@executor-js/plugin-openapi";
import { graphqlIntrospectionBlobDataMigration } from "@executor-js/plugin-graphql";
import { googleOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/google";
import { microsoftOpenApiOwnershipDataMigration } from "@executor-js/plugin-openapi/providers/microsoft";

import { providerServiceSplitDataMigration } from "@executor-js/plugin-provider-service-split";
import { authConfigTransforms } from "./auth-config-migration";
Expand Down Expand Up @@ -51,4 +52,5 @@ export const localDataMigrations: readonly SqliteDataMigration[] = [
// Stale-mark connections whose operations return NDJSON so their tool rows
// rebuild with array-wrapped output schemas (mirrors cloud's drizzle 0010).
openApiNdjsonOutputDataMigration,
microsoftOpenApiOwnershipDataMigration,
];
20 changes: 15 additions & 5 deletions packages/core/sdk/src/oauth-scope-union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,24 @@ describe("oauth.start integration-driven scopes", () => {
),
);

it.effect("filters stale declared scopes against authorization-server metadata", () =>
it.effect("requests declared resource scopes absent from authorization-server metadata", () =>
Effect.scoped(
Effect.gen(function* () {
const server = yield* serveOAuthTestServer({ scopes: ["calendar", "drive"] });
// Microsoft publishes OIDC scopes in authorization-server metadata,
// while Graph permissions are resource scopes and do not appear there.
// A declared integration contract must therefore remain authoritative.
const server = yield* serveMetadataServer({
authServerScopes: ["openid", "profile", "email", "offline_access"],
});
const graphScopes = [
"offline_access",
"User.Read",
"Files.Read.All",
"Sites.Read.All",
] as const;
const plugins = [
memoryCredentialsPlugin(),
makeScopePlugin({ scopes: ["calendar", "stale_scope", "drive"] }),
makeScopePlugin({ scopes: graphScopes }),
] as const;
const { executor } = yield* makeTestWorkspaceHarness({ plugins });
yield* executor.acme.seed();
Expand All @@ -241,7 +252,6 @@ describe("oauth.start integration-driven scopes", () => {
grant: "authorization_code",
clientId: "test-client",
clientSecret: "test-secret",
resource: server.resourceUrl,
});

const started = yield* executor.oauth.start({
Expand All @@ -255,7 +265,7 @@ describe("oauth.start integration-driven scopes", () => {
expect(started.status).toBe("redirect");
if (started.status !== "redirect") return;

expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["calendar", "drive"]);
expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([...graphScopes]);
}),
),
);
Expand Down
57 changes: 3 additions & 54 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ import {
discoverProtectedResourceMetadata,
OAuthDiscoveryError,
registerDynamicClient as registerDynamicClientDcr,
type OAuthAuthorizationServerMetadata,
} from "./oauth-discovery";
import {
assertSupportedOAuthEndpointUrl,
Expand Down Expand Up @@ -195,15 +194,6 @@ const refreshItemIdFor = (accessId: string): string => `${accessId}:refresh`;
/** Order-preserving de-duplication of a scope list. */
const dedupeScopes = (scopes: readonly string[]): readonly string[] => [...new Set(scopes)];

const intersectScopes = (
requested: readonly string[],
supported: readonly string[] | undefined,
): readonly string[] => {
if (!supported || supported.length === 0) return requested;
const supportedSet = new Set(supported);
return requested.filter((scope) => supportedSet.has(scope));
};

const recordedOAuthScope = (
token: OAuth2TokenResponse,
requestedScopes: readonly string[],
Expand Down Expand Up @@ -427,14 +417,6 @@ const canonicalUrlString = (value: string): string => {
return url.toString();
};

const oauthMetadataMatchesClient = (
client: Pick<LoadedOAuthClient, "authorizationUrl" | "tokenUrl">,
metadata: OAuthAuthorizationServerMetadata,
): boolean =>
canonicalUrlString(metadata.authorization_endpoint) ===
canonicalUrlString(client.authorizationUrl) &&
canonicalUrlString(metadata.token_endpoint) === canonicalUrlString(client.tokenUrl);

const isWellKnownOAuthMetadataUrl = (value: string): boolean => {
const path = new URL(value.trim()).pathname.toLowerCase();
return (
Expand Down Expand Up @@ -495,29 +477,6 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
// EXPLICIT — no localhost default. `null` means this executor has no OAuth
// callback; redirect-requiring flows fail loudly via `requireRedirectUri`.
const redirectUri = deps.redirectUri;
const discoveryOptions = { endpointUrlPolicy: deps.endpointUrlPolicy };

const filterAuthorizationCodeScopes = (
client: LoadedOAuthClient,
requestedScopes: readonly string[],
): Effect.Effect<readonly string[], never> =>
Effect.gen(function* () {
if (requestedScopes.length === 0) return requestedScopes;
const resource = client.resource
? yield* discoverProtectedResourceMetadata(client.resource, discoveryOptions).pipe(
Effect.catch(() => Effect.succeed(null)),
Effect.provide(httpClientLayer),
)
: null;
const issuer =
resource?.metadata.authorization_servers?.[0] ?? new URL(client.authorizationUrl).origin;
const as = yield* discoverAuthorizationServerMetadata(issuer, discoveryOptions).pipe(
Effect.catch(() => Effect.succeed(null)),
Effect.provide(httpClientLayer),
);
if (!as || !oauthMetadataMatchesClient(client, as.metadata)) return requestedScopes;
return intersectScopes(requestedScopes, as.metadata.scopes_supported);
}).pipe(Effect.catch(() => Effect.succeed(requestedScopes)));

// Caps on server-controlled discovery input — a hostile or buggy server must
// not be able to hang `oauth.start` or overflow the authorize URL.
Expand Down Expand Up @@ -1173,15 +1132,6 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
message: REDIRECT_URI_REQUIRED_MESSAGE,
});
}
// Prune stale DECLARED scopes against the AS's advertised set, but leave
// resource-discovered scopes untouched: an RFC 9728 `scopes_supported`
// list is already authoritative (§7.2) and must not be re-narrowed by a
// divergent authorization server.
const authorizationRequestedScopes =
scopePolicy.kind === "discover"
? requestedScopes
: yield* filterAuthorizationCodeScopes(client, requestedScopes);

// authorization_code: persist a session + build the authorize URL.
const verifier = createPkceCodeVerifier();
const challenge = yield* Effect.promise(() => createPkceCodeChallenge(verifier));
Expand All @@ -1206,14 +1156,13 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
redirect_url: flowRedirectUri,
pkce_verifier: verifier,
identity_label: input.identityLabel ?? null,
// Persist the requested scope set (declared ∪ client, filtered to the
// authorization-code flow) so `complete`'s recorded-scope fallback
// Persist the requested scope set so `complete`'s recorded-scope fallback
// reflects exactly what was requested when the AS omits `scope`,
// without re-resolving the integration's declared scopes at completion.
payload: {
owner: input.owner,
clientOwner: input.clientOwner,
requestedScopes: authorizationRequestedScopes,
requestedScopes,
},
expires_at: expiresAt,
created_at: now,
Expand All @@ -1226,7 +1175,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
authorizationUrl: client.authorizationUrl,
clientId: client.clientId,
redirectUrl: flowRedirectUri,
scopes: authorizationRequestedScopes,
scopes: requestedScopes,
state: providerState,
codeChallenge: challenge,
resource: client.resource ?? undefined,
Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/openapi/src/providers/microsoft/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ export {
type MicrosoftGraphSpecBuild,
} from "./graph";
export { microsoftGraphAdapter } from "./spec-format-adapter";
export {
microsoftOpenApiOwnershipCandidate,
microsoftOpenApiOwnershipDataMigration,
runSqliteMicrosoftOpenApiOwnershipMigration,
type MicrosoftOpenApiOwnershipMigrationOptions,
} from "./openapi-ownership-migration";
Loading