From 24aed9ccd3b68396643d1bd28b048d7c95e20e23 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Apr 2026 15:03:03 +0200 Subject: [PATCH 1/3] feat(files): policy-gated SP fallback on HTTP routes (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of 2 — remove pre-policy 401 on missing x-forwarded-user; let the volume policy decide via { id: , isServicePrincipal: true }. asUser(req) keeps strict throw semantics; single logger.debug on fallback replaces the dev-mode logger.warn. Startup no-explicit-policy warning broadened to mention header-less HTTP. Tests rewritten to codify new contract. Two pre-existing auto-generated files reformatted to match biome. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- packages/appkit/src/plugins/files/plugin.ts | 31 +-- .../files/tests/plugin.integration.test.ts | 116 ++++++++- .../src/plugins/files/tests/plugin.test.ts | 227 ++++++++++++++---- 3 files changed, 312 insertions(+), 62 deletions(-) diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 75f2e14d..28239076 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -100,19 +100,12 @@ export class FilesPlugin extends Plugin { } /** - * Extract user identity from the request. - * Falls back to `getCurrentUserId()` in development mode. + * Strict extraction for `VolumeHandle.asUser(req)` — throws when the header + * is missing. HTTP routes use an inline silent fallback instead. */ private _extractUser(req: express.Request): FilePolicyUser { const userId = req.header("x-forwarded-user")?.trim(); if (userId) return { id: userId }; - if (process.env.NODE_ENV === "development") { - logger.warn( - "No x-forwarded-user header — falling back to service principal identity for policy checks. " + - "Ensure your proxy forwards user headers to test per-user policies.", - ); - return { id: getCurrentUserId() }; - } throw AuthenticationError.missingToken( "Missing x-forwarded-user header. Cannot resolve user ID.", ); @@ -152,7 +145,8 @@ export class FilesPlugin extends Plugin { /** * HTTP-level wrapper around `_checkPolicy`. - * Extracts user (401 on failure), runs policy (403 on denial). + * Resolves the user inline (header when present, otherwise the SP identity), + * then runs the volume policy (403 on denial, 500 on unexpected error). * Returns `true` if the request may proceed, `false` if a response was sent. */ private async _enforcePolicy( @@ -163,15 +157,15 @@ export class FilesPlugin extends Plugin { path: string, resourceOverrides?: Partial, ): Promise { + const headerUserId = req.header("x-forwarded-user")?.trim(); let user: FilePolicyUser; - try { - user = this._extractUser(req); - } catch (error) { - if (error instanceof AuthenticationError) { - res.status(401).json({ error: error.message, plugin: this.name }); - return false; - } - throw error; + if (headerUserId) { + user = { id: headerUserId }; + } else { + logger.debug( + "No x-forwarded-user header — proceeding with service principal identity for policy evaluation.", + ); + user = { id: getCurrentUserId(), isServicePrincipal: true }; } try { @@ -231,6 +225,7 @@ export class FilesPlugin extends Plugin { if (!volumes[key].policy) { logger.warn( 'Volume "%s" has no explicit policy — defaulting to publicRead(). ' + + "This also matches header-less HTTP requests (which run as the service principal). " + "Set a policy in files({ volumes: { %s: { policy: ... } } }) to silence this warning.", key, key, diff --git a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts index da90760d..3c836f9b 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.integration.test.ts @@ -439,15 +439,125 @@ describe("Files Plugin Integration", () => { }); describe("Service principal execution", () => { - test("requests without user token return 401 (policy requires user identity)", async () => { + test("header-less request + default publicRead() + list → 200 (policy decides)", async () => { + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () { + yield { + name: "sp-file.txt", + path: "/Volumes/catalog/schema/vol/sp-file.txt", + is_directory: false, + }; + })(), + ); + // Use a unique path to avoid cached results from earlier tests const response = await fetch( `${baseUrl}/api/files/${VOL}/list?path=sp-only`, ); - expect(response.status).toBe(401); + expect(response.status).toBe(200); + }); + + test("header-less request + default publicRead() + upload → 403", async () => { + const response = await fetch( + `${baseUrl}/api/files/${VOL}/upload?path=/Volumes/catalog/schema/vol/sp-upload.bin`, + { + method: "POST", + headers: { "content-length": "0" }, + }, + ); + + expect(response.status).toBe(403); const data = (await response.json()) as { error: string }; - expect(data.error).toMatch(/x-forwarded-user/); + expect(data.error).toMatch(/Policy denied/); + }); + + test("header-less request + denyAll() volume → 403", async () => { + const denySpy = vi.fn().mockReturnValue(false); + const appkit = await createApp({ + plugins: [ + serverPlugin({ + port: TEST_PORT + 1, + host: "127.0.0.1", + autoStart: false, + }), + files({ + volumes: { + files: { policy: denySpy }, + }, + }), + ], + }); + + try { + await appkit.server.start(); + const localBase = `http://127.0.0.1:${TEST_PORT + 1}`; + + const response = await fetch( + `${localBase}/api/files/${VOL}/list?path=denied`, + ); + + expect(response.status).toBe(403); + expect(denySpy).toHaveBeenCalled(); + const userArg = denySpy.mock.calls[0][2]; + expect(userArg.isServicePrincipal).toBe(true); + } finally { + const srv = appkit.server.getServer(); + if (srv) { + await new Promise((resolve, reject) => { + srv.close((err) => (err ? reject(err) : resolve())); + }); + } + } + }); + + test("header-less HTTP request → custom policy observes isServicePrincipal: true", async () => { + const policySpy = vi.fn().mockReturnValue(true); + const appkit = await createApp({ + plugins: [ + serverPlugin({ + port: TEST_PORT + 2, + host: "127.0.0.1", + autoStart: false, + }), + files({ + volumes: { + files: { policy: policySpy }, + }, + }), + ], + }); + + try { + await appkit.server.start(); + const localBase = `http://127.0.0.1:${TEST_PORT + 2}`; + + mockFilesApi.listDirectoryContents.mockReturnValue( + (async function* () { + yield { + name: "spy-file.txt", + path: "/Volumes/catalog/schema/vol/spy-file.txt", + is_directory: false, + }; + })(), + ); + + const response = await fetch( + `${localBase}/api/files/${VOL}/list?path=spy`, + ); + + expect(response.status).toBe(200); + expect(policySpy).toHaveBeenCalledTimes(1); + const userArg = policySpy.mock.calls[0][2]; + expect(userArg.isServicePrincipal).toBe(true); + } finally { + const srv = appkit.server.getServer(); + if (srv) { + await new Promise((resolve, reject) => { + srv.close((err) => (err ? reject(err) : resolve())); + }); + } + } }); test("requests with user headers also succeed", async () => { diff --git a/packages/appkit/src/plugins/files/tests/plugin.test.ts b/packages/appkit/src/plugins/files/tests/plugin.test.ts index a4b9bea2..79587a1e 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.test.ts @@ -271,40 +271,12 @@ describe("FilesPlugin", () => { } }); - test("asUser without user header in production → throws AuthenticationError", () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "production"; - - try { - const plugin = new FilesPlugin(VOLUMES_CONFIG); - const handle = plugin.exports()("uploads"); - const mockReq = { header: () => undefined } as any; - - expect(() => handle.asUser(mockReq)).toThrow(AuthenticationError); - } finally { - process.env.NODE_ENV = originalEnv; - } - }); - - test("asUser in dev mode returns VolumeAPI with all 9 methods", () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "development"; - - try { - const plugin = new FilesPlugin(VOLUMES_CONFIG); - const handle = plugin.exports()("uploads"); - const mockReq = { - header: (name: string) => - name === "x-forwarded-user" ? "test-user" : undefined, - } as any; - const api = handle.asUser(mockReq); + test("asUser without user header → throws AuthenticationError", () => { + const plugin = new FilesPlugin(VOLUMES_CONFIG); + const handle = plugin.exports()("uploads"); + const mockReq = { header: () => undefined } as any; - for (const method of volumeMethods) { - expect(typeof (api as any)[method]).toBe("function"); - } - } finally { - process.env.NODE_ENV = originalEnv; - } + expect(() => handle.asUser(mockReq)).toThrow(AuthenticationError); }); test("direct methods on handle work as service principal", () => { @@ -988,19 +960,189 @@ describe("FilesPlugin", () => { delete process.env.DATABRICKS_VOLUME_WRITEONLY; }); - test("policy volume + no user header (production) → 401", async () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = "production"; + test("header-less HTTP + default publicRead() + read action → 200 with SP user", async () => { + const policySpy = vi.fn().mockReturnValue(true); + const spyConfig = { + volumes: { + spied: { policy: policySpy }, + uploads: {}, + exports: {}, + }, + }; + process.env.DATABRICKS_VOLUME_SPIED = "/Volumes/c/s/spied"; + + try { + const plugin = new FilesPlugin(spyConfig); + const handler = getRouteHandler(plugin, "get", "/list"); + const res = mockRes(); + + mockClient.files.listDirectoryContents.mockImplementation( + async function* () { + yield { name: "h.txt", path: "/h.txt", is_directory: false }; + }, + ); + + const noUserHeaders: Record = {}; + await handler( + { + params: { volumeKey: "spied" }, + query: {}, + headers: noUserHeaders, + header: (name: string) => noUserHeaders[name.toLowerCase()], + }, + res, + ); + + const statusCodes = (res.status.mock.calls as number[][]).map( + (c) => c[0], + ); + expect(statusCodes).not.toContain(401); + expect(statusCodes).not.toContain(403); + expect(policySpy).toHaveBeenCalledWith( + "list", + expect.objectContaining({ volume: "spied" }), + expect.objectContaining({ + id: "test-service-principal", + isServicePrincipal: true, + }), + ); + } finally { + delete process.env.DATABRICKS_VOLUME_SPIED; + } + }); + + test("header-less HTTP + default publicRead() + write action → 403 with SP user", async () => { + const plugin = new FilesPlugin(POLICY_CONFIG); + const handler = getRouteHandler(plugin, "post", "/upload"); + const res = mockRes(); + + const noUserHeaders: Record = { + "content-length": "100", + }; + await handler( + { + params: { volumeKey: "uploads" }, + query: { path: "/test.bin" }, + headers: noUserHeaders, + header: (name: string) => noUserHeaders[name.toLowerCase()], + }, + res, + ); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.stringContaining("Policy denied"), + }), + ); + }); + + test("header-less HTTP + denyAll() → 403 with SP user observed by policy", async () => { + const policySpy = vi.fn(policy.denyAll()); + const spyConfig = { + volumes: { + denied: { policy: policySpy }, + uploads: {}, + exports: {}, + }, + }; + process.env.DATABRICKS_VOLUME_DENIED = "/Volumes/c/s/denied"; + + try { + const plugin = new FilesPlugin(spyConfig); + const handler = getRouteHandler(plugin, "get", "/list"); + const res = mockRes(); + + const noUserHeaders: Record = {}; + await handler( + { + params: { volumeKey: "denied" }, + query: {}, + headers: noUserHeaders, + header: (name: string) => noUserHeaders[name.toLowerCase()], + }, + res, + ); + + expect(res.status).toHaveBeenCalledWith(403); + expect(policySpy).toHaveBeenCalledWith( + "list", + expect.objectContaining({ volume: "denied" }), + expect.objectContaining({ isServicePrincipal: true }), + ); + } finally { + delete process.env.DATABRICKS_VOLUME_DENIED; + } + }); + + test("header-less HTTP request → policy spy observes { isServicePrincipal: true } and decision is honored", async () => { + const allowSpy = vi.fn().mockResolvedValue(true); + const allowConfig = { + volumes: { + gated: { policy: allowSpy }, + uploads: {}, + exports: {}, + }, + }; + process.env.DATABRICKS_VOLUME_GATED = "/Volumes/c/s/gated"; + + try { + const plugin = new FilesPlugin(allowConfig); + const handler = getRouteHandler(plugin, "get", "/list"); + const res = mockRes(); + + mockClient.files.listDirectoryContents.mockImplementation( + async function* () { + yield { name: "g.txt", path: "/g.txt", is_directory: false }; + }, + ); + + const noUserHeaders: Record = {}; + await handler( + { + params: { volumeKey: "gated" }, + query: {}, + headers: noUserHeaders, + header: (name: string) => noUserHeaders[name.toLowerCase()], + }, + res, + ); + + expect(allowSpy).toHaveBeenCalledTimes(1); + const userArg = allowSpy.mock.calls[0][2]; + expect(userArg.isServicePrincipal).toBe(true); + expect(userArg.id).toBe("test-service-principal"); + + const statusCodes = (res.status.mock.calls as number[][]).map( + (c) => c[0], + ); + expect(statusCodes).not.toContain(401); + expect(statusCodes).not.toContain(403); + } finally { + delete process.env.DATABRICKS_VOLUME_GATED; + } + }); + + test("header-less HTTP request + policy returns false → 403 (decision honored)", async () => { + const denySpy = vi.fn().mockResolvedValue(false); + const denyConfig = { + volumes: { + gated: { policy: denySpy }, + uploads: {}, + exports: {}, + }, + }; + process.env.DATABRICKS_VOLUME_GATED = "/Volumes/c/s/gated"; + try { - const plugin = new FilesPlugin(POLICY_CONFIG); + const plugin = new FilesPlugin(denyConfig); const handler = getRouteHandler(plugin, "get", "/list"); const res = mockRes(); - // Override both headers to undefined so _extractUser has no user const noUserHeaders: Record = {}; await handler( { - params: { volumeKey: "public" }, + params: { volumeKey: "gated" }, query: {}, headers: noUserHeaders, header: (name: string) => noUserHeaders[name.toLowerCase()], @@ -1008,9 +1150,12 @@ describe("FilesPlugin", () => { res, ); - expect(res.status).toHaveBeenCalledWith(401); + expect(denySpy).toHaveBeenCalledTimes(1); + const userArg = denySpy.mock.calls[0][2]; + expect(userArg.isServicePrincipal).toBe(true); + expect(res.status).toHaveBeenCalledWith(403); } finally { - process.env.NODE_ENV = originalEnv; + delete process.env.DATABRICKS_VOLUME_GATED; } }); From 631abc99e28f1044ef906edcab5c7b948a8bbff3 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Apr 2026 15:11:19 +0200 Subject: [PATCH 2/3] feat(appkit): files plugin policy docs and JSDoc (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of 2 — non-behavioral polish. JSDoc on FilePolicyUser.id / isServicePrincipal broadened to describe header-less HTTP as a valid SP call origin. VolumeHandle JSDoc notes asUser(req) throws AuthenticationError.missingToken regardless of NODE_ENV. Files-plugin docs paragraphs that implied x-forwarded-user was mandatory have been reworded. Auto-regenerated typedoc for FilePolicyUser included. Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../api/appkit/Interface.FilePolicyUser.md | 10 ++++++++- docs/docs/plugins/files.md | 21 +++++++++++++------ packages/appkit/src/plugins/files/policy.ts | 13 +++++++++++- packages/appkit/src/plugins/files/types.ts | 4 +++- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/docs/docs/api/appkit/Interface.FilePolicyUser.md b/docs/docs/api/appkit/Interface.FilePolicyUser.md index 0c4bae2f..7be87fce 100644 --- a/docs/docs/api/appkit/Interface.FilePolicyUser.md +++ b/docs/docs/api/appkit/Interface.FilePolicyUser.md @@ -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? @@ -18,4 +23,7 @@ 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` header. Policy authors typically check +this first to distinguish SP traffic from end-user traffic. diff --git a/docs/docs/plugins/files.md b/docs/docs/plugins/files.md index c823a581..3e0294f6 100644 --- a/docs/docs/plugins/files.md +++ b/docs/docs/plugins/files.md @@ -121,7 +121,7 @@ There are three layers of access control in the files plugin. Understanding how - **UC grants** control what the service principal can do at the Databricks level. These are set at deploy time via `app.yaml` resource bindings. The SP needs `WRITE_VOLUME` — the plugin declares this via resource requirements. - **Execution identity** determines whose credentials are used for the actual API call. HTTP routes always use the SP. The programmatic API uses SP by default but supports `asUser(req)` for OBO. -- **File policies** are application-level checks evaluated **before** the API call. They receive the requesting user's identity (from the `x-forwarded-user` header) and decide allow/deny. This is the only gate that distinguishes between users on HTTP routes. +- **File policies** are application-level checks evaluated **before** the API call. They receive a `FilePolicyUser` describing the caller and decide allow/deny. On HTTP routes the user is extracted from the `x-forwarded-user` header when present; when the header is absent, the policy receives `{ id: , isServicePrincipal: true }` and decides for itself whether to allow service-principal traffic. This is the only gate that distinguishes between users on HTTP routes. :::warning @@ -233,7 +233,7 @@ Dangerous MIME types (`text/html`, `text/javascript`, `application/javascript`, ## HTTP routes -Routes are mounted at `/api/files/*`. All routes execute as the service principal. Policy enforcement checks user identity (from the `x-forwarded-user` header) before allowing operations — see [Access policies](#access-policies). +Routes are mounted at `/api/files/*`. All routes execute as the service principal. Before each operation the volume policy runs: user identity comes from the `x-forwarded-user` header when present, otherwise the policy is handed `{ id: , isServicePrincipal: true }` and decides whether to allow the call. See [Access policies](#access-policies). | Method | Path | Query / Body | Response | | ------ | -------------------------- | ---------------------------- | ------------------------------------------------- | @@ -369,9 +369,18 @@ interface FileResource { } interface FilePolicyUser { - /** User ID from the `x-forwarded-user` header. */ + /** + * 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. + */ id: string; - /** `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` header. + */ isServicePrincipal?: boolean; } @@ -420,9 +429,9 @@ Built-in extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.bmp`, ` ## User context -HTTP routes always execute as the **service principal** — the SP's Databricks credentials are used for all API calls. User identity is extracted from the `x-forwarded-user` header and passed to the volume's [access policy](#access-policies) for authorization. This means UC grants on the SP (not individual users) determine what operations are possible, while policies control what each user is allowed to do through the app. +HTTP routes always execute as the **service principal** — the SP's Databricks credentials are used for all API calls. User identity is extracted from the `x-forwarded-user` header and passed to the volume's [access policy](#access-policies) for authorization. When the header is absent the policy is handed `{ id: , isServicePrincipal: true }` and decides whether to allow the call — in practice that branch only fires in development without a reverse proxy or when an upstream proxy is misconfigured, since real Databricks Apps runtimes always forward the header. This means UC grants on the SP (not individual users) determine what operations are possible, while policies control what each user is allowed to do through the app. -The programmatic API returns a `VolumeHandle` that exposes all `VolumeAPI` methods directly (service principal) and an `asUser(req)` method for OBO access. Calling any method without `asUser()` logs a warning encouraging OBO usage but does not throw. OBO access is strongly recommended for production use. +The programmatic API returns a `VolumeHandle` that exposes all `VolumeAPI` methods directly (service principal) and an `asUser(req)` method for OBO access. Calling any method without `asUser()` runs the policy with `isServicePrincipal: true`. `asUser(req)` throws `AuthenticationError.missingToken` when the `x-forwarded-user` header is missing, regardless of `NODE_ENV`. OBO access is strongly recommended for production use. ## Resource requirements diff --git a/packages/appkit/src/plugins/files/policy.ts b/packages/appkit/src/plugins/files/policy.ts index 87b23f37..54875ca0 100644 --- a/packages/appkit/src/plugins/files/policy.ts +++ b/packages/appkit/src/plugins/files/policy.ts @@ -57,8 +57,19 @@ export interface FileResource { /** Minimal user identity passed to the policy function. */ export interface FilePolicyUser { + /** + * 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. + */ id: string; - /** `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` header. Policy authors typically check + * this first to distinguish SP traffic from end-user traffic. + */ isServicePrincipal?: boolean; } diff --git a/packages/appkit/src/plugins/files/types.ts b/packages/appkit/src/plugins/files/types.ts index 82b54688..0dc27b7f 100644 --- a/packages/appkit/src/plugins/files/types.ts +++ b/packages/appkit/src/plugins/files/types.ts @@ -87,7 +87,9 @@ export interface FilePreview extends FileMetadata { * * All methods execute as the service principal and enforce the volume's * policy (if configured) with `{ isServicePrincipal: true }`. - * `asUser(req)` re-wraps with the real user identity for per-user policy checks. + * `asUser(req)` re-wraps with the real user identity for per-user policy + * checks; it throws `AuthenticationError.missingToken` when the + * `x-forwarded-user` header is missing, regardless of `NODE_ENV`. */ export type VolumeHandle = VolumeAPI & { asUser: (req: IAppRequest) => VolumeAPI; From b20e24d64076858bc6f3c2193091c68ef70b6df3 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 23 Apr 2026 15:31:50 +0200 Subject: [PATCH 3/3] chore: update dev fallback and docs --- docs/docs/plugins/files.md | 2 +- packages/appkit/src/plugins/files/plugin.ts | 12 +++++-- .../src/plugins/files/tests/plugin.test.ts | 33 ++++++++++++++++--- packages/appkit/src/plugins/files/types.ts | 6 ++-- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/docs/docs/plugins/files.md b/docs/docs/plugins/files.md index 3e0294f6..18295567 100644 --- a/docs/docs/plugins/files.md +++ b/docs/docs/plugins/files.md @@ -431,7 +431,7 @@ Built-in extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.bmp`, ` HTTP routes always execute as the **service principal** — the SP's Databricks credentials are used for all API calls. User identity is extracted from the `x-forwarded-user` header and passed to the volume's [access policy](#access-policies) for authorization. When the header is absent the policy is handed `{ id: , isServicePrincipal: true }` and decides whether to allow the call — in practice that branch only fires in development without a reverse proxy or when an upstream proxy is misconfigured, since real Databricks Apps runtimes always forward the header. This means UC grants on the SP (not individual users) determine what operations are possible, while policies control what each user is allowed to do through the app. -The programmatic API returns a `VolumeHandle` that exposes all `VolumeAPI` methods directly (service principal) and an `asUser(req)` method for OBO access. Calling any method without `asUser()` runs the policy with `isServicePrincipal: true`. `asUser(req)` throws `AuthenticationError.missingToken` when the `x-forwarded-user` header is missing, regardless of `NODE_ENV`. OBO access is strongly recommended for production use. +The programmatic API returns a `VolumeHandle` that exposes all `VolumeAPI` methods directly (service principal) and an `asUser(req)` method for OBO access. Calling any method without `asUser()` runs the policy with `isServicePrincipal: true`. In production, `asUser(req)` throws `AuthenticationError.missingToken` when the `x-forwarded-user` header is missing. In development (`NODE_ENV === "development"`) it falls back to the service principal instead, so local testing without a reverse proxy continues to work. ## Resource requirements diff --git a/packages/appkit/src/plugins/files/plugin.ts b/packages/appkit/src/plugins/files/plugin.ts index 28239076..a9526a03 100644 --- a/packages/appkit/src/plugins/files/plugin.ts +++ b/packages/appkit/src/plugins/files/plugin.ts @@ -100,12 +100,20 @@ export class FilesPlugin extends Plugin { } /** - * Strict extraction for `VolumeHandle.asUser(req)` — throws when the header - * is missing. HTTP routes use an inline silent fallback instead. + * Extraction for `VolumeHandle.asUser(req)`. Throws in production when the + * header is missing. In development (`NODE_ENV === "development"`) falls + * back to the service principal so local testing without a reverse proxy + * works. HTTP routes use an inline silent fallback regardless of NODE_ENV. */ private _extractUser(req: express.Request): FilePolicyUser { const userId = req.header("x-forwarded-user")?.trim(); if (userId) return { id: userId }; + if (process.env.NODE_ENV === "development") { + logger.debug( + "No x-forwarded-user header on asUser(req) — falling back to service principal identity (dev mode).", + ); + return { id: getCurrentUserId(), isServicePrincipal: true }; + } throw AuthenticationError.missingToken( "Missing x-forwarded-user header. Cannot resolve user ID.", ); diff --git a/packages/appkit/src/plugins/files/tests/plugin.test.ts b/packages/appkit/src/plugins/files/tests/plugin.test.ts index 79587a1e..8af73ee0 100644 --- a/packages/appkit/src/plugins/files/tests/plugin.test.ts +++ b/packages/appkit/src/plugins/files/tests/plugin.test.ts @@ -271,12 +271,35 @@ describe("FilesPlugin", () => { } }); - test("asUser without user header → throws AuthenticationError", () => { - const plugin = new FilesPlugin(VOLUMES_CONFIG); - const handle = plugin.exports()("uploads"); - const mockReq = { header: () => undefined } as any; + test("asUser without user header in production → throws AuthenticationError", () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + const plugin = new FilesPlugin(VOLUMES_CONFIG); + const handle = plugin.exports()("uploads"); + const mockReq = { header: () => undefined } as any; + + expect(() => handle.asUser(mockReq)).toThrow(AuthenticationError); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); - expect(() => handle.asUser(mockReq)).toThrow(AuthenticationError); + test("asUser without user header in development → falls back to SP identity", () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + try { + const plugin = new FilesPlugin(VOLUMES_CONFIG); + const handle = plugin.exports()("uploads"); + const mockReq = { header: () => undefined } as any; + + // Does not throw; returns a VolumeAPI that will run the policy with + // { isServicePrincipal: true } (matching the HTTP-path collapsed semantic). + const api = handle.asUser(mockReq); + expect(typeof api.list).toBe("function"); + } finally { + process.env.NODE_ENV = originalEnv; + } }); test("direct methods on handle work as service principal", () => { diff --git a/packages/appkit/src/plugins/files/types.ts b/packages/appkit/src/plugins/files/types.ts index 0dc27b7f..54a9b369 100644 --- a/packages/appkit/src/plugins/files/types.ts +++ b/packages/appkit/src/plugins/files/types.ts @@ -88,8 +88,10 @@ export interface FilePreview extends FileMetadata { * All methods execute as the service principal and enforce the volume's * policy (if configured) with `{ isServicePrincipal: true }`. * `asUser(req)` re-wraps with the real user identity for per-user policy - * checks; it throws `AuthenticationError.missingToken` when the - * `x-forwarded-user` header is missing, regardless of `NODE_ENV`. + * checks. In production it throws `AuthenticationError.missingToken` when + * the `x-forwarded-user` header is missing; in development + * (`NODE_ENV === "development"`) it falls back to the service principal so + * local testing without a reverse proxy continues to work. */ export type VolumeHandle = VolumeAPI & { asUser: (req: IAppRequest) => VolumeAPI;