Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
366a772
fix(appkit): guard app-only resources in caller scopes
MarioCadenas Sep 23, 2026
e2c05cc
fix(appkit): avoid internal deprecated identity access
MarioCadenas Sep 25, 2026
a41c7b2
fix(appkit): label resource guards from manifest metadata
MarioCadenas Sep 29, 2026
a95a98e
fix(appkit): route internal on-behalf-of scoping off deprecated asUser
MarioCadenas Sep 30, 2026
5a1a592
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 1, 2026
663a1ab
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 1, 2026
c1ad31c
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 1, 2026
408d198
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 1, 2026
b3af856
refactor(appkit): use Set.has for the app-only resource check
MarioCadenas Oct 1, 2026
f8f05a0
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 1, 2026
4fb8394
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 1, 2026
e756011
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 1, 2026
7b451e3
fix(appkit): route genie and serving OBO scoping off deprecated asUser
MarioCadenas Oct 2, 2026
ed412cc
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 5, 2026
12ae314
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 5, 2026
a51431a
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 5, 2026
035f57f
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 5, 2026
413fe20
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 5, 2026
82688b0
chore: merge execution-identity-lifecycle into execution-resource-guards
MarioCadenas Oct 6, 2026
626d179
Merge branch 'execution-identity-lifecycle' into execution-resource-g…
MarioCadenas Oct 6, 2026
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
25 changes: 25 additions & 0 deletions docs/docs/plugins/execution-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,31 @@ is already open, fallback retains it instead of widening to SP. The marker does
not leak outside the scope. Production never falls back when credentials are
missing.

## App-only resources and missing credentials

The manifest capability contract keeps `secret`, `database`, and `postgres`
app-only for the new `appkit.asUser` and `runInCallerContext` APIs. Accessing
those resources through these caller-scoped plugin APIs or tools
raises a clear error identifying the resource by its manifest alias, or its type
when no alias is available. The message states that the resource is app-only in
this version of AppKit and does not support OBO execution through these APIs.
The check applies when using cached handles inside a later user scope too.
It does not reject unrelated plugins merely because an app-only plugin is
installed. Required resources, runtime requirements, and configured optional
resources determine the plugin's resource capability.

Deprecated `plugin.asUser` and `runInUserContext`, direct request-based tool
dispatch, and the existing agents HTTP routes retain their established resource
behavior, including Lakebase per-user routing. They still establish user identity
and reject missing production credentials; they never fall back to SP by omission.
Using a deprecated entry point inside a new guarded scope cannot disable its guards.

Missing-token messages distinguish OBO-capable resources from generic operations.
For an OBO-capable resource, the message explains that no user token was forwarded
and the app may be deployed service-principal-only. Otherwise the generic missing
user token error remains. The app-level check uses the registered plugins' active
resource metadata because no individual plugin has been selected yet.

## Credential expiration and telemetry

A structured downstream HTTP 401 inside a caller scope throws
Expand Down
96 changes: 18 additions & 78 deletions docs/docs/plugins/lakebase.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,88 +113,28 @@ await createApp({
});
```

## On-Behalf-Of (OBO) — per-user connections
## On-Behalf-Of (OBO) {#on-behalf-of-obo--per-user-connections}

When your app needs Row-Level Security (RLS) or per-user data isolation, use `asUser(req)` to execute queries using a per-user Lakebase connection pool. Each user's pool is authenticated with their Databricks identity, so PostgreSQL's `current_user` reflects the actual user.
Lakebase is app-only through the new `appkit.asUser` and `runInCallerContext`
APIs. Operations in those scopes fail with a clear error. For a connector call
without a manifest alias:

### Prerequisites

1. **Enable user authorization** in your Databricks App with the **`postgres`** scope. See [User authorization](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/auth#user-authorization) for setup instructions. In your `databricks.yml`:
```yaml
resources:
apps:
app:
user_api_scopes:
- postgres
```
Apps scaffolded with `databricks apps init` and the Lakebase plugin include this automatically.

2. Each app user needs a **Postgres role** in Lakebase. Create one with the Databricks CLI:

```bash
databricks postgres create-role "projects/{project_id}/branches/{branch_id}" \
--json '{"spec": {"identity_type": "USER", "postgres_role": "user@example.com"}}'
```

Alternatively, create roles in the Lakebase UI under **Branch Overview** → **Add role**.

:::note
Do not grant `databricks_superuser` to OBO users — superusers bypass RLS. Use [fine-grained grants](#fine-grained-permissions) instead.
:::

### Usage

No configuration needed — just call `asUser(req)`:

```ts
const AppKit = await createApp({
plugins: [server(), lakebase()],
});

// Service principal query (default — bypasses RLS as table owner)
const all = await AppKit.lakebase.query("SELECT * FROM app.orders");

// User-scoped query (per-user pool, RLS enforced)
app.get("/api/my-orders", async (req, res) => {
const result = await AppKit.lakebase
.asUser(req)
.query("SELECT * FROM app.orders ORDER BY created_at DESC");
res.json(result.rows);
});
```text
Resource "postgres" is app-only in this version of AppKit and does not support OBO (on-behalf-of-user) execution. It runs as the service principal. Resource type: postgres.
```

When `asUser(req)` is called:
1. The user's token and identity are extracted from `x-forwarded-access-token` and `x-forwarded-email` headers (set automatically by Databricks Apps).
2. A per-user `pg.Pool` is created (or reused) with the user's OAuth credentials.
3. `query()` and `pool` use the user's pool — `current_user` in PostgreSQL reflects the user's identity.

### Row-Level Security example

```sql
-- As the service principal (during app setup):
ALTER TABLE app.orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY user_orders ON app.orders
FOR ALL TO PUBLIC
USING (owner = current_user);

-- Grant access so OBO users can query
GRANT USAGE ON SCHEMA app TO PUBLIC;
GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA app TO PUBLIC;
```

### How it works

- The **service principal pool** (`AppKit.lakebase.pool`) is always created and used for DDL operations, seeding, and admin queries.
- **Per-user pools** are created on the first `asUser(req)` call and cached by user identity. Each pool has its own OAuth token refresh cycle.
- Idle connections within per-user pools close automatically (30s idle timeout). Empty pool objects are cleaned up periodically.
- On shutdown, all pools (SP + user) are closed gracefully.
- In development mode (`NODE_ENV=development`), if no user token is available, `asUser(req)` falls back to the SP pool with a warning.

:::caution[RLS and superusers]
PostgreSQL superusers bypass Row-Level Security entirely. Users with the `databricks_superuser` role will see all rows regardless of RLS policies. For RLS enforcement, use [fine-grained grants](#fine-grained-permissions) instead of the superuser role.
:::

Use a plain Lakebase call outside a caller scope for SP execution. There is no
`asApp()` escape from a caller scope. The guard also applies to agent tools, ORM
configuration, and pool handles obtained before entering a new caller scope.

The platform has a `postgres` user API scope, but AppKit does not enable that
capability in the new v1 execution API. For backward compatibility, deprecated
`plugin.asUser(req)` and `runInUserContext` retain the existing per-user pool
routing, as do direct request-based tool dispatch and existing agents routes.
These paths keep the user's identity and do not silently use the SP. They cannot
disable an enclosing new caller scope's guard. Existing applications can upgrade
without migrating their Lakebase OBO calls, then adopt the new execution API
explicitly. Database permissions and row-level policies still govern data access.
## Database Permissions

When you create the app with the Lakebase resource using the [Getting started](#getting-started-with-the-lakebase) guide, the Service Principal is automatically granted `CONNECT_AND_CREATE` permission on the `postgres` resource. This lets the Service Principal connect to the database and create new objects, but **not access any existing schemas or tables.**
Expand Down
15 changes: 5 additions & 10 deletions packages/appkit/src/connectors/lakebase/routing-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Pool, PoolClient, QueryResult, QueryResultRow } from "pg";

import type { CallerContext } from "../../context/caller-context";
import { getCallerContext } from "../../context/execution-context";
import { assertResourceExecution } from "../../context/resource-capabilities";

/**
* Subset of `pg.Pool` exposed by the Lakebase plugin.
Expand All @@ -23,16 +24,8 @@ export interface LakebasePool {
}

/**
* A `pg.Pool`-like wrapper that routes queries to the appropriate pool
* based on the current execution context.
*
* When called inside `runInCallerContext()` (set up by `Plugin.asUser(req)`),
* queries route to the per-user pool returned by `resolveUserPool`.
* Otherwise, queries route to the service-principal pool.
*
* This enables OBO (On-Behalf-Of) without custom `asUser()` overrides —
* the base class sets up AsyncLocalStorage context, and the RoutingPool
* reads it transparently.
* A `pg.Pool`-like compatibility wrapper. New caller scopes enforce the v1
* app-only contract. Deprecated entry points retain existing per-user routing.
*/
export class RoutingPool implements LakebasePool {
constructor(
Expand All @@ -41,6 +34,8 @@ export class RoutingPool implements LakebasePool {
) {}

private activePool(): Pool {
// Strict scopes reject before resolving a pool; legacy OBO keeps its user pool.
assertResourceExecution("postgres");
const userCtx = getCallerContext();
return userCtx ? this.resolveUserPool(userCtx) : this.spPool;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Pool } from "pg";
import { describe, expect, test, vi } from "vitest";

import { runInCallerContext } from "../../../context/execution-context";
import { RoutingPool } from "../routing-pool";

function makeMockPool(label: string) {
Expand All @@ -18,6 +19,28 @@ function makeMockPool(label: string) {
}

describe("RoutingPool", () => {
test("new caller scopes reject queries and connects without touching either pool", () => {
const spPool = makeMockPool("sp");
const userPool = makeMockPool("user");
const resolveUserPool = vi.fn(() => userPool);
const pool = new RoutingPool(spPool, resolveUserPool);
const caller = {
client: {} as any,
principal: { type: "user" as const, userId: "alice" },
workspaceId: Promise.resolve("workspace"),
};
for (const operation of [
() => pool.query("SELECT 1"),
() => pool.connect(),
]) {
expect(() =>
runInCallerContext<Promise<unknown>>(caller, operation),
).toThrow('Resource "postgres" is app-only in this version of AppKit');
}
expect(resolveUserPool).not.toHaveBeenCalled();
expect(spPool.query).not.toHaveBeenCalled();
expect(spPool.connect).not.toHaveBeenCalled();
});
test("routes to SP pool when no user context is active", async () => {
const spPool = makeMockPool("sp");
const userPool = makeMockPool("user");
Expand Down
49 changes: 40 additions & 9 deletions packages/appkit/src/context/execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,28 @@ import {
type UserContext,
} from "./user-context";

const executionContextStorage = new AsyncLocalStorage<CallerContext>();
interface CallerScope {
readonly caller: CallerContext;
readonly legacyExecution: boolean;
}

const executionContextStorage = new AsyncLocalStorage<CallerScope>();

function runInCallerScope<T>(
callerContext: CallerContext,
fn: () => T,
legacyResources?: WarehouseBinding,
legacyExecution = false,
): T {
const caller = snapshotCallerContext(callerContext);
const scope = Object.freeze({
caller: snapshotCallerContext(callerContext),
// Deprecated entry points cannot disable guards in an existing new scope.
legacyExecution:
legacyExecution &&
(executionContextStorage.getStore()?.legacyExecution ?? true),
});
return runWithResourceBindings(legacyResources, () =>
executionContextStorage.run(caller, () => {
executionContextStorage.run(scope, () => {
try {
const result = fn();
if (result instanceof Promise) {
Expand Down Expand Up @@ -71,6 +83,19 @@ export function runInCallerContext<T>(
return runInCallerScope(callerContext, fn);
}

/** @internal Preserve legacy resource behavior without weakening an enclosing scope. */
export function runInLegacyCallerContext<T>(
caller: CallerContext,
fn: () => T,
): T {
return runInCallerScope(caller, fn, undefined, true);
}

/** @internal Legacy user entry points retain their established resource routing. */
export function isLegacyCallerScope(): boolean {
return executionContextStorage.getStore()?.legacyExecution === true;
}

/** @deprecated Use runInCallerContext. */
export function runInUserContext<T>(
userContext: UserContext | (CallerContext & Pick<UserContext, "warehouseId">),
Expand All @@ -82,9 +107,10 @@ export function runInUserContext<T>(
toCallerContext(userContext),
fn,
Object.freeze({ warehouseId: userContext.warehouseId }),
true,
);
}
return runInCallerContext(toCallerContext(userContext), fn);
return runInLegacyCallerContext(toCallerContext(userContext), fn);
}

/**
Expand All @@ -98,7 +124,7 @@ export function runInUserContext<T>(
export function getExecutionContext():
| ServiceContextState
| (CallerContext & UserContext) {
const callerContext = executionContextStorage.getStore();
const callerContext = getCallerContext();
if (callerContext) {
return legacyUserContext(callerContext, captureWarehouseId());
}
Expand All @@ -118,6 +144,11 @@ export function getCurrentActorId(): string | undefined {
return getCallerContext()?.principal.userId;
}

/** @internal Bare ID of the effective caller or service principal. */
export function getCurrentPrincipalId(): string {
return getCurrentActorId() ?? ServiceContext.get().serviceUserId;
}

/**
* @deprecated Use getCurrentPrincipalKey for new cache keys or getCurrentActorId
* for audit. Preserves the bare user or service ID for existing callers.
Expand All @@ -127,7 +158,7 @@ export function getCurrentUserId(): string {
"getCurrentUserId",
"getCurrentPrincipalKey (cache) or getCurrentActorId (audit)",
);
return getCurrentActorId() ?? ServiceContext.get().serviceUserId;
return getCurrentPrincipalId();
}

/**
Expand Down Expand Up @@ -169,12 +200,12 @@ export function isInUserContext(): boolean {
* to be initialized and never throws.
*/
export function getCallerContext(): CallerContext | undefined {
return executionContextStorage.getStore();
return executionContextStorage.getStore()?.caller;
}

/** @deprecated Use getCallerContext and its principal field. */
export function getUserContext(): (CallerContext & UserContext) | undefined {
warnContextDeprecation("getUserContext", "getCallerContext");
const scope = executionContextStorage.getStore();
return scope ? legacyUserContext(scope, captureWarehouseId()) : undefined;
const caller = getCallerContext();
return caller ? legacyUserContext(caller, captureWarehouseId()) : undefined;
}
1 change: 1 addition & 0 deletions packages/appkit/src/context/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
getCallerContext,
getCurrentActorId,
getCurrentPrincipalId,
getCurrentPrincipalKey,
getCurrentUserId,
getExecutionContext,
Expand Down
30 changes: 25 additions & 5 deletions packages/appkit/src/context/request-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import type { Request } from "express";

import { AuthenticationError } from "../errors";
import { createLogger } from "../logging/logger";
import { runInCallerContext } from "./execution-context";
import {
runInCallerContext,
runInLegacyCallerContext,
} from "./execution-context";
import { hasOboResource } from "./resource-capabilities";
import { ServiceContext } from "./service-context";

const logger = createLogger("execution-context");
Expand All @@ -21,7 +25,11 @@ export function isDevOboFallback(): boolean {
/** Build the caller once from the trusted Apps proxy headers. */
export function createRequestScope(
req: Request,
createCaller = ServiceContext.createCallerContext,
resourceTypes: readonly string[] = [],
options: {
createCaller?: typeof ServiceContext.createCallerContext;
legacy?: boolean;
} = {},
): RequestScope {
const token = req.header("x-forwarded-access-token")?.trim();
const userId = req.header("x-forwarded-user")?.trim();
Expand All @@ -40,14 +48,26 @@ export function createRequestScope(
),
};
}
if (!token) throw AuthenticationError.missingToken("user token");
if (!token) {
if (hasOboResource(resourceTypes)) {
const message =
"This resource is OBO-capable but no user token was forwarded. The app is likely deployed service-principal-only. Enable user authorization and forward x-forwarded-access-token.";
throw new AuthenticationError(message, { clientMessage: message });
}
throw AuthenticationError.missingToken("user token");
}
if (!userId && !isDev) throw AuthenticationError.missingUserId();

const caller = createCaller(
const caller = (options.createCaller ?? ServiceContext.createCallerContext)(
token,
userId || "dev-user",
undefined,
userEmail,
);
return { run: (fn) => runInCallerContext(caller, fn) };
return {
run: (fn) =>
options.legacy
? runInLegacyCallerContext(caller, fn)
: runInCallerContext(caller, fn),
};
}
Loading
Loading