Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
18 changes: 11 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

All notable changes to this project will be documented in this file.

# Changelog
## Unreleased

# Changelog
### appkit (files plugin) — per-volume auth mode

# Changelog
* **feat(files):** add per-volume `auth` field (`VolumeConfig.auth`) and plugin-level `auth` default (`IFilesConfig.auth`) for selecting between service-principal and on-behalf-of-user execution. Resolution order: `volume.auth ?? plugin.auth ?? "service-principal"`. OBO mode wires HTTP route handlers and `VolumeHandle.asUser` through `runInUserContext` so SDK calls execute as the end user.
* **feat(files):** add `files.auth_mode` OpenTelemetry span attribute on every operation, set to either `"service-principal"` or `"on-behalf-of-user"` for trace filtering. The attribute lands on the connector's existing `files.<op>` span (no duplicate spans).
* **feat(files)!:** `appKit.files("vol").asUser(req).list()` now executes the SDK call as the **end user** (previously the SDK still ran as the service principal — only the policy user was swapped). Programmatic callers that relied on SP credentials post-`asUser(req)` must remove the `asUser` wrap.
* **fix(files):** `asUser(req)` now requires both `x-forwarded-user` AND `x-forwarded-access-token` in production; throws `AuthenticationError.missingToken` when either is missing. Previously, a request with only the user header silently fell back to SP credentials at the SDK level while the policy saw a real-user identity — a privilege-confusion bug. Dev fallback marks the policy user as `isServicePrincipal: true`.
* **fix(files):** OBO volume read responses are no longer cached. SP volume reads still cache. Trade-off: every OBO read hits the SDK; in exchange, no cross-user staleness. (Follow-up: per-(volume, path) generation counter for OBO list cache.)
* **fix(files):** write handlers (`upload`, `mkdir`, `delete`) now `await` cache invalidation before sending the HTTP response, eliminating a write→read race within the same client tick.
* **chore(files):** remove undocumented `bypassPolicy` option on `createVolumeAPI`. Zero consumers in `packages/` or `apps/`; no migration needed.

# Changelog
#### Honest limitation

# Changelog

# Changelog
Programmatic calls on an OBO volume **without** `asUser(req)` (i.e. `appKit.files("obo-vol").list()`) cannot synthesize a user identity and continue to execute against the service principal client at the call site. For programmatic per-user execution, use `asUser(req)`. The OBO volume default applies to **HTTP route traffic**, where the request headers are available.

## [0.26.0](https://github.com/databricks/appkit/compare/v0.25.1...v0.26.0) (2026-04-27)

Expand Down
5 changes: 5 additions & 0 deletions apps/dev-playground/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ env:
valueFrom: volume
- name: DATABRICKS_VOLUME_IMPLICIT
valueFrom: volume
# OBO demo: same physical volume; auth: "on-behalf-of-user" routes
# HTTP traffic through runInUserContext so SDK calls execute as the
# end user.
- name: DATABRICKS_VOLUME_OBO_DEMO
valueFrom: volume
73 changes: 73 additions & 0 deletions apps/dev-playground/client/src/routes/policy-matrix.route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ function PolicyMatrixRoute() {
const [runningAll, setRunningAll] = useState(false);
const [spResult, setSpResult] = useState<string | null>(null);
const [oboResult, setOboResult] = useState<string | null>(null);
const [oboVolumeResult, setOboVolumeResult] = useState<string | null>(null);
const [oboVolumeHttpResult, setOboVolumeHttpResult] = useState<string | null>(
null,
);

useEffect(() => {
fetch("/whoami")
Expand Down Expand Up @@ -197,6 +201,40 @@ function PolicyMatrixRoute() {
setOboResult(JSON.stringify(await r.json(), null, 2));
}, []);

/**
* Programmatic OBO-volume smoke. Calls the dev-playground's
* `/policy/obo-volume` route which hits `appkit.files("obo_demo")` —
* a volume configured with `auth: "on-behalf-of-user"` — through both
* `asUser(req)` and the bare callable. The browser automatically
* forwards `x-forwarded-user` / `x-forwarded-access-token` when running
* behind the Databricks Apps reverse proxy; locally they're absent and
* the dev fallback reports `service-principal` execution.
*/
const runOboVolumeSmoke = useCallback(async () => {
setOboVolumeResult("…");
const r = await fetch("/policy/obo-volume");
setOboVolumeResult(JSON.stringify(await r.json(), null, 2));
}, []);

/**
* Direct HTTP probe against the OBO volume's `/list` route. Confirms
* end-to-end that the route handler routes the SDK call through
* `runInUserContext` when the headers are present, and returns 401 (or
* 403, in dev fallback) when they're missing.
*/
const runOboVolumeHttp = useCallback(async () => {
setOboVolumeHttpResult("…");
try {
const r = await fetch(`/api/files/obo_demo/list`);
const body = await r.json().catch(() => ({}) as Record<string, unknown>);
setOboVolumeHttpResult(
JSON.stringify({ httpStatus: r.status, body }, null, 2),
);
} catch (err) {
setOboVolumeHttpResult(err instanceof Error ? err.message : String(err));
}
}, []);

const reset = useCallback(() => setState(initialState), [initialState]);

return (
Expand Down Expand Up @@ -297,6 +335,41 @@ function PolicyMatrixRoute() {
<SmokePanel title="On-behalf-of user" body={oboResult} />
</div>
</div>

<div className="mt-10">
<h2 className="text-xl font-semibold mb-2">
Per-volume OBO mode (<code>auth: "on-behalf-of-user"</code>)
</h2>
<p className="text-sm text-muted-foreground mb-4">
Hits the <code>obo_demo</code> volume — configured with{" "}
<code>auth: "on-behalf-of-user"</code> — to confirm SDK calls
execute as the end user when the request carries{" "}
<code>x-forwarded-access-token</code> +{" "}
<code>x-forwarded-user</code>. In the deployed Databricks App those
headers are injected by the platform reverse proxy. Locally they're
absent and the dev-mode fallback applies: <em>HTTP returns 403</em>{" "}
(the <code>usersOnly</code> policy denies SP traffic) and the
programmatic path runs as the SP.
</p>
<div className="flex gap-3 mb-4">
<Button variant="outline" onClick={runOboVolumeHttp}>
Hit /api/files/obo_demo/list
</Button>
<Button variant="outline" onClick={runOboVolumeSmoke}>
Run OBO-volume programmatic smoke
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<SmokePanel
title="HTTP — /api/files/obo_demo/list"
body={oboVolumeHttpResult}
/>
<SmokePanel
title="Programmatic — appkit.files('obo_demo').asUser(req).list()"
body={oboVolumeResult}
/>
</div>
</div>
</div>
</div>
);
Expand Down
54 changes: 54 additions & 0 deletions apps/dev-playground/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ const adminOnly: FilePolicy = (action, _resource, user) => {
return true;
};

/**
* OBO demo policy: deny anything running as the SP (including the dev
* fallback when no `x-forwarded-access-token` is present). Only real
* end-users (`isServicePrincipal: false`) get through.
*/
const usersOnly: FilePolicy = (_action, _resource, user) => {
return user.isServicePrincipal !== true;
};

createApp({
plugins: [
server(),
Expand Down Expand Up @@ -79,6 +88,14 @@ createApp({
write_only: { policy: files.policy.not(files.policy.publicRead()) },
// no explicit policy → falls back to publicRead() + startup warning
implicit: {},
// OBO demo volume — auth: "on-behalf-of-user" routes HTTP traffic
// through `runInUserContext` so SDK calls execute with the end
// user's access token. The `usersOnly` policy denies any traffic
// that wasn't authenticated via `x-forwarded-access-token`.
obo_demo: {
auth: "on-behalf-of-user",
policy: usersOnly,
},
},
}),
serving(),
Expand Down Expand Up @@ -194,6 +211,43 @@ createApp({
results,
});
});

/**
* Per-volume OBO mode demo. Hits the `obo_demo` volume — configured
* with `auth: "on-behalf-of-user"` — to confirm:
*
* 1. With a forwarded user identity, HTTP routes execute the SDK
* call as the end user (request goes through `runInUserContext`).
* 2. Without `x-forwarded-access-token`, production returns 401;
* development falls back to the SP and the `usersOnly` policy
* rejects with 403.
* 3. Programmatic `appkit.files("obo_demo").asUser(req).list()` runs
* inside the same user context.
*
* Returns the HTTP status, body, and the user identity the server
* observes — so the policy-matrix client can render a clear
* pass/fail panel.
*/
app.get("/policy/obo-volume", async (req, res) => {
const xForwardedUser = req.header("x-forwarded-user") ?? null;
const xForwardedToken =
(req.header("x-forwarded-access-token")?.length ?? 0) > 0;

const programmatic: ProbeResult[] = await runProbes([
[
"obo_demo",
"list",
() => appkit.files("obo_demo").asUser(req).list(),
],
]);

res.json({
mode: "on-behalf-of-user",
xForwardedUser,
xForwardedAccessTokenPresent: xForwardedToken,
programmatic,
});
});
});
},
}).catch(console.error);
Expand Down
28 changes: 27 additions & 1 deletion docs/docs/api/appkit/Interface.FilePolicyUser.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ Minimal user identity passed to the policy function.
id: string;
```

Identifier of the requesting caller. For end-user HTTP requests this is
the value of the `x-forwarded-user` header; for direct SDK calls and
header-less HTTP requests (which run as the service principal), this is
the service principal's ID.

***

### isServicePrincipal?
Expand All @@ -18,4 +23,25 @@ id: string;
optional isServicePrincipal: boolean;
```

`true` when the caller is the service principal (direct SDK call, not `asUser`).
`true` when the call is executing as the service principal — either a
direct SDK call (`appKit.files(...)`) or an HTTP request that arrived
without an `x-forwarded-user` / `x-forwarded-access-token` header.
Policy authors typically check this first to distinguish SP traffic
from end-user traffic.

The flag reflects the **policy user** the plugin selects, which
combines the volume's effective `auth` mode with the headers on the
incoming request. The full matrix:

| Volume `auth` | Path | Headers | `isServicePrincipal` | Notes |
| --------------------- | ------------------------------ | ----------------------------- | -------------------- | ---------------------------------------------------------------------------------------------- |
| `service-principal` | HTTP | `x-forwarded-user` present | `false` (or unset) | Pre-OBO behavior. Policy sees the end user but the SDK call still runs as the SP. |
| `service-principal` | HTTP | no `x-forwarded-user` | `true` | Headerless request — policy and SDK both run as the SP. |
| `on-behalf-of-user` | HTTP | valid token + user header | `false` | Real end-user execution. Policy sees the user; the SDK call also runs as the user. |
| `on-behalf-of-user` | HTTP | missing token, dev-fallback | `true` | Only reachable when `NODE_ENV === "development"` (prod returns 401). Treated as SP traffic. |
| any | Programmatic `asUser(req)` | `x-forwarded-user` present | `false` | `asUser` extracts the user; the SDK call runs as the user inside `runInUserContext`. |

Programmatic calls without `asUser(req)` always set
`isServicePrincipal: true` because no request is available to derive a
user identity from. OBO volume defaults apply only to HTTP route
traffic; for programmatic per-user execution, use `asUser(req)`.
Loading
Loading