Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
24aed9c
feat(files): policy-gated SP fallback on HTTP routes (phase 1)
atilafassina Apr 23, 2026
631abc9
feat(appkit): files plugin policy docs and JSDoc (phase 2)
atilafassina Apr 23, 2026
b20e24d
chore: update dev fallback and docs
atilafassina Apr 23, 2026
2cfcfa4
feat(files): per-volume auth field + _resolveAuth helper (phase 1)
atilafassina Apr 27, 2026
2b357e4
feat(appkit): files OBO identity extraction + policy gate (phase 2)
atilafassina Apr 27, 2026
9f244f9
feat(appkit): files OBO read routes via runInUserContext (phase 3)
atilafassina Apr 27, 2026
515f8cf
feat(appkit): files OBO write routes + upload-headers test (phase 4)
atilafassina Apr 27, 2026
20d0e0b
feat(appkit): files asUser routes SDK calls as the user (phase 5)
atilafassina Apr 27, 2026
44f950b
feat(appkit): files.auth_mode span attribute + manifest scope JSDoc (…
atilafassina Apr 27, 2026
5e6baa1
docs(appkit): files OBO docs, playground demo, changelog (phase 7)
atilafassina Apr 27, 2026
3f98628
fix(appkit): files OBO review fixes — auth strictness, allocation, ca…
atilafassina Apr 27, 2026
81ad34a
fix(appkit): files invalidate-cache await + integration ephemeral ports
atilafassina Apr 27, 2026
a304d96
Merge branch 'main' into sp-files
atilafassina Apr 27, 2026
6f27d08
Merge remote-tracking branch 'origin/sp-files' into sp-files
atilafassina Apr 27, 2026
f687fa2
fix(appkit): files plugin OBO review hardening (5 findings)
atilafassina Apr 28, 2026
68fe36a
fix(appkit): files /read atomic 413 + list-cache key parity for ?path=/
atilafassina May 4, 2026
248ec94
fix(appkit): files Copilot review findings — root invalidation, error…
atilafassina May 5, 2026
cec17db
fix(appkit): rename files plugin _extractObiUser → _extractOboUser
atilafassina May 5, 2026
5e5fb95
docs(appkit): refresh stale _enforcePolicy NOTE about SDK identity
atilafassina May 5, 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
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