Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/dev-playground/.gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Playwright
test-results/
playwright-report/
.e2e/

# Auto-generated types (regenerated on `pnpm dev` by appKitTypesPlugin)
shared/appkit-types/serving.d.ts
28 changes: 28 additions & 0 deletions apps/dev-playground/e2e.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { web } from "@e2edev/web";
import type { E2EConfig } from "e2e";

import { appEnv, userHeaders } from "./e2e/identity/env";

export default {
tests: "e2e/**/*.e2e.ts",
targets: [
{
engine: web({
url: "http://127.0.0.1:0",
readyUrl: "http://127.0.0.1:{port}/health",
// Every browser request carries the signed-in user, as the Apps proxy would.
headers: userHeaders,
command: {
executable: "node",
args: ["--import", "tsx", "server.ts"],
cwd: "e2e/identity/app",
env: appEnv,
startupTimeout: 120_000,
log: ".e2e/logs/identity-app.log",
},
}),
},
],
// Identity checks share one SP and one user; keep the order and the log readable.
workers: 1,
} satisfies E2EConfig;
74 changes: 74 additions & 0 deletions apps/dev-playground/e2e/identity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Execution identity e2e suite

End-to-end checks that AppKit runs each call as the right principal: the app
service principal (SP) by default, the signed-in user on the on-behalf-of (OBO)
path. Built on [`e2e`](https://www.npmjs.com/package/e2e) with the
`@e2edev/web` engine.

Requires the execution-identity stack (#592, #594, #595, #597, #601). On `main`
without it, the `appkit.asUser(req)` routes in `app/server.ts` do not exist.

## How identity is injected

The runner starts `app/server.ts` as a production server (`NODE_ENV=production`;
development mode would turn a missing user token into a silent SP run). Requests
carry the headers the Databricks Apps proxy forwards for a signed-in user:
`x-forwarded-access-token`, `x-forwarded-user`, `x-forwarded-email`. API checks
send them with `fetch`; browser steps get them from `web({ headers })` in
`e2e.config.ts`. The server authenticates as a real SP through
`DATABRICKS_CLIENT_ID` / `DATABRICKS_CLIENT_SECRET`, so the SP and the user are
different principals and every check can fail.

All assertions are deterministic. B5 sends a chat turn to the app's own agent;
its serving-endpoint model picks the tools, and the test reads the tool outputs
from the SSE stream, never the model's text. No e2e model provider is needed.

## Run

```sh
cd apps/dev-playground
export DATABRICKS_HOST=https://<workspace>
export E2E_SP_CLIENT_ID=<sp application id> E2E_SP_CLIENT_SECRET=<sp oauth secret>
export E2E_USER_EMAIL=<you@example.com>
export E2E_USER_TOKEN=$(databricks auth token --profile <profile> | jq -r .access_token)
export DATABRICKS_WAREHOUSE_ID=<id> DATABRICKS_SERVING_ENDPOINT_NAME=<endpoint>
export LAKEBASE_ENDPOINT=<projects/.../endpoints/...> PGHOST=<host> PGDATABASE=<db>
pnpm test:e2e # all tests
pnpm test:e2e analytics # one file
```

**Local mode (no SP secret).** Leave `E2E_SP_CLIENT_ID` / `E2E_SP_CLIENT_SECRET`
unset and the server runs on `E2E_USER_TOKEN`. SP and user are then the same
principal, so the 7 tests that compare them skip with
`needs a distinct SP`; the principal-kind, fail-closed, app-only, and
deprecation checks still run.

The SP needs `CAN USE` on the warehouse, `CAN QUERY` on the endpoint, and a
Lakebase role. The app log is `.e2e/logs/identity-app.log`.

## Checklist mapping

| Checklist row | File | Test |
| --- | --- | --- |
| B1 `.sql` runs as SP | `analytics.e2e.ts` | B1: a .sql query runs as the app service principal |
| B2 `.obo.sql` runs as user | `analytics.e2e.ts` | B2: a .obo.sql query runs as the signed-in user |
| B3 `asUser(req).run(...)` | `as-user.e2e.ts` | B3: asUser(req).run(...) runs as the user |
| B3 one-call form | `as-user.e2e.ts` | B3: the one-call asUser(req).plugin.method() form runs as the user |
| B4 plain call runs as SP | `as-user.e2e.ts` | B4: a plain plugin call runs as the SP even with user headers |
| B5 toolkit tool as user | `agents.e2e.ts` | B5: a plugin-toolkit tool runs as the user |
| B5 hand-rolled tool and model as SP | `agents.e2e.ts` | B5: a hand-rolled tool and the model call run as the SP |
| B7 Lakebase always SP | `lakebase.e2e.ts` | B7: a Lakebase query connects as the SP even with user headers |
| B7 Lakebase is app-only | `lakebase.e2e.ts` | B7: asUser(req).lakebase is refused, never run as the user |
| Guardrail: fail-closed (asUser) | `as-user.e2e.ts` | fail-closed: asUser with no user token rejects instead of running as the SP |
| Guardrail: fail-closed (agent tool) | `agents.e2e.ts` | fail-closed: a plugin tool call with no user token never runs as the SP |
| Guardrail: no `Plugin.asUser` deprecation from core plugins | `deprecation.e2e.ts` | core plugins on the OBO path log no Plugin.asUser deprecation warning |

B6 (unbound-warehouse OBO) and Part A (provisioning) need a deployed app or the
CLI and are not covered here.

## How this differs from `server/testing-kit.integration.test.ts`

That suite runs in-process against a mocked workspace client: it proves AppKit
routes a call to the user or SP context. This suite sends real HTTP through the
proxy-header path to a real workspace, so it proves which principal the
warehouse, Lakebase, and the agent's tools actually see.
83 changes: 83 additions & 0 deletions apps/dev-playground/e2e/identity/agents.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect, test } from "e2e";

import {
DISTINCT_SP,
NEEDS_DISTINCT_SP,
postSse,
SERVER_IDENTITY,
type SseEvent,
SP_CLIENT_ID,
USER_EMAIL,
userHeaders,
} from "./env";

// The app's own model decides to call the tools; the assertions only read tool outputs.
const PROMPT = "Who am I? Prove identity with both tools.";

/** Tool name -> parsed output, joined from function_call and function_call_output items. */
function toolOutputs(events: SseEvent[]): Record<string, any> {
const items = events
.filter((e) => e.data?.type === "response.output_item.done")
.map((e) => e.data.item);
const names = new Map(
items
.filter((i) => i?.type === "function_call")
.map((i) => [i.call_id, i.name]),
);
const outputs: Record<string, any> = {};
for (const item of items.filter((i) => i?.type === "function_call_output")) {
try {
outputs[names.get(item.call_id)] = JSON.parse(item.output);
} catch {
outputs[names.get(item.call_id)] = item.output;
}
}
return outputs;
}

async function chat(
baseUrl: string | undefined,
headers?: Record<string, string>,
) {
const res = await postSse(
new URL("/api/agents/chat", baseUrl),
{ message: PROMPT },
headers,
);
return { ...res, outputs: toolOutputs(res.events) };
}

test.describe("agents execution identity", () => {
test("B5: a plugin-toolkit tool runs as the user", async ({ app }) => {
test.skip(!DISTINCT_SP, NEEDS_DISTINCT_SP);
const { status, outputs } = await chat(app.baseUrl, userHeaders);
expect(status).toBe(200);
expect(JSON.stringify(outputs["analytics.query"])).toContain(USER_EMAIL);
expect(JSON.stringify(outputs["analytics.query"])).not.toContain(
SP_CLIENT_ID,
);
});

test("B5: a hand-rolled tool and the model call run as the SP", async ({
app,
}) => {
const { status, outputs, text } = await chat(app.baseUrl, userHeaders);
expect(status).toBe(200);
expect(outputs.whoami_sp).toMatchObject({ principal: "app" });
// The model call ran (it chose the tools) with no model-serving user scope: SP.
expect(text).not.toMatch(/^event: error$/m);
});

test("fail-closed: a plugin tool call with no user token never runs as the SP", async ({
app,
}) => {
// A signed-in user whose token did not arrive: only the token is missing.
const { "x-forwarded-access-token": _, ...noToken } = userHeaders;
const { status, outputs } = await chat(app.baseUrl, noToken);
expect(status).toBe(200);
// The tool was called and answered with the token error, not with the server's rows.
const query = JSON.stringify(outputs["analytics.query"]);
expect(query).toMatch(/token/i);
expect(query).not.toContain(SERVER_IDENTITY);
});
});
37 changes: 37 additions & 0 deletions apps/dev-playground/e2e/identity/analytics.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from "e2e";

import {
DISTINCT_SP,
identityFromQuery,
NEEDS_DISTINCT_SP,
postSse,
SP_CLIENT_ID,
USER_EMAIL,
userHeaders,
} from "./env";

test.describe("analytics query files", () => {
test("B1: a .sql query runs as the app service principal", async ({
app,
}) => {
test.skip(!DISTINCT_SP, NEEDS_DISTINCT_SP);
const { status, events } = await postSse(
new URL("/api/analytics/query/whoami", app.baseUrl),
{ parameters: {} },
userHeaders,
);
expect(status).toBe(200);
expect(identityFromQuery(events)).toBe(SP_CLIENT_ID);
});

test("B2: a .obo.sql query runs as the signed-in user", async ({ app }) => {
test.skip(!DISTINCT_SP, NEEDS_DISTINCT_SP);
const { status, events } = await postSse(
new URL("/api/analytics/query/whoami_obo", app.baseUrl),
{ parameters: {} },
userHeaders,
);
expect(status).toBe(200);
expect(identityFromQuery(events)).toBe(USER_EMAIL);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SELECT current_user() AS identity
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SELECT current_user() AS identity
58 changes: 58 additions & 0 deletions apps/dev-playground/e2e/identity/app/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { analytics, createApp, lakebase, server } from "@databricks/appkit";
import { agents } from "@databricks/appkit/beta";

const WHOAMI_SQL = "SELECT current_user() AS identity";

// The e2e runner appends to one log across runs; tests read from the last marker.
console.log("e2e-identity-app: boot");

/** Identity probes for the execution-identity e2e suite. Every route answers `{ identity }`. */
createApp({
plugins: [agents(), analytics(), lakebase(), server()],
async onPluginsReady(appkit) {
appkit.server.extend((app) => {
type Req = Parameters<typeof appkit.asUser>[0];
const probe = (path: string, fn: (req: Req) => Promise<unknown>) =>
app.get(path, async (req, res) => {
try {
res.json({ identity: await fn(req) });
} catch (error) {
const err = error as Error & { code?: string };
res.status(500).json({ code: err.code, error: err.message });
}
});
const identity = (rows: unknown) =>
(rows as { identity: string }[])[0]?.identity;

probe("/e2e/default", async () =>
identity(await appkit.analytics.query(WHOAMI_SQL)),
);
probe("/e2e/as-user-block", (req) =>
appkit
.asUser(req)
.run(async (kit) => identity(await kit.analytics.query(WHOAMI_SQL))),
);
probe("/e2e/as-user-oneshot", async (req) =>
identity(await appkit.asUser(req).analytics.query(WHOAMI_SQL)),
);
probe(
"/e2e/lakebase",
async () =>
(await appkit.lakebase.query("SELECT current_user AS identity"))
.rows[0]?.identity,
);
probe(
"/e2e/lakebase-as-user",
async (req) =>
(
await appkit
.asUser(req)
.lakebase.query("SELECT current_user AS identity")
).rows[0]?.identity,
);
});
},
}).catch((error) => {
console.error(error);
process.exit(1);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { getCurrentPrincipalKey } from "@databricks/appkit";
import { createAgent, tool } from "@databricks/appkit/beta";
import { z } from "zod";

/**
* B5 runtime-identity probe agent (default chat agent, id = folder "identity").
*
* Two tools make the execution identity observable in one chat turn:
*
* - `analytics.query` is a PLUGIN-TOOLKIT tool. The agents plugin dispatches
* it through `executeTool`, which opens the request's user scope, so the
* SQL runs on behalf of the signed-in USER (OBO). `current_user()` returns
* the user's email.
*
* - `whoami_sp` is a HAND-ROLLED tool({ execute }). `execute` receives only
* its arguments and runs in the ambient app context, never a user scope.
* `getCurrentPrincipalKey()` therefore returns "app" (the service
* principal), the direct complement of the OBO tool's "user:<id>". We also
* surface the SP client id from the platform-injected env so the SP has a
* concrete identifier alongside the principal kind.
*
* Why the accessor and not a SQL round-trip: inside an agent `tools(plugins)`
* builder, `plugins.<name>` is a toolkit provider (it only exposes
* `toolkit()`), not the service-principal exports, so a hand-rolled execute
* cannot call `plugins.analytics.query`. `getCurrentPrincipalKey()` is the
* simplest correct way for a hand-rolled execute to prove its principal.
*
* The agent model (serving endpoint) call also runs as the service principal:
* the endpoint is bound to the app SP and the app declares no `model-serving`
* user scope, so a working chat proves the model call does not use the user
* token.
*/
const WHOAMI_SQL = "SELECT current_user() AS identity";

export default createAgent({
default: true,
instructions: [
"You are a runtime identity probe.",
"When the user asks who they are, who is running, or to prove identity,",
"you MUST call BOTH tools, each exactly once, then report both results.",
`1. call \`analytics.query\` with the query "${WHOAMI_SQL}" to get the`,
"on-behalf-of USER identity (read the `identity` column from the result).",
"2. call `whoami_sp` to get the app SERVICE PRINCIPAL identity.",
"Reply with exactly these two lines:",
"USER (OBO): <identity column from analytics.query>",
"SERVICE PRINCIPAL: <principal from whoami_sp> <servicePrincipalClientId from whoami_sp>",
].join(" "),
tools: (plugins) => ({
// Plugin-toolkit tool: dispatched via executeTool, runs OBO (user).
...plugins.analytics.toolkit({ only: ["query"] }),
// Hand-rolled tool: runs in the ambient app context (service principal).
whoami_sp: tool({
description:
"Return the running principal for a hand-rolled tool: the principal kind and the app service principal client id.",
schema: z.object({}),
annotations: { effect: "read" },
execute: async () => ({
// "app" when running as the service principal; "user:<id>" would mean
// a user scope leaked into a hand-rolled tool (it must not).
principal: getCurrentPrincipalKey(),
servicePrincipalClientId: process.env.DATABRICKS_CLIENT_ID ?? null,
}),
}),
}),
});
Loading
Loading