Skip to content
Merged
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
28 changes: 0 additions & 28 deletions docs/docs/api/appkit/TypeAlias.JobHandle.md

This file was deleted.

7 changes: 2 additions & 5 deletions docs/docs/api/appkit/TypeAlias.JobsExport.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion docs/docs/api/appkit/index.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 0 additions & 5 deletions docs/docs/api/appkit/typedoc-sidebar.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 1 addition & 13 deletions docs/docs/plugins/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion packages/appkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export type {
IJobsConfig,
JobAPI,
JobConfig,
JobHandle,
JobsExport,
} from "./plugins/jobs";
export type {
Expand Down
8 changes: 1 addition & 7 deletions packages/appkit/src/plugins/jobs/index.ts
Original file line number Diff line number Diff line change
@@ -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";
19 changes: 3 additions & 16 deletions packages/appkit/src/plugins/jobs/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { STATUS_CODES } from "node:http";
import type express from "express";
import type {
IAppRequest,
IAppRouter,
PluginExecutionSettings,
StreamExecutionSettings,
Expand All @@ -27,7 +26,6 @@ import type {
IJobsConfig,
JobAPI,
JobConfig,
JobHandle,
JobRunStatus,
JobsExport,
} from "./types";
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;
Expand Down
127 changes: 1 addition & 126 deletions packages/appkit/src/plugins/jobs/tests/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
});
});
15 changes: 2 additions & 13 deletions packages/appkit/src/plugins/jobs/types.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -66,14 +66,6 @@ export interface IJobsConfig extends BasePluginConfig {
jobs?: Record<string, JobConfig>;
}

/**
* 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.
Expand All @@ -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;
Loading