From 9869479a8925e2a87c4481d033ef9fd6a889af53 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Fri, 7 Aug 2026 10:18:25 +0200 Subject: [PATCH] fix(jobs): remove non-functional OBO (.asUser) support No jobs OAuth scope exists for Databricks Apps user authorization, so the documented .asUser(req) OBO path could never work at runtime. Remove the JobHandle type, the exports() asUser wrapper, related tests, and docs. Closes #515 Signed-off-by: Pawel Kosiec --- docs/docs/api/appkit/TypeAlias.JobHandle.md | 28 ---- docs/docs/api/appkit/TypeAlias.JobsExport.md | 7 +- docs/docs/api/appkit/index.md | 1 - docs/docs/api/appkit/typedoc-sidebar.ts | 5 - docs/docs/plugins/jobs.md | 14 +- packages/appkit/src/index.ts | 1 - packages/appkit/src/plugins/jobs/index.ts | 8 +- packages/appkit/src/plugins/jobs/plugin.ts | 19 +-- .../src/plugins/jobs/tests/plugin.test.ts | 127 +----------------- packages/appkit/src/plugins/jobs/types.ts | 15 +-- 10 files changed, 10 insertions(+), 215 deletions(-) delete mode 100644 docs/docs/api/appkit/TypeAlias.JobHandle.md diff --git a/docs/docs/api/appkit/TypeAlias.JobHandle.md b/docs/docs/api/appkit/TypeAlias.JobHandle.md deleted file mode 100644 index f20a11b95..000000000 --- a/docs/docs/api/appkit/TypeAlias.JobHandle.md +++ /dev/null @@ -1,28 +0,0 @@ -# Type Alias: JobHandle - -```ts -type JobHandle = JobAPI & { - asUser: (req: IAppRequest) => JobAPI; -}; -``` - -Job handle returned by `appkit.jobs("etl")`. -Supports OBO access via `.asUser(req)`. - -## Type Declaration - -### asUser() - -```ts -asUser: (req: IAppRequest) => JobAPI; -``` - -#### Parameters - -| Parameter | Type | -| ------ | ------ | -| `req` | `IAppRequest` | - -#### Returns - -[`JobAPI`](Interface.JobAPI.md) diff --git a/docs/docs/api/appkit/TypeAlias.JobsExport.md b/docs/docs/api/appkit/TypeAlias.JobsExport.md index 3191346bd..53d2fbeff 100644 --- a/docs/docs/api/appkit/TypeAlias.JobsExport.md +++ b/docs/docs/api/appkit/TypeAlias.JobsExport.md @@ -1,7 +1,7 @@ # Type Alias: JobsExport() ```ts -type JobsExport = (jobKey: string) => JobHandle; +type JobsExport = (jobKey: string) => JobAPI; ``` Public API shape of the jobs plugin. @@ -15,7 +15,7 @@ Callable to select a job by key. ## Returns -[`JobHandle`](TypeAlias.JobHandle.md) +[`JobAPI`](Interface.JobAPI.md) ## Example @@ -27,7 +27,4 @@ const { run_id } = await appkit.jobs("etl").runNow(); for await (const status of appkit.jobs("etl").runAndWait()) { console.log(status.status, status.run); } - -// OBO access -await appkit.jobs("etl").asUser(req).runNow(); ``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index b7d394176..f39a52db2 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -111,7 +111,6 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | [FilePolicy](TypeAlias.FilePolicy.md) | A policy function that decides whether `user` may perform `action` on `resource`. Return `true` to allow, `false` to deny. | | [HostedTool](TypeAlias.HostedTool.md) | - | | [IAppRouter](TypeAlias.IAppRouter.md) | Express router type for plugin route registration | -| [JobHandle](TypeAlias.JobHandle.md) | Job handle returned by `appkit.jobs("etl")`. Supports OBO access via `.asUser(req)`. | | [JobsExport](TypeAlias.JobsExport.md) | Public API shape of the jobs plugin. Callable to select a job by key. | | [PluginData](TypeAlias.PluginData.md) | Tuple of plugin class, config, and name. Created by `toPlugin()` and passed to `createApp()`. | | [Plugins](TypeAlias.Plugins.md) | Plugin map passed to the function form of [AgentDefinition.tools](Interface.AgentDefinition.md#tools). Each entry exposes a `.toolkit(opts?)` method that returns a record of [ToolkitEntry](Interface.ToolkitEntry.md) markers ready to be spread into a tool record. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index 489424a2e..18a5333b1 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -468,11 +468,6 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/TypeAlias.IAppRouter", label: "IAppRouter" }, - { - type: "doc", - id: "api/appkit/TypeAlias.JobHandle", - label: "JobHandle" - }, { type: "doc", id: "api/appkit/TypeAlias.JobsExport", diff --git a/docs/docs/plugins/jobs.md b/docs/docs/plugins/jobs.md index 5f6cc33cd..d736dcfb7 100644 --- a/docs/docs/plugins/jobs.md +++ b/docs/docs/plugins/jobs.md @@ -12,7 +12,6 @@ Trigger and monitor [Databricks Lakeflow Jobs](https://docs.databricks.com/en/jo - Run-and-wait with SSE streaming status updates - Parameter validation with Zod schemas - Task-type-aware parameter mapping (notebook, python_wheel, sql, etc.) -- Optional on-behalf-of (OBO) user execution via `.asUser(req)` ## Basic usage @@ -123,18 +122,7 @@ When `taskType` is omitted, parameters are passed through to the SDK as-is. ## Execution context -HTTP routes run as the **app's service principal** by default. Jobs are typically shared infrastructure, and the app's resource binding (`databricks.yml`) grants `CAN_MANAGE_RUN` to the SP — so users trigger runs without needing individual grants. - -Per-run attribution in the Jobs UI will show the app's SP, not the human user. If you need user-level attribution (or want the Databricks permission check to use the user's grants), opt in to OBO explicitly in a custom handler via `.asUser(req)`: - -```ts -// Default: runs as the app's service principal -const result = await AppKit.jobs("etl").runNow({ startDate: "2025-01-01" }); - -// Opt-in: runs as the logged-in user (requires `jobs.jobs` in -// `databricks.yml` user_api_scopes AND the user's own CAN_MANAGE_RUN grant) -const result = await AppKit.jobs("etl").asUser(req).runNow({ startDate: "2025-01-01" }); -``` +Jobs always run as the **app's service principal**. The app's resource binding (`databricks.yml`) grants `CAN_MANAGE_RUN` to the SP, so users trigger runs without needing individual grants. Per-run attribution in the Jobs UI shows the app's SP, not the human user. ## HTTP endpoints diff --git a/packages/appkit/src/index.ts b/packages/appkit/src/index.ts index b798256f9..eac0b27b9 100644 --- a/packages/appkit/src/index.ts +++ b/packages/appkit/src/index.ts @@ -74,7 +74,6 @@ export type { IJobsConfig, JobAPI, JobConfig, - JobHandle, JobsExport, } from "./plugins/jobs"; export type { diff --git a/packages/appkit/src/plugins/jobs/index.ts b/packages/appkit/src/plugins/jobs/index.ts index 9567342ca..b391baac7 100644 --- a/packages/appkit/src/plugins/jobs/index.ts +++ b/packages/appkit/src/plugins/jobs/index.ts @@ -1,8 +1,2 @@ export { jobs } from "./plugin"; -export type { - IJobsConfig, - JobAPI, - JobConfig, - JobHandle, - JobsExport, -} from "./types"; +export type { IJobsConfig, JobAPI, JobConfig, JobsExport } from "./types"; diff --git a/packages/appkit/src/plugins/jobs/plugin.ts b/packages/appkit/src/plugins/jobs/plugin.ts index 14ca754a9..24585dd5a 100644 --- a/packages/appkit/src/plugins/jobs/plugin.ts +++ b/packages/appkit/src/plugins/jobs/plugin.ts @@ -1,7 +1,6 @@ import { STATUS_CODES } from "node:http"; import type express from "express"; import type { - IAppRequest, IAppRouter, PluginExecutionSettings, StreamExecutionSettings, @@ -27,7 +26,6 @@ import type { IJobsConfig, JobAPI, JobConfig, - JobHandle, JobRunStatus, JobsExport, } from "./types"; @@ -256,10 +254,7 @@ class JobsPlugin extends Plugin { const jobConfig = this.jobConfigs[jobKey]; // Capture `this` for use in the async generator const self = this; - // Eagerly capture the client and userId so that when createJobAPI is - // called inside an asUser() proxy (which runs in user context), the - // closures below use the user-scoped client instead of falling back - // to the service principal when the ALS context has already exited. + // Capture client and userId eagerly: the closures below run later, after the ALS context may have exited. const client = this.client; const userKey = getCurrentUserId(); @@ -728,22 +723,14 @@ class JobsPlugin extends Plugin { } exports(): JobsExport { - const resolveJob = (jobKey: string): JobHandle => { + const resolveJob = (jobKey: string): JobAPI => { if (!this.jobKeys.includes(jobKey)) { throw new Error( `Unknown job "${jobKey}". Available jobs: ${this.jobKeys.join(", ")}`, ); } - const spApi = this.createJobAPI(jobKey); - - return { - ...spApi, - asUser: (req: IAppRequest) => { - const userPlugin = this.asUser(req) as JobsPlugin; - return userPlugin.createJobAPI(jobKey); - }, - }; + return this.createJobAPI(jobKey); }; return resolveJob as JobsExport; diff --git a/packages/appkit/src/plugins/jobs/tests/plugin.test.ts b/packages/appkit/src/plugins/jobs/tests/plugin.test.ts index 9c27960f2..532cb7024 100644 --- a/packages/appkit/src/plugins/jobs/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/jobs/tests/plugin.test.ts @@ -2,7 +2,6 @@ import { mockServiceContext, setupDatabricksEnv } from "@tools/test-helpers"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { ServiceContext } from "../../../context/service-context"; -import { AuthenticationError } from "../../../errors"; import { ResourceType } from "../../../registry"; import { JOBS_READ_DEFAULTS, @@ -248,14 +247,13 @@ describe("JobsPlugin", () => { expect(typeof exported).toBe("function"); }); - test("returns job handle with asUser and direct JobAPI methods", () => { + test("returns job handle with direct JobAPI methods", () => { process.env.DATABRICKS_JOB_ETL = "123"; const plugin = new JobsPlugin({}); const exported = plugin.exports(); const handle = exported("etl"); - expect(typeof handle.asUser).toBe("function"); expect(typeof handle.runNow).toBe("function"); expect(typeof handle.runAndWait).toBe("function"); expect(typeof handle.lastRun).toBe("function"); @@ -741,76 +739,6 @@ describe("JobsPlugin", () => { }); }); - describe("OBO and service principal access", () => { - test("job handle exposes asUser and all JobAPI methods", () => { - process.env.DATABRICKS_JOB_ETL = "123"; - - const plugin = new JobsPlugin({}); - const handle = plugin.exports()("etl"); - - expect(typeof handle.asUser).toBe("function"); - - const jobMethods = [ - "runNow", - "runAndWait", - "lastRun", - "listRuns", - "getRun", - "getRunOutput", - "cancelRun", - "getJob", - ]; - for (const method of jobMethods) { - expect(typeof (handle as any)[method]).toBe("function"); - } - }); - - test("asUser throws AuthenticationError without token in production", () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "production"; - process.env.DATABRICKS_JOB_ETL = "123"; - - try { - const plugin = new JobsPlugin({}); - const handle = plugin.exports()("etl"); - const mockReq = { header: () => undefined } as any; - - expect(() => handle.asUser(mockReq)).toThrow(AuthenticationError); - } finally { - process.env.NODE_ENV = originalEnv; - } - }); - - test("asUser in dev mode returns JobAPI with all methods", () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "development"; - process.env.DATABRICKS_JOB_ETL = "123"; - - try { - const plugin = new JobsPlugin({}); - const handle = plugin.exports()("etl"); - const mockReq = { header: () => undefined } as any; - const api = handle.asUser(mockReq); - - const jobMethods = [ - "runNow", - "runAndWait", - "lastRun", - "listRuns", - "getRun", - "getRunOutput", - "cancelRun", - "getJob", - ]; - for (const method of jobMethods) { - expect(typeof (api as any)[method]).toBe("function"); - } - } finally { - process.env.NODE_ENV = originalEnv; - } - }); - }); - describe("clientConfig", () => { test("returns configured job keys with params schema", () => { process.env.DATABRICKS_JOB_ETL = "123"; @@ -1990,57 +1918,4 @@ describe("injectRoutes", () => { expect(mockRes.end).not.toHaveBeenCalled(); }); }); - - describe("execution context", () => { - test("HTTP routes run as service principal by default (no asUser)", async () => { - process.env.DATABRICKS_JOB_ETL = "123"; - - mockClient.jobs.listRuns.mockReturnValue((async function* () {})()); - - const plugin = new JobsPlugin({}); - const asUserSpy = vi.spyOn(plugin, "asUser"); - const routeSpy = vi.spyOn(plugin as any, "route"); - - const mockRouter = { get: vi.fn(), post: vi.fn(), delete: vi.fn() }; - plugin.injectRoutes(mockRouter as any); - - const runsRoute = routeSpy.mock.calls.find( - (call) => (call[1] as any).name === "runs", - ); - const handler = (runsRoute?.[1] as any).handler; - - const mockReq = { - params: { jobKey: "etl" }, - query: {}, - header: vi.fn().mockReturnValue("test-token"), - } as any; - - const mockRes = { - status: vi.fn().mockReturnThis(), - json: vi.fn(), - } as any; - - await handler(mockReq, mockRes); - - // Route handlers should not implicitly call asUser — callers opt into - // OBO via the programmatic `exports().asUser(req)` surface. - expect(asUserSpy).not.toHaveBeenCalled(); - }); - - test("programmatic exports().asUser(req) still delegates through asUser", () => { - process.env.DATABRICKS_JOB_ETL = "123"; - - const plugin = new JobsPlugin({}); - const asUserSpy = vi.spyOn(plugin, "asUser"); - const handle = plugin.exports()("etl"); - - const mockReq = { - header: vi.fn().mockReturnValue("test-token"), - } as any; - - handle.asUser(mockReq); - - expect(asUserSpy).toHaveBeenCalledWith(mockReq); - }); - }); }); diff --git a/packages/appkit/src/plugins/jobs/types.ts b/packages/appkit/src/plugins/jobs/types.ts index d10d5684b..0ad101df6 100644 --- a/packages/appkit/src/plugins/jobs/types.ts +++ b/packages/appkit/src/plugins/jobs/types.ts @@ -1,4 +1,4 @@ -import type { BasePluginConfig, IAppRequest } from "shared"; +import type { BasePluginConfig } from "shared"; import type { z } from "zod"; import type { ExecutionResult } from "../../plugin"; import type { jobs } from "../../workspace-client"; @@ -66,14 +66,6 @@ export interface IJobsConfig extends BasePluginConfig { jobs?: Record; } -/** - * Job handle returned by `appkit.jobs("etl")`. - * Supports OBO access via `.asUser(req)`. - */ -export type JobHandle = JobAPI & { - asUser: (req: IAppRequest) => JobAPI; -}; - /** * Public API shape of the jobs plugin. * Callable to select a job by key. @@ -87,9 +79,6 @@ export type JobHandle = JobAPI & { * for await (const status of appkit.jobs("etl").runAndWait()) { * console.log(status.status, status.run); * } - * - * // OBO access - * await appkit.jobs("etl").asUser(req).runNow(); * ``` */ -export type JobsExport = (jobKey: string) => JobHandle; +export type JobsExport = (jobKey: string) => JobAPI;