From 6da8991ac459a5801c68065999207dcda811c873 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 7 Oct 2026 16:10:30 +0200 Subject: [PATCH 1/2] feat(shared): migrate currentUser to the modular @databricks/sdk-scim client The facade's currentUser accessor now returns a lazily built ScimClient (sdk-scim 0.51.0, same core/auth/options 0.51.0) via mapToClientOptions, so SP and OBO auth resolve exactly like warehouses/statementExecution. Legacy currentUser.me() becomes ScimClient.me({}); it hits the same /api/2.0/preview/scim/v2/Me endpoint and the unmarshal keeps id and userName, the only fields AppKit reads. The CLI doctor check and the lakebase connector stay on the legacy client. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- knip.json | 1 + packages/appkit/package.json | 1 + .../appkit/src/context/service-context.ts | 2 +- packages/shared/package.json | 1 + .../shared/src/workspace-client/client.ts | 11 +++++++++-- .../shared/src/workspace-client/modular.ts | 10 ++++++++++ packages/shared/src/workspace-client/types.ts | 5 +++-- pnpm-lock.yaml | 19 +++++++++++++++++++ 8 files changed, 45 insertions(+), 5 deletions(-) diff --git a/knip.json b/knip.json index 21f7b9ca0..b46121129 100644 --- a/knip.json +++ b/knip.json @@ -14,6 +14,7 @@ "@databricks/sdk-core", "@databricks/sdk-experimental", "@databricks/sdk-options", + "@databricks/sdk-scim", "@databricks/sdk-statementexecution", "@databricks/sdk-warehouses", "@mlflow/core", diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 555f9da55..a00cc0c2e 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -75,6 +75,7 @@ "@databricks/sdk-core": "0.51.0", "@databricks/sdk-experimental": "0.17.0", "@databricks/sdk-options": "0.51.0", + "@databricks/sdk-scim": "0.51.0", "@databricks/sdk-statementexecution": "0.52.0", "@databricks/sdk-warehouses": "0.53.0", "@opentelemetry/api": "1.9.0", diff --git a/packages/appkit/src/context/service-context.ts b/packages/appkit/src/context/service-context.ts index 789457c93..c73cd31c0 100644 --- a/packages/appkit/src/context/service-context.ts +++ b/packages/appkit/src/context/service-context.ts @@ -186,7 +186,7 @@ export class ServiceContext { const [resolvedWorkspaceId, currentUser, resolvedResources] = await Promise.all([ ServiceContext.getWorkspaceId(wsClient), - wsClient.currentUser.me(), + wsClient.currentUser.me({}), WarehouseResource.resolve(wsClient, options?.warehouseId), ]); diff --git a/packages/shared/package.json b/packages/shared/package.json index 85d2a0672..867a9f2b4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -52,6 +52,7 @@ "@databricks/sdk-core": "0.51.0", "@databricks/sdk-experimental": "0.17.0", "@databricks/sdk-options": "0.51.0", + "@databricks/sdk-scim": "0.51.0", "@databricks/sdk-statementexecution": "0.52.0", "@databricks/sdk-warehouses": "0.53.0", "@standard-schema/spec": "1.1.0", diff --git a/packages/shared/src/workspace-client/client.ts b/packages/shared/src/workspace-client/client.ts index db0fab36e..29494bb3c 100644 --- a/packages/shared/src/workspace-client/client.ts +++ b/packages/shared/src/workspace-client/client.ts @@ -13,9 +13,11 @@ import { type WorkspaceClientOptions, } from "./legacy"; import { + buildScimClient, buildStatementExecutionClient, buildWarehousesClient, buildWorkspaceAuth, + type ScimClient, type StatementExecutionClient, type WarehousesClient, type WorkspaceAuth, @@ -29,6 +31,7 @@ export class AppKitWorkspaceClient implements WorkspaceClient { #warehouses?: WarehousesClient; #statementExecution?: StatementExecutionClient; #auth?: WorkspaceAuth; + #currentUser?: ScimClient; constructor(opts: WorkspaceClientOptions) { this.#opts = opts; @@ -66,8 +69,12 @@ export class AppKitWorkspaceClient implements WorkspaceClient { return this.#getLegacy().servingEndpoints; } - get currentUser() { - return this.#getLegacy().currentUser; + // Migrated to the modular SDK (SCIM) — built lazily, independent of the legacy client. + get currentUser(): ScimClient { + if (!this.#currentUser) { + this.#currentUser = buildScimClient(this.#opts); + } + return this.#currentUser; } // Modular auth + raw-request seam — built lazily, independent of the legacy client. diff --git a/packages/shared/src/workspace-client/modular.ts b/packages/shared/src/workspace-client/modular.ts index 4699983eb..058701ddd 100644 --- a/packages/shared/src/workspace-client/modular.ts +++ b/packages/shared/src/workspace-client/modular.ts @@ -40,6 +40,7 @@ import { } from "@databricks/sdk-core/http"; import { type ProfileOptions, resolve } from "@databricks/sdk-core/profiles"; import type { ClientOptions } from "@databricks/sdk-options/client"; +import { ScimClient } from "@databricks/sdk-scim/v1"; import { StatementExecutionClient } from "@databricks/sdk-statementexecution/v1"; import { WarehousesClient } from "@databricks/sdk-warehouses/v1"; @@ -490,7 +491,16 @@ export function buildStatementExecutionClient( // really throw (status in `httpStatusCode`, not the wrapper's `statusCode`). export { ApiError as ModularApiError } from "@databricks/sdk-core/apierror"; +/** + * Build a modular SCIM client from wrapper options. Backs the facade's + * `currentUser` accessor: legacy `currentUser.me()` is `ScimClient.me({})`. + */ +export function buildScimClient(opts: WorkspaceClientOptions): ScimClient { + return new ScimClient(mapToClientOptions(opts)); +} + // ── Client type re-exports (for the facade accessor types) ─────────────── +export type { ScimClient } from "@databricks/sdk-scim/v1"; export type { StatementExecutionClient } from "@databricks/sdk-statementexecution/v1"; export type { WarehousesClient } from "@databricks/sdk-warehouses/v1"; diff --git a/packages/shared/src/workspace-client/types.ts b/packages/shared/src/workspace-client/types.ts index 1adb4c5ca..1eb59394a 100644 --- a/packages/shared/src/workspace-client/types.ts +++ b/packages/shared/src/workspace-client/types.ts @@ -18,6 +18,7 @@ import type { StatementExecutionClient, WarehousesClient, WorkspaceAuth, + ScimClient, } from "./modular"; // Legacy SDK type namespaces for un-migrated services, re-exported so AppKit @@ -59,8 +60,8 @@ export interface WorkspaceClient extends WorkspaceAuth { /** Serving Endpoints. */ readonly servingEndpoints: LegacyWorkspaceClient["servingEndpoints"]; - /** Current user. */ - readonly currentUser: LegacyWorkspaceClient["currentUser"]; + /** Current user (modular SDK SCIM client; `me({})` returns the caller). */ + readonly currentUser: ScimClient; /** * Legacy SDK `Config`. Prefer `getHost()` / `authenticate(headers)` (modular, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 929d9c01e..ba0ae702c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: '@databricks/sdk-options': specifier: 0.51.0 version: 0.51.0 + '@databricks/sdk-scim': + specifier: 0.51.0 + version: 0.51.0 '@databricks/sdk-statementexecution': specifier: 0.52.0 version: 0.52.0(patch_hash=9377a46b883b548d47116662bf73b159a4da51ec28b9ff1896a7e4a7841c240c) @@ -621,6 +624,9 @@ importers: '@databricks/sdk-options': specifier: 0.51.0 version: 0.51.0 + '@databricks/sdk-scim': + specifier: 0.51.0 + version: 0.51.0 '@databricks/sdk-statementexecution': specifier: 0.52.0 version: 0.52.0(patch_hash=9377a46b883b548d47116662bf73b159a4da51ec28b9ff1896a7e4a7841c240c) @@ -2012,6 +2018,10 @@ packages: resolution: {integrity: sha512-p5uBh64Y1onnvwlEwfRsmKmGRI23Y4Ly8fZiCgNUwVDe4Z9d0ctI0mo1v4gnDqlmBaBml3TZfqn5BJBROCXsEw==} engines: {node: '>=22.0.0'} + '@databricks/sdk-scim@0.51.0': + resolution: {integrity: sha512-WSTpuID4gRoTxVApyxujW+MsI5ANvmn8/DqPK6fXqxCkSINLEbtR+GTk/th8xnADtJekKNIWzeORFblXd3idcg==} + engines: {node: '>=22.0.0'} + '@databricks/sdk-statementexecution@0.52.0': resolution: {integrity: sha512-ymDGDW67PJ/0VujM89mUrE6mbbrabsKJP5LvPv8ZbgzPR0bm0DwXMcQzvS0XUt0UGITjV2gqbx5jQwP3ijtOmg==} engines: {node: '>=22.0.0'} @@ -14321,6 +14331,15 @@ snapshots: '@databricks/sdk-auth': 0.51.0 '@databricks/sdk-core': 0.51.0 + '@databricks/sdk-scim@0.51.0': + dependencies: + '@databricks/sdk-auth': 0.51.0 + '@databricks/sdk-core': 0.51.0 + '@databricks/sdk-options': 0.51.0 + '@js-temporal/polyfill': 0.5.1 + json-bigint: 1.0.0 + zod: 4.3.6 + '@databricks/sdk-statementexecution@0.52.0(patch_hash=9377a46b883b548d47116662bf73b159a4da51ec28b9ff1896a7e4a7841c240c)': dependencies: '@databricks/sdk-auth': 0.51.0 From e26a83ce12ddd0b1d60aea061fb0a6526443e66f Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 9 Oct 2026 17:03:51 +0200 Subject: [PATCH 2/2] fix(appkit): resolve the dev OBO host fallback via modular getHost() createCallerContext fell back to the legacy SDK Config.host in development, which the legacy client fills only after its first API call. With currentUser.me() and the rest of startup on the modular SDK, no legacy call happens, so profile-only setups (no DATABRICKS_HOST) threw missingEnvVar("DATABRICKS_HOST") on every asUser call. Resolve the host once at init with wsClient.getHost() (local, profile-aware) and use it as the same dev-only fallback. A getHost() failure leaves it unset so callers keep getting missingEnvVar instead of failing startup. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../appkit/src/context/service-context.ts | 25 +++- .../src/context/tests/service-context.test.ts | 116 +++++++++++++----- 2 files changed, 106 insertions(+), 35 deletions(-) diff --git a/packages/appkit/src/context/service-context.ts b/packages/appkit/src/context/service-context.ts index c73cd31c0..ef9142159 100644 --- a/packages/appkit/src/context/service-context.ts +++ b/packages/appkit/src/context/service-context.ts @@ -44,6 +44,8 @@ export interface ServiceContextState { */ export class ServiceContext { private static instance: ServiceContextState | null = null; + /** Workspace host resolved at init (profile-aware); dev fallback for OBO. */ + private static resolvedHost: string | undefined; private static initPromise: Promise | null = null; /** @@ -111,11 +113,11 @@ export class ServiceContext { throw AuthenticationError.missingToken("user token"); } - // Local templates can configure only a profile, whose host the SDK resolved. + // Local templates can configure only a profile, whose host init resolved. const host = process.env.DATABRICKS_HOST || (process.env.NODE_ENV === "development" && ServiceContext.isInitialized() - ? ServiceContext.get().client.config?.host + ? ServiceContext.resolvedHost : undefined); if (!host) { throw ConfigurationError.missingEnvVar("DATABRICKS_HOST"); @@ -183,12 +185,14 @@ export class ServiceContext { const wsClient = client ?? createWorkspaceClient({ clientOptions: getClientOptions() }); - const [resolvedWorkspaceId, currentUser, resolvedResources] = + const [resolvedWorkspaceId, currentUser, resolvedResources, host] = await Promise.all([ ServiceContext.getWorkspaceId(wsClient), wsClient.currentUser.me({}), WarehouseResource.resolve(wsClient, options?.warehouseId), + ServiceContext.resolveHost(wsClient), ]); + ServiceContext.resolvedHost = host; if (!currentUser.id) { throw ConfigurationError.resourceNotFound("Service user ID"); @@ -240,12 +244,27 @@ export class ServiceContext { return workspaceId; } + /** + * Resolve the host locally (env or profile, no network). Never fails startup: + * without a host, createCallerContext throws missingEnvVar instead. + */ + private static async resolveHost( + client: WorkspaceClient, + ): Promise { + try { + return await client.getHost(); + } catch { + return undefined; + } + } + /** * Reset the service context. Only for testing purposes. */ static reset(): void { ServiceContext.instance = null; ServiceContext.initPromise = null; + ServiceContext.resolvedHost = undefined; WarehouseResource.reset(); } } diff --git a/packages/appkit/src/context/tests/service-context.test.ts b/packages/appkit/src/context/tests/service-context.test.ts index 3251ea867..f2859edb4 100644 --- a/packages/appkit/src/context/tests/service-context.test.ts +++ b/packages/appkit/src/context/tests/service-context.test.ts @@ -12,34 +12,47 @@ import { ServiceContext } from "../service-context"; // ── Mock the workspace-client wrapper ────────────────────────────── -const { mockMe, mockApiRequest, MockWorkspaceClient, MockConfigError } = - vi.hoisted(() => { - const mockMe = vi.fn(); - const mockApiRequest = vi.fn(); - - const MockWorkspaceClient = vi.fn().mockImplementation(() => ({ - currentUser: { me: mockMe }, - // Tests script legacy-style results; adapt them to the raw `Response` - // `client.request` returns (org id → response header, else JSON body). - request: async (req: unknown) => { - const result = await mockApiRequest(req); - const orgId = result?.["x-databricks-org-id"]; - return orgId !== undefined - ? new Response(null, { headers: { "x-databricks-org-id": orgId } }) - : new Response(JSON.stringify(result ?? {})); - }, - })); - - class MockConfigError extends Error { - baseMessage: string; - constructor(message: string) { - super(message); - this.baseMessage = message; - } +const { + mockMe, + mockGetHost, + mockApiRequest, + MockWorkspaceClient, + MockConfigError, +} = vi.hoisted(() => { + const mockMe = vi.fn(); + const mockGetHost = vi.fn(); + const mockApiRequest = vi.fn(); + + const MockWorkspaceClient = vi.fn().mockImplementation(() => ({ + currentUser: { me: mockMe }, + getHost: mockGetHost, + // Tests script legacy-style results; adapt them to the raw `Response` + // `client.request` returns (org id → response header, else JSON body). + request: async (req: unknown) => { + const result = await mockApiRequest(req); + const orgId = result?.["x-databricks-org-id"]; + return orgId !== undefined + ? new Response(null, { headers: { "x-databricks-org-id": orgId } }) + : new Response(JSON.stringify(result ?? {})); + }, + })); + + class MockConfigError extends Error { + baseMessage: string; + constructor(message: string) { + super(message); + this.baseMessage = message; } + } - return { mockMe, mockApiRequest, MockWorkspaceClient, MockConfigError }; - }); + return { + mockMe, + mockGetHost, + mockApiRequest, + MockWorkspaceClient, + MockConfigError, + }; +}); vi.mock("../../workspace-client", async (importOriginal) => { const actual = @@ -325,26 +338,65 @@ describe("ServiceContext", () => { ); }); - test("uses the initialized profile host for local callers without DATABRICKS_HOST", () => { + // Regression: the dev fallback must come from the modular getHost() at + // init, not the legacy Config.host (only filled after a legacy API call). + async function initWithProfileHost() { + ServiceContext.reset(); + mockGetHost.mockResolvedValue("https://profile-host.example.com"); + await ServiceContext.initialize({ warehouseId: true }); + } + + test("uses the init-resolved profile host for local callers without DATABRICKS_HOST", async () => { delete process.env.DATABRICKS_HOST; process.env.NODE_ENV = "development"; - process.env.DATABRICKS_CONFIG_PROFILE = "selected-user"; - Object.defineProperty(ServiceContext.get().client, "config", { - value: { host: "https://profile-workspace.databricks.com" }, - }); + await initWithProfileHost(); const caller = ServiceContext.createCallerContext("user-token", "alice"); expect(caller.principal).toMatchObject({ type: "user", userId: "alice" }); expect(MockWorkspaceClient).toHaveBeenLastCalledWith( expect.objectContaining({ - host: "https://profile-workspace.databricks.com", + host: "https://profile-host.example.com", token: "user-token", authType: "pat", }), ); }); + test("DATABRICKS_HOST wins over the profile host", async () => { + process.env.NODE_ENV = "development"; + await initWithProfileHost(); + process.env.DATABRICKS_HOST = "https://env-host.example.com"; + + ServiceContext.createCallerContext("user-token", "alice"); + + expect(MockWorkspaceClient).toHaveBeenLastCalledWith( + expect.objectContaining({ host: "https://env-host.example.com" }), + ); + }); + + test("outside development the profile host is ignored and it still throws", async () => { + process.env.NODE_ENV = "production"; + await initWithProfileHost(); + delete process.env.DATABRICKS_HOST; + + expect(() => + ServiceContext.createCallerContext("user-token", "alice"), + ).toThrow(ConfigurationError); + }); + + test("a getHost() failure does not fail startup; callers get missingEnvVar", async () => { + ServiceContext.reset(); + process.env.NODE_ENV = "development"; + mockGetHost.mockRejectedValue(new Error("no host configured")); + await ServiceContext.initialize({ warehouseId: true }); + delete process.env.DATABRICKS_HOST; + + expect(() => + ServiceContext.createCallerContext("user-token", "alice"), + ).toThrow(ConfigurationError); + }); + test("should throw InitializationError when service context is not initialized", () => { ServiceContext.reset();