Skip to content

Commit 0900ea9

Browse files
authored
refactor(appkit): own asUser OBO wrapping in Plugin proxy (#385)
1 parent d451278 commit 0900ea9

3 files changed

Lines changed: 685 additions & 109 deletions

File tree

packages/appkit/src/core/appkit.ts

Lines changed: 10 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@ import type {
1010
} from "shared";
1111
import { version as productVersion } from "../../package.json";
1212
import { CacheManager } from "../cache";
13-
import { runInUserContext, ServiceContext } from "../context";
14-
import type { UserContext } from "../context/user-context";
13+
import { ServiceContext } from "../context";
1514
import {
1615
isInternalTelemetryEnabled,
1716
TelemetryReporter,
1817
} from "../internal-telemetry";
1918
import { createLogger } from "../logging/logger";
20-
import { USER_CONTEXT_SYMBOL } from "../plugin/plugin";
19+
import { isPlainObject } from "../plugin/plugin";
2120
import { ResourceRegistry, ResourceType } from "../registry";
2221
import type { TelemetryConfig } from "../telemetry";
2322
import { TelemetryManager } from "../telemetry";
@@ -128,45 +127,24 @@ export class AppKit<TPlugins extends InputPluginMap> {
128127
const val = exports[key];
129128
if (typeof val === "function") {
130129
exports[key] = (val as (...args: unknown[]) => unknown).bind(context);
131-
} else if (AppKit.isPlainObject(val)) {
130+
} else if (isPlainObject(val)) {
132131
this.bindExportMethods(val as Record<string, unknown>, context);
133132
}
134133
}
135134
}
136135

137-
/**
138-
* Wraps all function properties in an exports object so they run
139-
* inside the given user context (via AsyncLocalStorage).
140-
* This ensures RoutingPool and other context-aware code sees the
141-
* user identity even though the function was obtained outside the proxy.
142-
*/
143-
private wrapExportsInUserContext(
144-
exports: Record<string, unknown>,
145-
userContext: UserContext,
146-
) {
147-
for (const key in exports) {
148-
if (!Object.hasOwn(exports, key)) continue;
149-
const val = exports[key];
150-
if (typeof val === "function") {
151-
const fn = val as (...args: unknown[]) => unknown;
152-
exports[key] = (...args: unknown[]) =>
153-
runInUserContext(userContext, () => fn(...args));
154-
} else if (AppKit.isPlainObject(val)) {
155-
this.wrapExportsInUserContext(
156-
val as Record<string, unknown>,
157-
userContext,
158-
);
159-
}
160-
}
161-
}
162-
163136
/**
164137
* Wraps a plugin's exports with an `asUser` method that returns
165138
* a user-scoped version of the exports.
166139
*
167140
* When `exports()` returns a callable (function), it is returned as-is
168141
* since the plugin manages its own `asUser` per-call (e.g. files plugin).
169142
* When it returns a plain object, the standard `asUser` wrapper is added.
143+
*
144+
* The OBO-side wrapping lives inside `Plugin.asUser` — calling
145+
* `plugin.asUser(req).exports()` returns exports whose functions already
146+
* run inside the user's AsyncLocalStorage scope. AppKit only adapts the
147+
* shape; it does not own the user-context concept.
170148
*/
171149
private wrapWithAsUser<T extends BasePlugin>(plugin: T) {
172150
// If plugin doesn't implement exports(), return empty object
@@ -192,40 +170,11 @@ export class AppKit<TPlugins extends InputPluginMap> {
192170
* Returns user-scoped exports where all methods execute with the
193171
* user's Databricks credentials instead of the service principal.
194172
*/
195-
asUser: (req: import("express").Request) => {
196-
const userPlugin = (plugin as any).asUser(req);
197-
const userContext = (userPlugin as any)[
198-
USER_CONTEXT_SYMBOL
199-
] as UserContext;
200-
const userExports = (plugin.exports?.() ?? {}) as Record<
201-
string,
202-
unknown
203-
>;
204-
// Wrap each export in runInUserContext instead of bind.
205-
// bind() bypasses the Proxy get trap, so methods called via bind
206-
// would not run inside the user's AsyncLocalStorage context.
207-
if (userContext) {
208-
this.wrapExportsInUserContext(userExports, userContext);
209-
} else {
210-
// Fallback for dev mode proxy (no userContext symbol)
211-
this.bindExportMethods(userExports, userPlugin);
212-
}
213-
return userExports;
214-
},
173+
asUser: (req: import("express").Request) =>
174+
(plugin as any).asUser(req).exports() as Record<string, unknown>,
215175
};
216176
}
217177

218-
/**
219-
* Returns true if the value is a plain object (not an array, Date, etc.).
220-
*/
221-
private static isPlainObject(
222-
value: unknown,
223-
): value is Record<string, unknown> {
224-
if (typeof value !== "object" || value === null) return false;
225-
const proto = Object.getPrototypeOf(value);
226-
return proto === Object.prototype || proto === null;
227-
}
228-
229178
static async _createApp<
230179
T extends PluginData<PluginConstructor, unknown, string>[],
231180
>(

packages/appkit/src/plugin/plugin.ts

Lines changed: 88 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,7 @@ import type {
1414
} from "shared";
1515
import { AppManager } from "../app";
1616
import { CacheManager } from "../cache";
17-
import {
18-
getCurrentUserId,
19-
runInUserContext,
20-
ServiceContext,
21-
type UserContext,
22-
} from "../context";
17+
import { getCurrentUserId, runInUserContext, ServiceContext } from "../context";
2318
import type { PluginContext } from "../core/plugin-context";
2419
import { AppKitError, AuthenticationError } from "../errors";
2520
import { createLogger } from "../logging/logger";
@@ -43,19 +38,55 @@ import type {
4338

4439
const logger = createLogger("plugin");
4540

46-
/**
47-
* Symbol used to expose the UserContext from an asUser() proxy.
48-
* Allows wrapWithAsUser in appkit.ts to retrieve the context and
49-
* wrap export methods in runInUserContext().
50-
*/
51-
export const USER_CONTEXT_SYMBOL = Symbol("appkit.userContext");
52-
5341
/**
5442
* OTel context key for marking OBO dev mode fallback.
5543
* Set when asUser() is called in development mode without a user token.
5644
*/
5745
const DEV_OBO_FALLBACK_KEY = createContextKey("appkit.devOboFallback");
5846

47+
/**
48+
* Returns true if `value` is a plain object literal (not an array, Date,
49+
* class instance, etc.). Used to decide whether to recurse into nested
50+
* export shapes when wrapping functions.
51+
*
52+
* @internal exported so the AppKit core can reuse the same predicate for
53+
* its `bindExportMethods` walk; not part of the public package surface.
54+
*/
55+
export function isPlainObject(
56+
value: unknown,
57+
): value is Record<string, unknown> {
58+
if (typeof value !== "object" || value === null) return false;
59+
const proto = Object.getPrototypeOf(value);
60+
return proto === Object.prototype || proto === null;
61+
}
62+
63+
/**
64+
* Returns a deep copy of `exports` where every function has been replaced
65+
* with `wrap(fn)`, walking into nested plain objects.
66+
*
67+
* Used by the asUser proxy to make the user context follow function
68+
* references that escape the proxy via `exports()`. The original input is
69+
* not mutated, so plugins that memoize `exports()` are safe — each call
70+
* through the proxy yields an independent, freshly wrapped view.
71+
*/
72+
function wrapExportFunctions(
73+
exports: Record<string, unknown>,
74+
wrap: (fn: (...a: unknown[]) => unknown) => (...a: unknown[]) => unknown,
75+
): Record<string, unknown> {
76+
const result: Record<string, unknown> = {};
77+
for (const key of Object.keys(exports)) {
78+
const val = exports[key];
79+
if (typeof val === "function") {
80+
result[key] = wrap(val as (...a: unknown[]) => unknown);
81+
} else if (isPlainObject(val)) {
82+
result[key] = wrapExportFunctions(val, wrap);
83+
} else {
84+
result[key] = val;
85+
}
86+
}
87+
return result;
88+
}
89+
5990
/**
6091
* Returns true if the current execution is an OBO dev mode fallback
6192
* (asUser() was called but fell back to service principal due to missing token).
@@ -403,30 +434,18 @@ export abstract class Plugin<
403434
const userEmail = req.header("x-forwarded-email");
404435
const isDev = process.env.NODE_ENV === "development";
405436

406-
// In local development, skip user impersonation
407-
// since there's no user token available
437+
// In local development, skip user impersonation since there's no user
438+
// token available. Mark execution as OBO dev fallback via OTel context
439+
// so telemetry can distinguish intended OBO calls from regular SP calls.
408440
if (!token && isDev) {
409441
logger.warn(
410442
"asUser() called without user token in development mode. Skipping user impersonation.",
411443
);
412444

413-
// Return a proxy that marks execution as OBO dev fallback via OTel context,
414-
// so telemetry spans can distinguish intended OBO calls from regular SP calls
415-
return new Proxy(this, {
416-
get: (target, prop, receiver) => {
417-
const value = Reflect.get(target, prop, receiver);
418-
if (typeof value !== "function") return value;
419-
if (typeof prop === "string" && EXCLUDED_FROM_PROXY.has(prop))
420-
return value;
421-
422-
return (...args: unknown[]) => {
423-
const ctx = otelContext
424-
.active()
425-
.setValue(DEV_OBO_FALLBACK_KEY, true);
426-
return otelContext.with(ctx, () => value.apply(target, args));
427-
};
428-
},
429-
}) as this;
445+
return this._createAsUserProxy((fn) => (...args) => {
446+
const ctx = otelContext.active().setValue(DEV_OBO_FALLBACK_KEY, true);
447+
return otelContext.with(ctx, () => fn(...args));
448+
});
430449
}
431450

432451
if (!token) {
@@ -446,34 +465,55 @@ export abstract class Plugin<
446465
userEmail ?? undefined,
447466
);
448467

449-
// Return a proxy that wraps method calls in user context
450-
return this._createUserContextProxy(userContext);
468+
return this._createAsUserProxy(
469+
(fn) =>
470+
(...args) =>
471+
runInUserContext(userContext, () => fn(...args)),
472+
);
451473
}
452474

453475
/**
454-
* Creates a proxy that wraps method calls in a user context.
455-
* This allows all plugin methods to automatically use the user's
456-
* Databricks credentials.
476+
* Creates a proxy of `this` where every method call — and every function
477+
* in the result of `exports()` — runs inside `wrapCall`.
478+
*
479+
* `wrapCall` decides the per-call scope. Two strategies are used today:
480+
* - real OBO: fn => (...args) => runInUserContext(userContext, () => fn(...args))
481+
* - dev fallback: fn => (...args) => otelContext.with(DEV_OBO_FALLBACK_KEY=true, () => fn(...args))
482+
*
483+
* `exports` is intercepted because methods captured in the returned
484+
* exports object never re-enter the proxy's `get` trap. Wrapping them
485+
* here is the only way to make the user context follow function
486+
* references back out of the plugin.
457487
*/
458-
private _createUserContextProxy(userContext: UserContext): this {
488+
private _createAsUserProxy(
489+
wrapCall: (
490+
fn: (...a: unknown[]) => unknown,
491+
) => (...a: unknown[]) => unknown,
492+
): this {
459493
return new Proxy(this, {
460494
get: (target, prop, receiver) => {
461-
// Expose userContext via symbol so wrapWithAsUser can wrap exports
462-
if (prop === USER_CONTEXT_SYMBOL) return userContext;
463-
464495
const value = Reflect.get(target, prop, receiver);
465496

466-
if (typeof value !== "function") {
497+
if (typeof value !== "function") return value;
498+
if (typeof prop === "string" && EXCLUDED_FROM_PROXY.has(prop))
467499
return value;
468-
}
469500

470-
if (typeof prop === "string" && EXCLUDED_FROM_PROXY.has(prop)) {
471-
return value;
501+
if (prop === "exports") {
502+
return () => {
503+
const raw = (value as () => unknown).call(target);
504+
if (raw == null) return {};
505+
// Callable exports (e.g. files, jobs) manage per-call asUser
506+
// themselves; leave them untouched.
507+
if (typeof raw === "function") return raw;
508+
if (isPlainObject(raw)) {
509+
return wrapExportFunctions(raw, wrapCall);
510+
}
511+
return raw;
512+
};
472513
}
473514

474-
return (...args: unknown[]) => {
475-
return runInUserContext(userContext, () => value.apply(target, args));
476-
};
515+
const fn = (value as (...a: unknown[]) => unknown).bind(target);
516+
return wrapCall(fn);
477517
},
478518
}) as this;
479519
}

0 commit comments

Comments
 (0)