diff --git a/.changeset/clean-clouds-adopt.md b/.changeset/clean-clouds-adopt.md new file mode 100644 index 000000000..16c06ceb9 --- /dev/null +++ b/.changeset/clean-clouds-adopt.md @@ -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. diff --git a/apps/host-cloudflare/src/db/data-migrations.test.ts b/apps/host-cloudflare/src/db/data-migrations.test.ts index 254c02377..ba8ae76b2 100644 --- a/apps/host-cloudflare/src/db/data-migrations.test.ts +++ b/apps/host-cloudflare/src/db/data-migrations.test.ts @@ -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([]); @@ -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([]); @@ -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([]); @@ -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()); + }), + ); }); diff --git a/apps/host-cloudflare/src/db/data-migrations.ts b/apps/host-cloudflare/src/db/data-migrations.ts index 4e511505d..7a64281af 100644 --- a/apps/host-cloudflare/src/db/data-migrations.ts +++ b/apps/host-cloudflare/src/db/data-migrations.ts @@ -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 { @@ -224,6 +229,47 @@ const copyProviderServiceSplitBlobsToR2 = ( }), }); +const copyMicrosoftOpenApiOwnershipBlobsToR2 = ( + client: SqliteDataMigrationClient, + bucket: R2Bucket, +): Effect.Effect => + Effect.gen(function* () { + const attempt = (run: () => Promise): Effect.Effect => + 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, @@ -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 = ( diff --git a/apps/host-selfhost/src/db/data-migrations.ts b/apps/host-selfhost/src/db/data-migrations.ts index 99d814f49..2cf9f97d9 100644 --- a/apps/host-selfhost/src/db/data-migrations.ts +++ b/apps/host-selfhost/src/db/data-migrations.ts @@ -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"; @@ -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, ]; diff --git a/apps/local/src/db/data-migrations.ts b/apps/local/src/db/data-migrations.ts index 522a7321d..4940c825b 100644 --- a/apps/local/src/db/data-migrations.ts +++ b/apps/local/src/db/data-migrations.ts @@ -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"; @@ -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, ]; diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 744e6f941..a391e279f 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -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(); @@ -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({ @@ -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]); }), ), ); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 4e4f32aa6..e06bc9546 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -54,7 +54,6 @@ import { discoverProtectedResourceMetadata, OAuthDiscoveryError, registerDynamicClient as registerDynamicClientDcr, - type OAuthAuthorizationServerMetadata, } from "./oauth-discovery"; import { assertSupportedOAuthEndpointUrl, @@ -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[], @@ -427,14 +417,6 @@ const canonicalUrlString = (value: string): string => { return url.toString(); }; -const oauthMetadataMatchesClient = ( - client: Pick, - 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 ( @@ -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 => - 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. @@ -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)); @@ -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, @@ -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, diff --git a/packages/plugins/openapi/src/providers/microsoft/index.ts b/packages/plugins/openapi/src/providers/microsoft/index.ts index fe51454d3..3b23c7bba 100644 --- a/packages/plugins/openapi/src/providers/microsoft/index.ts +++ b/packages/plugins/openapi/src/providers/microsoft/index.ts @@ -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"; diff --git a/packages/plugins/openapi/src/providers/microsoft/openapi-ownership-migration.test.ts b/packages/plugins/openapi/src/providers/microsoft/openapi-ownership-migration.test.ts new file mode 100644 index 000000000..ce6477caf --- /dev/null +++ b/packages/plugins/openapi/src/providers/microsoft/openapi-ownership-migration.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { collectTables } from "@executor-js/sdk"; +import type { SqliteDataMigrationClient } from "@executor-js/sdk/core"; +import { createSqliteTestFumaDb } from "@executor-js/sdk/testing"; + +import { runSqliteMicrosoftOpenApiOwnershipMigration } from "./openapi-ownership-migration"; + +const now = 1_780_000_000_000; + +const insertIntegration = ( + client: SqliteDataMigrationClient, + row: { readonly slug: string; readonly pluginId: string; readonly config: unknown }, +) => + client.execute({ + sql: `INSERT INTO integration + (row_id, tenant, slug, plugin_id, name, description, config, can_remove, can_refresh, created_at, updated_at) + VALUES (?, 'org_1', ?, ?, ?, ?, ?, 1, 1, ?, ?)`, + args: [ + `integration-${row.slug}`, + row.slug, + row.pluginId, + row.slug, + row.slug, + JSON.stringify(row.config), + now, + now, + ], + }); + +const insertBlob = (client: SqliteDataMigrationClient, key: string, value: string) => + client.execute({ + sql: "INSERT INTO blob (namespace, key, value, row_id, id) VALUES ('o:org_1/microsoft', ?, ?, ?, ?)", + args: [key, value, `blob-${key}`, JSON.stringify(["o:org_1/microsoft", key])], + }); + +const insertOperation = (client: SqliteDataMigrationClient) => + client.execute({ + sql: `INSERT INTO plugin_storage + (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id) + VALUES ('org_1', 'org', '', 'microsoft', 'operation', 'op.profile', ?, ?, ?, 'operation-row')`, + args: [ + JSON.stringify({ + integration: "microsoft_graph", + toolName: "meUser.meUserGetUser", + binding: { method: "get", pathTemplate: "/me" }, + }), + now, + now, + ], + }); + +const insertToolAndDefinition = (client: SqliteDataMigrationClient) => + Effect.promise(async () => { + await client.execute({ + sql: `INSERT INTO tool + (tenant, owner, subject, integration, connection, plugin_id, name, description, input_schema, output_schema, annotations, created_at, updated_at, row_id) + VALUES ('org_1', 'org', '', 'microsoft_graph', 'default', 'microsoft', 'me.get', 'Get me', NULL, NULL, NULL, ?, ?, 'tool-row')`, + args: [now, now], + }); + await client.execute({ + sql: `INSERT INTO definition + (tenant, owner, subject, integration, connection, plugin_id, name, schema, created_at, row_id) + VALUES ('org_1', 'org', '', 'microsoft_graph', 'default', 'microsoft', 'User', '{}', ?, 'definition-row')`, + args: [now], + }); + }); + +const insertConnection = (client: SqliteDataMigrationClient) => + client.execute({ + sql: `INSERT INTO connection + (tenant, owner, subject, integration, name, template, provider, item_ids, identity_label, + description, tools_synced_at, oauth_client, oauth_client_owner, refresh_item_id, expires_at, + oauth_scope, oauth_token_url, provider_state, created_at, updated_at, row_id) + VALUES ('org_1', 'org', '', 'microsoft_graph', 'default', 'azureAdDelegated', 'encrypted', ?, + 'graph@example.com', 'Graph', ?, 'microsoft-graph', 'org', ?, ?, ?, NULL, ?, ?, ?, 'connection-row')`, + args: [ + JSON.stringify({ token: "oauth:org:microsoft_graph:default" }), + now, + "oauth:org:microsoft_graph:default:refresh", + now + 3_600_000, + "Files.Read.All Sites.Read.All User.Read profile openid email", + JSON.stringify({ durable: true }), + now, + now, + ], + }); + +const scopedConfig = { + specHash: "graph-hash", + microsoftGraphPresetIds: ["profile"], + microsoftGraphCustomScopes: ["Files.Read.All", "Sites.Read.All"], + authenticationTemplate: [ + { + slug: "azureAdDelegated", + kind: "oauth2", + authorizationUrl: "https://login.example/authorize", + tokenUrl: "https://login.example/token", + scopes: ["offline_access", "User.Read", "Files.Read.All", "Sites.Read.All"], + }, + ], +}; + +describe("runSqliteMicrosoftOpenApiOwnershipMigration", () => { + it.effect("adopts a scoped Microsoft integration without changing its connection", () => + Effect.gen(function* () { + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); + const client = db.client; + + yield* Effect.promise(() => + insertIntegration(client, { + slug: "microsoft_graph", + pluginId: "microsoft", + config: scopedConfig, + }), + ); + yield* Effect.promise(() => + insertIntegration(client, { + slug: "microsoft", + pluginId: "microsoft", + config: scopedConfig, + }), + ); + yield* Effect.promise(() => insertBlob(client, "spec/graph-hash", "graph spec")); + yield* Effect.promise(() => insertBlob(client, "defs/graph-hash", "graph defs")); + yield* Effect.promise(() => insertOperation(client)); + yield* insertToolAndDefinition(client); + yield* Effect.promise(() => insertConnection(client)); + + const connectionBefore = yield* Effect.promise(() => + client.execute("SELECT * FROM connection WHERE integration = 'microsoft_graph'"), + ); + + expect(yield* runSqliteMicrosoftOpenApiOwnershipMigration(client)).toBe(1); + expect(yield* runSqliteMicrosoftOpenApiOwnershipMigration(client)).toBe(0); + + const integrations = yield* Effect.promise(() => + client.execute("SELECT slug, plugin_id, config FROM integration ORDER BY slug"), + ); + expect(integrations.rows).toEqual([ + { slug: "microsoft", plugin_id: "microsoft", config: JSON.stringify(scopedConfig) }, + { slug: "microsoft_graph", plugin_id: "openapi", config: JSON.stringify(scopedConfig) }, + ]); + + const blobs = yield* Effect.promise(() => + client.execute("SELECT namespace, key, value FROM blob ORDER BY namespace, key"), + ); + expect(blobs.rows).toEqual([ + { namespace: "o:org_1/microsoft", key: "defs/graph-hash", value: "graph defs" }, + { namespace: "o:org_1/microsoft", key: "spec/graph-hash", value: "graph spec" }, + { namespace: "o:org_1/openapi", key: "defs/graph-hash", value: "graph defs" }, + { namespace: "o:org_1/openapi", key: "spec/graph-hash", value: "graph spec" }, + ]); + + const ownershipRows = yield* Effect.promise(() => + client.execute( + `SELECT 'operation' AS kind, plugin_id FROM plugin_storage + UNION ALL SELECT 'tool', plugin_id FROM tool + UNION ALL SELECT 'definition', plugin_id FROM definition + ORDER BY kind`, + ), + ); + expect(ownershipRows.rows).toEqual([ + { kind: "definition", plugin_id: "openapi" }, + { kind: "operation", plugin_id: "openapi" }, + { kind: "tool", plugin_id: "openapi" }, + ]); + + const connectionAfter = yield* Effect.promise(() => + client.execute("SELECT * FROM connection WHERE integration = 'microsoft_graph'"), + ); + expect(connectionAfter.rows).toEqual(connectionBefore.rows); + + yield* Effect.promise(() => db.close()); + }), + ); + + it.effect("fails closed when a database-backed source blob is missing", () => + Effect.gen(function* () { + const db = yield* Effect.promise(() => createSqliteTestFumaDb({ tables: collectTables() })); + yield* Effect.promise(() => + insertIntegration(db.client, { + slug: "microsoft_graph", + pluginId: "microsoft", + config: scopedConfig, + }), + ); + yield* Effect.promise(() => insertBlob(db.client, "spec/graph-hash", "graph spec")); + + const error = yield* Effect.flip(runSqliteMicrosoftOpenApiOwnershipMigration(db.client)); + expect(error.migration).toBe("2026-08-05-microsoft-openapi-ownership"); + + const rows = yield* Effect.promise(() => + db.client.execute("SELECT slug, plugin_id FROM integration"), + ); + expect(rows.rows).toEqual([{ slug: "microsoft_graph", plugin_id: "microsoft" }]); + + yield* Effect.promise(() => db.close()); + }), + ); +}); diff --git a/packages/plugins/openapi/src/providers/microsoft/openapi-ownership-migration.ts b/packages/plugins/openapi/src/providers/microsoft/openapi-ownership-migration.ts new file mode 100644 index 000000000..03cc93913 --- /dev/null +++ b/packages/plugins/openapi/src/providers/microsoft/openapi-ownership-migration.ts @@ -0,0 +1,208 @@ +import { Effect } from "effect"; +import { DataMigrationError, type SqliteDataMigrationClient } from "@executor-js/sdk/core"; + +const MIGRATION_NAME = "2026-08-05-microsoft-openapi-ownership"; + +/** + * Microsoft integrations created after the provider-service split can retain + * the retired `microsoft` owner when an older host and a newer migration + * ledger overlap. The default `microsoft` monolith still belongs to the broad + * service-split migration; already-scoped integrations keep their slug and are + * adopted in place by OpenAPI. + */ +export const microsoftOpenApiOwnershipCandidate = (alias?: string): string => { + const column = (name: string) => (alias ? `${alias}.${name}` : name); + return `${column("plugin_id")} = 'microsoft' + AND ${column("slug")} <> 'microsoft' + AND ${column("config")} IS NOT NULL + AND json_valid(${column("config")}) + AND json_type(${column("config")}, '$.microsoftGraphPresetIds') = 'array' + AND json_type(${column("config")}, '$.authenticationTemplate') = 'array' + AND json_extract(${column("config")}, '$.specHash') IS NOT NULL + AND json_extract(${column("config")}, '$.specHash') <> ''`; +}; + +const execute = ( + client: SqliteDataMigrationClient, + stmt: string | { readonly sql: string; readonly args: readonly unknown[] }, +) => + Effect.tryPromise({ + try: () => client.execute(stmt), + catch: (cause) => new DataMigrationError({ migration: MIGRATION_NAME, cause }), + }); + +const migrationFailure = (message: string): DataMigrationError => + new DataMigrationError({ migration: MIGRATION_NAME, cause: message }); + +export interface MicrosoftOpenApiOwnershipMigrationOptions { + /** Cloudflare stores specs in R2 and verifies/copies them before this D1 step. */ + readonly blobBackend?: "database" | "external"; +} + +export const runSqliteMicrosoftOpenApiOwnershipMigration = ( + client: SqliteDataMigrationClient, + options: MicrosoftOpenApiOwnershipMigrationOptions = {}, +): Effect.Effect => + Effect.gen(function* () { + const exists = yield* execute( + client, + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'integration'", + ); + if (exists.rows.length === 0) return 0; + + const count = yield* execute( + client, + `SELECT COUNT(*) AS count FROM integration WHERE ${microsoftOpenApiOwnershipCandidate()}`, + ); + const moved = Number(count.rows[0]?.count ?? 0); + if (moved === 0) return 0; + + if ((options.blobBackend ?? "database") === "database") { + const missingBlobs = yield* execute( + client, + `SELECT COUNT(*) AS count + FROM integration m + WHERE ${microsoftOpenApiOwnershipCandidate("m")} + AND ( + NOT EXISTS ( + SELECT 1 FROM blob b + WHERE b.namespace = 'o:' || m.tenant || '/microsoft' + AND b.key = 'spec/' || json_extract(m.config, '$.specHash') + ) + OR NOT EXISTS ( + SELECT 1 FROM blob b + WHERE b.namespace = 'o:' || m.tenant || '/microsoft' + AND b.key = 'defs/' || json_extract(m.config, '$.specHash') + ) + )`, + ); + if (Number(missingBlobs.rows[0]?.count ?? 0) > 0) { + return yield* migrationFailure( + "Microsoft OpenAPI ownership source spec/defs blobs are incomplete", + ); + } + } + + const conflictingOperations = yield* execute( + client, + `SELECT COUNT(*) AS count + FROM plugin_storage source + JOIN integration m + ON m.tenant = source.tenant + JOIN plugin_storage target + ON target.tenant = source.tenant + AND target.owner = source.owner + AND target.subject = source.subject + AND target.plugin_id = 'openapi' + AND target.collection = source.collection + AND target.key = source.key + WHERE source.plugin_id = 'microsoft' + AND source.collection = 'operation' + AND ${microsoftOpenApiOwnershipCandidate("m")} + AND json_extract(source.data, '$.integration') = m.slug + AND target.data <> source.data`, + ); + if (Number(conflictingOperations.rows[0]?.count ?? 0) > 0) { + return yield* migrationFailure( + "Microsoft OpenAPI ownership found conflicting target operation rows", + ); + } + + const applyAll = Effect.gen(function* () { + if ((options.blobBackend ?? "database") === "database") { + yield* execute( + client, + `INSERT OR IGNORE INTO blob (namespace, key, value, row_id, id) + SELECT + 'o:' || m.tenant || '/openapi', + b.key, + b.value, + lower(hex(randomblob(16))), + json_array('o:' || m.tenant || '/openapi', b.key) + FROM integration m + JOIN blob b + ON b.namespace = 'o:' || m.tenant || '/microsoft' + AND b.key IN ( + 'spec/' || json_extract(m.config, '$.specHash'), + 'defs/' || json_extract(m.config, '$.specHash') + ) + WHERE ${microsoftOpenApiOwnershipCandidate("m")}`, + ); + } + + yield* execute( + client, + `INSERT OR IGNORE INTO plugin_storage + (tenant, owner, subject, plugin_id, collection, key, data, created_at, updated_at, row_id) + SELECT + source.tenant, + source.owner, + source.subject, + 'openapi', + source.collection, + source.key, + source.data, + source.created_at, + source.updated_at, + lower(hex(randomblob(16))) + FROM plugin_storage source + JOIN integration m + ON m.tenant = source.tenant + WHERE source.plugin_id = 'microsoft' + AND source.collection = 'operation' + AND ${microsoftOpenApiOwnershipCandidate("m")} + AND json_extract(source.data, '$.integration') = m.slug`, + ); + + yield* execute( + client, + `DELETE FROM plugin_storage + WHERE plugin_id = 'microsoft' + AND collection = 'operation' + AND EXISTS ( + SELECT 1 + FROM integration m + WHERE m.tenant = plugin_storage.tenant + AND ${microsoftOpenApiOwnershipCandidate("m")} + AND json_extract(plugin_storage.data, '$.integration') = m.slug + )`, + ); + + for (const table of ["tool", "definition"] as const) { + yield* execute( + client, + `UPDATE ${table} + SET plugin_id = 'openapi' + WHERE plugin_id = 'microsoft' + AND EXISTS ( + SELECT 1 + FROM integration m + WHERE m.tenant = ${table}.tenant + AND m.slug = ${table}.integration + AND ${microsoftOpenApiOwnershipCandidate("m")} + )`, + ); + } + + yield* execute( + client, + `UPDATE integration + SET plugin_id = 'openapi' + WHERE ${microsoftOpenApiOwnershipCandidate()}`, + ); + + yield* execute(client, "COMMIT"); + return moved; + }); + + yield* execute(client, "BEGIN"); + return yield* applyAll.pipe( + Effect.tapError(() => execute(client, "ROLLBACK").pipe(Effect.ignore)), + ); + }); + +export const microsoftOpenApiOwnershipDataMigration = { + name: MIGRATION_NAME, + run: (client: SqliteDataMigrationClient) => + runSqliteMicrosoftOpenApiOwnershipMigration(client).pipe(Effect.asVoid), +};