From 31d1df04356ee6cb39cb1bb90e42e7c302974320 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 7 May 2026 19:48:41 +0200 Subject: [PATCH 1/4] docs(AppKit): document Files plugin policy API Update the Files skill reference for AppKit v0.21.0+ per-volume policy enforcement (PR databricks/appkit#197): - Add Permission Model section explaining the three layers (UC grants, execution identity, file policies) - Add Access Policies section with built-ins, combinators, custom policies, and policy-input types - Fix HTTP Routes intro: routes always run as service principal, user identity comes from x-forwarded-user and is passed to the policy (not asUser(req)) - Reframe Server-Side API: asUser(req) switches policy identity, not UC credentials - Refresh troubleshooting with policy-related errors and the default-publicRead() write-denial gotcha Co-authored-by: Isaac --- .../references/appkit/files.md | 222 +++++++++++++++--- 1 file changed, 184 insertions(+), 38 deletions(-) diff --git a/skills/databricks-apps/references/appkit/files.md b/skills/databricks-apps/references/appkit/files.md index 108d9c0..3e28212 100644 --- a/skills/databricks-apps/references/appkit/files.md +++ b/skills/databricks-apps/references/appkit/files.md @@ -6,12 +6,12 @@ Use the `files()` plugin when your app needs to **browse, upload, download, or m ## When to Use Files vs Other Patterns -| Pattern | Use Case | Data Source | -| --- | --- | --- | -| Analytics | Read-only dashboards, charts, KPIs | Databricks SQL Warehouse | -| Lakebase | CRUD operations, persistent state, forms | PostgreSQL (Lakebase) | -| Files | File uploads, downloads, browsing, previews | Unity Catalog Volumes | -| Files + Analytics | Upload CSVs then query warehouse tables | Volumes + SQL Warehouse | +| Pattern | Use Case | Data Source | +| ----------------- | ------------------------------------------- | ------------------------ | +| Analytics | Read-only dashboards, charts, KPIs | Databricks SQL Warehouse | +| Lakebase | CRUD operations, persistent state, forms | PostgreSQL (Lakebase) | +| Files | File uploads, downloads, browsing, previews | Unity Catalog Volumes | +| Files + Analytics | Upload CSVs then query warehouse tables | Volumes + SQL Warehouse | ## Scaffolding @@ -43,29 +43,169 @@ The env var suffix (after `DATABRICKS_VOLUME_`) becomes the volume key, lowercas import { createApp, files, server } from "@databricks/appkit"; await createApp({ - plugins: [ - server(), - files(), - ], + plugins: [server(), files()], }); ``` +### Configuration + +Plugin-level options inherit to every volume; per-volume config overrides them: + +```typescript +files({ + maxUploadSize: 5_000_000_000, // 5 GB default (all volumes) + customContentTypes: { ".avro": "application/avro" }, + volumes: { + uploads: { maxUploadSize: 100_000_000 }, // 100 MB override + exports: {}, // inherits plugin-level + }, +}); +``` + +Auto-discovered volumes merge with explicit config — `volumes: {}` is only needed for overrides. + +## Permission Model + +Three layers gate file access — understand all three before deploying: + +1. **Unity Catalog grants** — the SP needs `WRITE_VOLUME`. Set at deploy time via `app.yaml` resource bindings; the plugin auto-declares the requirement. +2. **Execution identity** — HTTP routes **always** run as the service principal. The programmatic API runs as SP by default; `asUser(req)` re-wraps with the request's user identity. +3. **File policies** — per-volume function `(action, resource, user) → boolean` evaluated **before** every operation. This is the only layer that distinguishes between users on HTTP routes (since HTTP always uses SP credentials). + +> Removing a user's UC `WRITE_VOLUME` grant has **no effect on HTTP access** — the SP's grant is what's used. Policies are the only way to restrict per-user access through HTTP routes. + +## Access Policies + +Volumes without an explicit `policy` default to `files.policy.publicRead()` (reads allowed, writes denied) and log a startup warning. Set an explicit policy on every volume that needs writes. + +```typescript +import { files } from "@databricks/appkit"; + +files({ + volumes: { + public_data: { policy: files.policy.publicRead() }, // reads only + uploads: { policy: files.policy.allowAll() }, // anyone can write + archive: { policy: files.policy.denyAll() }, // locked down + }, +}); +``` + +### Built-in policies + +| Helper | Allows | +| --------------------------- | ------------------------------------------------------------------ | +| `files.policy.publicRead()` | `list`, `read`, `download`, `raw`, `exists`, `metadata`, `preview` | +| `files.policy.allowAll()` | every action | +| `files.policy.denyAll()` | no action (yes — even `list`) | + +### Combinators + +- `files.policy.all(p1, p2, ...)` — AND, short-circuits on first deny +- `files.policy.any(p1, p2, ...)` — OR, short-circuits on first allow +- `files.policy.not(p)` — invert (e.g. `not(publicRead())` = write-only drop-box) + +### Custom policies + +A `FilePolicy` is `(action, resource, user) => boolean | Promise`. `READ_ACTIONS` and `WRITE_ACTIONS` are exported `Set` for action-class checks. + +```typescript +import { type FilePolicy, WRITE_ACTIONS } from "@databricks/appkit"; + +const ADMIN_IDS = ["admin@company.com"]; + +const adminWrite: FilePolicy = (action, _resource, user) => { + if (WRITE_ACTIONS.has(action)) return ADMIN_IDS.includes(user.id); + return true; // reads open to everyone +}; + +files({ + volumes: { reports: { policy: adminWrite } }, +}); +``` + +Mix custom logic with built-ins via combinators — e.g. SP can do anything, users can only read: + +```typescript +files.policy.any( + (_action, _resource, user) => !!user.isServicePrincipal, + files.policy.publicRead(), +); +``` + +### Policy inputs + +```typescript +type FileAction = + | "list" | "read" | "download" | "raw" | "exists" | "metadata" | "preview" + | "upload" | "mkdir" | "delete"; + +interface FileResource { + path: string; // relative path within the volume + volume: string; // volume key + size?: number; // content-length in bytes (uploads only) +} + +interface FilePolicyUser { + id: string; // from `x-forwarded-user` header (HTTP) or req + isServicePrincipal?: boolean; // true when programmatic API skipped asUser() +} +``` + +### Enforcement + +- **HTTP routes**: denied → `403 { error: "Policy denied \"{action}\" on volume \"{volume}\"", plugin: "files" }`. +- **Programmatic API**: denied → throws `PolicyDeniedError` (importable from `@databricks/appkit`) with `.action` and `.volumeKey` properties. Both `appkit.files("vol").list()` (SP, `isServicePrincipal: true`) and `appkit.files("vol").asUser(req).list()` (user) are gated. + ## Server-Side API (Programmatic) -Access volumes through the `files()` callable, which returns a `VolumeHandle`: +Access volumes through the `files()` callable, which returns a `VolumeHandle`. Every method runs as the service principal — `asUser(req)` only changes the user identity passed into the **policy** check (the underlying SDK call still uses SP credentials). ```typescript -// ✅ CORRECT — OBO access (recommended) -const entries = await appkit.files("uploads").asUser(req).list(); -const content = await appkit.files("exports").asUser(req).read("report.csv"); - -// ❌ WRONG — omitting .asUser(req) -const entries = await appkit.files("uploads").list(); -// In dev: silently falls back to service principal credentials, bypassing user-level UC permissions -// In production: throws an error +// User identity passed to policy → user.id from req +await appkit.files("uploads").asUser(req).list(); + +// SP identity passed to policy → user.isServicePrincipal === true; logs a warning +await appkit.files("uploads").list(); + +// Named accessor equivalent +await appkit.files.volume("uploads").asUser(req).list(); ``` -**ALWAYS use `.asUser(req)`** — without it, dev mode silently uses the app's service principal (masking permission issues that will crash in production). +**Use `.asUser(req)` in route handlers.** Without it the policy sees `isServicePrincipal: true` and a warning is logged — fine for background jobs, wrong for user-driven endpoints where per-user policy decisions matter. Policy denial throws `PolicyDeniedError`. + +**`VolumeAPI` methods**: `list`, `read`, `download`, `exists`, `metadata`, `preview`, `upload`, `createDirectory`, `delete`. `read()` caps files at 10 MB by default — pass `{ maxSize: }` or use `download()` for larger files. Paths are absolute (`/Volumes/...`) or relative to the volume root; `../` and null bytes are rejected. + +### Execution defaults + +Every operation runs through cache/retry/timeout interceptors: + +| Tier | Operations | Cache | Retry | Timeout | +| -------- | ------------------------------------- | ----- | ----- | ------- | +| Read | list, read, exists, metadata, preview | 60 s | 3× | 30 s | +| Download | download, raw | — | 3× | 30 s | +| Write | upload, mkdir, delete | — | — | 600 s | + +Cache keys include the volume key (no cross-volume collisions). Write operations auto-invalidate the parent directory's cached `list` entry. + +## HTTP Routes + +Mounted at `/api/files/*`. All routes execute as the service principal; user identity is read from the `x-forwarded-user` header and passed to the volume's policy. Policy denial → `403`. + +| Method | Path | Purpose | +| ------ | ---------------------------- | ------------------------------------------ | +| GET | `/volumes` | List configured volume keys | +| GET | `/:volumeKey/list?path=` | Directory listing | +| GET | `/:volumeKey/read?path=` | Read text content | +| GET | `/:volumeKey/download?path=` | Binary stream (attachment) | +| GET | `/:volumeKey/raw?path=` | Inline stream (attachment for unsafe MIME) | +| GET | `/:volumeKey/exists?path=` | Existence check | +| GET | `/:volumeKey/metadata?path=` | File metadata | +| GET | `/:volumeKey/preview?path=` | Preview (text + type flags) | +| POST | `/:volumeKey/upload?path=` | Upload (raw body) | +| POST | `/:volumeKey/mkdir` | Create directory (`body.path`) | +| DELETE | `/:volumeKey?path=` | Delete file | + +Path validation: non-empty, ≤ 4096 chars, no null bytes, no `../`. The `/raw` endpoint sets `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`; HTML/JS/SVG MIME types are forced to attachment. ## Frontend Components @@ -184,8 +324,8 @@ export function FilesPage() { ```typescript const handleUpload = async (file: File) => { const uploadPath = currentPath ? `${currentPath}/${file.name}` : file.name; - const response = await fetch(apiUrl('upload', { path: uploadPath }), { - method: 'POST', + const response = await fetch(apiUrl("upload", { path: uploadPath }), { + method: "POST", body: file, }); if (!response.ok) { @@ -203,7 +343,7 @@ const handleUpload = async (file: File) => { const handleDelete = async (filePath: string) => { const response = await fetch( `/api/files/${volumeKey}?path=${encodeURIComponent(filePath)}`, - { method: 'DELETE' }, + { method: "DELETE" }, ); if (!response.ok) { const data = await response.json().catch(() => ({})); @@ -217,23 +357,28 @@ const handleDelete = async (filePath: string) => { ```typescript const handleCreateDirectory = async (name: string) => { const dirPath = currentPath ? `${currentPath}/${name}` : name; - const response = await fetch(apiUrl('mkdir'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch(apiUrl("mkdir"), { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: dirPath }), }); if (!response.ok) { const data = await response.json().catch(() => ({})); - throw new Error(data.error ?? `Create directory failed (${response.status})`); + throw new Error( + data.error ?? `Create directory failed (${response.status})`, + ); } }; ``` ## Resource Requirements -Each volume key requires a resource with `WRITE_VOLUME` permission. Declare in `databricks.yml`: +The plugin **auto-generates** volume resource requirements from `DATABRICKS_VOLUME_*` env vars — setting them in `app.yaml` is usually all you need. Each discovered volume key becomes a required `WRITE_VOLUME` resource validated at startup. + +Declare the volume explicitly in `databricks.yml` only when you need to pin it as a managed resource, then wire the env var via `valueFrom` in `app.yaml`: ```yaml +# databricks.yml resources: apps: my_app: @@ -248,9 +393,8 @@ resources: > **Note:** The scaffolded HTTP routes (`/api/files/...`) execute as the service principal and do not require `user_api_scopes`. The scope is needed when using the programmatic `appkit.files("key").asUser(req)` API for per-user Volume access. -Wire the env var in `app.yaml`: - ```yaml +# app.yaml env: - name: DATABRICKS_VOLUME_UPLOADS valueFrom: uploads-volume @@ -258,11 +402,13 @@ env: ## Troubleshooting -| Error | Cause | Solution | -| --- | --- | --- | -| `Unknown volume key "X"` | Volume env var not set or misspelled | Check `DATABRICKS_VOLUME_X` is set in `app.yaml` or `.env` | -| 413 on upload | File exceeds `maxUploadSize` | Increase `maxUploadSize` in plugin config or per-volume config | -| `read()` rejects large file | File > 10 MB default limit | Use `download()` for large files or pass `{ maxSize: }` | -| Blocked content type on `/raw` | Dangerous MIME type (html, js, svg) | Use `/download` instead — these types are forced to attachment | -| Service principal access blocked | Called volume method without `.asUser(req)` | Always use `appkit.files("key").asUser(req).method()` | -| `path traversal` error | Path contains `../` | Use relative paths from volume root or absolute `/Volumes/...` paths | +| Error | Cause | Solution | +| ------------------------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `Unknown volume key "X"` | Volume env var not set or misspelled | Check `DATABRICKS_VOLUME_X` is set in `app.yaml` or `.env` | +| 413 on upload | File exceeds `maxUploadSize` | Increase `maxUploadSize` in plugin config or per-volume config | +| `read()` rejects large file | File > 10 MB default limit | Use `download()` for large files or pass `{ maxSize: }` | +| Blocked content type on `/raw` | Dangerous MIME type (html, js, svg) | Use `/download` instead — these types are forced to attachment | +| 403 on HTTP route | Volume's policy denied the action for the requesting user | Inspect `policy` config; user id comes from the `x-forwarded-user` header | +| Writes return 403 unexpectedly | Volume has no `policy` configured → defaults to `publicRead()` which denies writes | Set explicit `policy: files.policy.allowAll()` (or stricter) on volumes that accept writes | +| `PolicyDeniedError` from programmatic call | Volume's policy denied the action — SP identity used if `asUser(req)` was omitted | Call `.asUser(req)` for user-driven calls; gate trusted SP code with `policy.allowAll()` | +| Invalid path error | Path contains `../`, null bytes, or exceeds 4096 chars | Use relative paths from the volume root, or absolute `/Volumes/...` paths | From 2663a7d8fa858e0b7f9115e53b051b38e3e68e2b Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Thu, 7 May 2026 20:28:42 +0200 Subject: [PATCH 2/4] docs(AppKit): trim Files skill to defer reference data to upstream docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align with the jobs.md/lakebase.md style: keep gotchas and idioms in-skill, push encyclopedic reference to `npx @databricks/appkit docs`. - Flatten Access Policies subsections (5 H3s → 0) - Drop Policy inputs types block (duplicate of upstream `## Types`) - Drop standalone SP-bypass snippet (mention inline instead) - Inline Execution defaults table as a single sentence in Server-Side API - Drop full HTTP Routes table; keep the 403/security gotchas - Add `npx @databricks/appkit docs` pointers from each trimmed section (defer-to-docs count: 2 → 5, matching jobs.md) Net effect: 410 → 346 lines (-15%); the policy concepts and HTTP-route execution model stay, the redundant tables go. Co-authored-by: Isaac --- .../references/appkit/files.md | 86 +++---------------- 1 file changed, 11 insertions(+), 75 deletions(-) diff --git a/skills/databricks-apps/references/appkit/files.md b/skills/databricks-apps/references/appkit/files.md index 3e28212..b24317a 100644 --- a/skills/databricks-apps/references/appkit/files.md +++ b/skills/databricks-apps/references/appkit/files.md @@ -90,7 +90,7 @@ files({ }); ``` -### Built-in policies +Built-in policies: | Helper | Allows | | --------------------------- | ------------------------------------------------------------------ | @@ -98,63 +98,25 @@ files({ | `files.policy.allowAll()` | every action | | `files.policy.denyAll()` | no action (yes — even `list`) | -### Combinators +Combinators: `policy.all(...)` (AND, short-circuits on deny), `policy.any(...)` (OR, short-circuits on allow), `policy.not(p)` (e.g. `not(publicRead())` = write-only drop-box). -- `files.policy.all(p1, p2, ...)` — AND, short-circuits on first deny -- `files.policy.any(p1, p2, ...)` — OR, short-circuits on first allow -- `files.policy.not(p)` — invert (e.g. `not(publicRead())` = write-only drop-box) - -### Custom policies - -A `FilePolicy` is `(action, resource, user) => boolean | Promise`. `READ_ACTIONS` and `WRITE_ACTIONS` are exported `Set` for action-class checks. +A `FilePolicy` is `(action, resource, user) => boolean | Promise`. Exported `READ_ACTIONS` / `WRITE_ACTIONS` are `Set` for action-class checks. `user.id` comes from the `x-forwarded-user` header (HTTP) or `req` (`asUser(req)`); `user.isServicePrincipal === true` when the programmatic API skipped `asUser()`. Full `FileAction` / `FileResource` / `FilePolicyUser` shape: `npx @databricks/appkit docs Files plugin`. ```typescript import { type FilePolicy, WRITE_ACTIONS } from "@databricks/appkit"; const ADMIN_IDS = ["admin@company.com"]; +// Writes admin-only; reads open. Wrap with `policy.any(spBypass, adminWrite)` for SP bypass. const adminWrite: FilePolicy = (action, _resource, user) => { if (WRITE_ACTIONS.has(action)) return ADMIN_IDS.includes(user.id); - return true; // reads open to everyone + return true; }; -files({ - volumes: { reports: { policy: adminWrite } }, -}); +files({ volumes: { reports: { policy: adminWrite } } }); ``` -Mix custom logic with built-ins via combinators — e.g. SP can do anything, users can only read: - -```typescript -files.policy.any( - (_action, _resource, user) => !!user.isServicePrincipal, - files.policy.publicRead(), -); -``` - -### Policy inputs - -```typescript -type FileAction = - | "list" | "read" | "download" | "raw" | "exists" | "metadata" | "preview" - | "upload" | "mkdir" | "delete"; - -interface FileResource { - path: string; // relative path within the volume - volume: string; // volume key - size?: number; // content-length in bytes (uploads only) -} - -interface FilePolicyUser { - id: string; // from `x-forwarded-user` header (HTTP) or req - isServicePrincipal?: boolean; // true when programmatic API skipped asUser() -} -``` - -### Enforcement - -- **HTTP routes**: denied → `403 { error: "Policy denied \"{action}\" on volume \"{volume}\"", plugin: "files" }`. -- **Programmatic API**: denied → throws `PolicyDeniedError` (importable from `@databricks/appkit`) with `.action` and `.volumeKey` properties. Both `appkit.files("vol").list()` (SP, `isServicePrincipal: true`) and `appkit.files("vol").asUser(req).list()` (user) are gated. +**Enforcement**: HTTP denial → `403 { error: "Policy denied \"{action}\" on volume \"{volume}\"", plugin: "files" }`. Programmatic denial → throws `PolicyDeniedError` (exported from `@databricks/appkit`, has `.action` / `.volumeKey`). Both SP and `asUser(req)` calls are gated. ## Server-Side API (Programmatic) @@ -173,39 +135,13 @@ await appkit.files.volume("uploads").asUser(req).list(); **Use `.asUser(req)` in route handlers.** Without it the policy sees `isServicePrincipal: true` and a warning is logged — fine for background jobs, wrong for user-driven endpoints where per-user policy decisions matter. Policy denial throws `PolicyDeniedError`. -**`VolumeAPI` methods**: `list`, `read`, `download`, `exists`, `metadata`, `preview`, `upload`, `createDirectory`, `delete`. `read()` caps files at 10 MB by default — pass `{ maxSize: }` or use `download()` for larger files. Paths are absolute (`/Volumes/...`) or relative to the volume root; `../` and null bytes are rejected. - -### Execution defaults - -Every operation runs through cache/retry/timeout interceptors: - -| Tier | Operations | Cache | Retry | Timeout | -| -------- | ------------------------------------- | ----- | ----- | ------- | -| Read | list, read, exists, metadata, preview | 60 s | 3× | 30 s | -| Download | download, raw | — | 3× | 30 s | -| Write | upload, mkdir, delete | — | — | 600 s | - -Cache keys include the volume key (no cross-volume collisions). Write operations auto-invalidate the parent directory's cached `list` entry. +**`VolumeAPI` methods**: `list`, `read`, `download`, `exists`, `metadata`, `preview`, `upload`, `createDirectory`, `delete`. `read()` caps files at 10 MB by default — pass `{ maxSize: }` or use `download()` for larger files. Paths are absolute (`/Volumes/...`) or relative to the volume root; `../` and null bytes are rejected. Reads cache 60 s with 3 retries; writes have a 600 s timeout, no retry, no cache; cache keys include the volume key, and writes auto-invalidate the parent directory's `list` cache. Full method signatures: `npx @databricks/appkit docs Files plugin`. ## HTTP Routes -Mounted at `/api/files/*`. All routes execute as the service principal; user identity is read from the `x-forwarded-user` header and passed to the volume's policy. Policy denial → `403`. - -| Method | Path | Purpose | -| ------ | ---------------------------- | ------------------------------------------ | -| GET | `/volumes` | List configured volume keys | -| GET | `/:volumeKey/list?path=` | Directory listing | -| GET | `/:volumeKey/read?path=` | Read text content | -| GET | `/:volumeKey/download?path=` | Binary stream (attachment) | -| GET | `/:volumeKey/raw?path=` | Inline stream (attachment for unsafe MIME) | -| GET | `/:volumeKey/exists?path=` | Existence check | -| GET | `/:volumeKey/metadata?path=` | File metadata | -| GET | `/:volumeKey/preview?path=` | Preview (text + type flags) | -| POST | `/:volumeKey/upload?path=` | Upload (raw body) | -| POST | `/:volumeKey/mkdir` | Create directory (`body.path`) | -| DELETE | `/:volumeKey?path=` | Delete file | - -Path validation: non-empty, ≤ 4096 chars, no null bytes, no `../`. The `/raw` endpoint sets `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`; HTML/JS/SVG MIME types are forced to attachment. +Mounted at `/api/files/*`. All routes execute as the service principal; user identity is read from the `x-forwarded-user` header and passed to the volume's policy (denial → `403`). Reads are GET (`list`, `read`, `download`, `raw`, `exists`, `metadata`, `preview`); writes are POST (`upload`, `mkdir`) and DELETE — full route shape, request bodies, and response types: `npx @databricks/appkit docs Files plugin`. + +The `/raw` endpoint sets `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`; HTML/JS/SVG MIME types are forced to attachment. ## Frontend Components From e024eafb0a5e4cd5098c67de384dbfe2c7299f82 Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Fri, 22 May 2026 10:04:01 +0200 Subject: [PATCH 3/4] docs(AppKit): align Files skill with OBO behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill described pre-OBO behavior for `asUser(req)` and HTTP routes. Per the current source (appkit v0.36.0, JSDoc on `VolumeHandle.asUser` explicitly flags this as a behavior change): - `asUser(req)` is now a hard override that wraps the call in `runInUserContext`; the SDK call runs as the end user, not just the policy identity. - Per-volume `auth: "service-principal" | "on-behalf-of-user"` controls execution identity for HTTP routes too — they don't always run as SP. Updates: - Permission Model: rewrite layer 2 (execution identity) around the `auth` field; clarify UC-grants behavior on SP vs OBO volumes. - Configuration example: add `auth` per-volume option and resolution order. - Server-Side API: rewrite `asUser(req)` semantics to describe SDK-call override; document production-vs-dev fallback. - HTTP Routes: replace "always run as SP" with the `auth`-based model; describe `/raw` attachment-forcing as an allowlist (images, plain text, CSV, markdown, JSON, PDF), not a denylist of HTML/JS/SVG. - `user_api_scopes` Note: required for OBO volumes too, not only `.asUser(req)` programmatic calls. - Typo fixes: `READ_ACTIONS`/`WRITE_ACTIONS` are `ReadonlySet` not `Set`; troubleshooting `Unknown volume "X"` (was `Unknown volume key "X"`). Co-authored-by: Isaac --- .../references/appkit/files.md | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/skills/databricks-apps/references/appkit/files.md b/skills/databricks-apps/references/appkit/files.md index b24317a..cb9d7a9 100644 --- a/skills/databricks-apps/references/appkit/files.md +++ b/skills/databricks-apps/references/appkit/files.md @@ -57,22 +57,23 @@ files({ customContentTypes: { ".avro": "application/avro" }, volumes: { uploads: { maxUploadSize: 100_000_000 }, // 100 MB override + user_data: { auth: "on-behalf-of-user" }, // SDK calls run as end user exports: {}, // inherits plugin-level }, }); ``` -Auto-discovered volumes merge with explicit config — `volumes: {}` is only needed for overrides. +`auth` resolves per volume: `VolumeConfig.auth` > plugin-level `auth` > `"service-principal"`. Auto-discovered volumes merge with explicit config — `volumes: {}` is only needed for overrides. ## Permission Model Three layers gate file access — understand all three before deploying: -1. **Unity Catalog grants** — the SP needs `WRITE_VOLUME`. Set at deploy time via `app.yaml` resource bindings; the plugin auto-declares the requirement. -2. **Execution identity** — HTTP routes **always** run as the service principal. The programmatic API runs as SP by default; `asUser(req)` re-wraps with the request's user identity. -3. **File policies** — per-volume function `(action, resource, user) → boolean` evaluated **before** every operation. This is the only layer that distinguishes between users on HTTP routes (since HTTP always uses SP credentials). +1. **Unity Catalog grants** — SP volumes need the SP to hold `WRITE_VOLUME`; OBO volumes need the end user to hold it. Set at deploy time via `app.yaml` resource bindings. +2. **Execution identity** — set by the volume's `auth` field. `"service-principal"` (default) runs SDK calls as the SP. `"on-behalf-of-user"` runs them as the request's end user via `runInUserContext`, using the `x-forwarded-access-token` header injected by the Databricks Apps reverse proxy. Programmatic `asUser(req)` is a hard override that forces OBO regardless of the volume's `auth`. +3. **File policies** — per-volume function `(action, resource, user) → boolean` evaluated **before** every operation, on every code path (HTTP and programmatic, SP and OBO). -> Removing a user's UC `WRITE_VOLUME` grant has **no effect on HTTP access** — the SP's grant is what's used. Policies are the only way to restrict per-user access through HTTP routes. +> On SP volumes, UC grants gate the SP — removing a user's UC grant has no effect on HTTP access, so policies are the only per-user gate. On OBO volumes, UC grants gate the end user directly, and policies stack on top. ## Access Policies @@ -100,7 +101,7 @@ Built-in policies: Combinators: `policy.all(...)` (AND, short-circuits on deny), `policy.any(...)` (OR, short-circuits on allow), `policy.not(p)` (e.g. `not(publicRead())` = write-only drop-box). -A `FilePolicy` is `(action, resource, user) => boolean | Promise`. Exported `READ_ACTIONS` / `WRITE_ACTIONS` are `Set` for action-class checks. `user.id` comes from the `x-forwarded-user` header (HTTP) or `req` (`asUser(req)`); `user.isServicePrincipal === true` when the programmatic API skipped `asUser()`. Full `FileAction` / `FileResource` / `FilePolicyUser` shape: `npx @databricks/appkit docs Files plugin`. +A `FilePolicy` is `(action, resource, user) => boolean | Promise`. Exported `READ_ACTIONS` / `WRITE_ACTIONS` are `ReadonlySet` for action-class checks. `user.id` comes from the `x-forwarded-user` header (HTTP) or `req` (`asUser(req)`); `user.isServicePrincipal === true` when the programmatic API skipped `asUser()`. Full `FileAction` / `FileResource` / `FilePolicyUser` shape: `npx @databricks/appkit docs Files plugin`. ```typescript import { type FilePolicy, WRITE_ACTIONS } from "@databricks/appkit"; @@ -120,28 +121,28 @@ files({ volumes: { reports: { policy: adminWrite } } }); ## Server-Side API (Programmatic) -Access volumes through the `files()` callable, which returns a `VolumeHandle`. Every method runs as the service principal — `asUser(req)` only changes the user identity passed into the **policy** check (the underlying SDK call still uses SP credentials). +Access volumes through the `files()` callable, which returns a `VolumeHandle`. Without `asUser(req)`, calls follow the volume's `auth` setting (SP by default). With `asUser(req)`, the call is wrapped in `runInUserContext` — the SDK call runs as the request's end user **and** the policy sees that user, regardless of the volume's `auth`. ```typescript -// User identity passed to policy → user.id from req +// Forced OBO. SDK call runs as user; policy sees user.id from req. await appkit.files("uploads").asUser(req).list(); -// SP identity passed to policy → user.isServicePrincipal === true; logs a warning +// Follows the volume's auth. On SP volumes: runs as SP, policy sees isServicePrincipal: true. await appkit.files("uploads").list(); -// Named accessor equivalent +// Named accessor equivalent. await appkit.files.volume("uploads").asUser(req).list(); ``` -**Use `.asUser(req)` in route handlers.** Without it the policy sees `isServicePrincipal: true` and a warning is logged — fine for background jobs, wrong for user-driven endpoints where per-user policy decisions matter. Policy denial throws `PolicyDeniedError`. +**Use `.asUser(req)` in user-driven route handlers** when you want UC grants enforced against the actual user. In production, `asUser(req)` throws `AuthenticationError.missingToken` if the `x-forwarded-user` header is missing; in dev (`NODE_ENV === "development"`) it logs a warning and falls back to SP. Policy denial throws `PolicyDeniedError`. **`VolumeAPI` methods**: `list`, `read`, `download`, `exists`, `metadata`, `preview`, `upload`, `createDirectory`, `delete`. `read()` caps files at 10 MB by default — pass `{ maxSize: }` or use `download()` for larger files. Paths are absolute (`/Volumes/...`) or relative to the volume root; `../` and null bytes are rejected. Reads cache 60 s with 3 retries; writes have a 600 s timeout, no retry, no cache; cache keys include the volume key, and writes auto-invalidate the parent directory's `list` cache. Full method signatures: `npx @databricks/appkit docs Files plugin`. ## HTTP Routes -Mounted at `/api/files/*`. All routes execute as the service principal; user identity is read from the `x-forwarded-user` header and passed to the volume's policy (denial → `403`). Reads are GET (`list`, `read`, `download`, `raw`, `exists`, `metadata`, `preview`); writes are POST (`upload`, `mkdir`) and DELETE — full route shape, request bodies, and response types: `npx @databricks/appkit docs Files plugin`. +Mounted at `/api/files/*`. Execution identity follows the volume's `auth` field: SP volumes run SDK calls as the service principal; OBO volumes run them as the end user using the token from `x-forwarded-access-token`. User identity (from `x-forwarded-user`) is always passed to the volume's policy (denial → `403`). Reads are GET (`list`, `read`, `download`, `raw`, `exists`, `metadata`, `preview`); writes are POST (`upload`, `mkdir`) and DELETE — full route shape, request bodies, and response types: `npx @databricks/appkit docs Files plugin`. -The `/raw` endpoint sets `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`; HTML/JS/SVG MIME types are forced to attachment. +The `/raw` endpoint sets `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`. Inline streaming uses an allowlist (images, plain text, CSV, markdown, JSON, PDF); anything else is forced to attachment. ## Frontend Components @@ -327,7 +328,7 @@ resources: permission: WRITE_VOLUME ``` -> **Note:** The scaffolded HTTP routes (`/api/files/...`) execute as the service principal and do not require `user_api_scopes`. The scope is needed when using the programmatic `appkit.files("key").asUser(req)` API for per-user Volume access. +> **Note:** `user_api_scopes` is required for OBO volumes (`auth: "on-behalf-of-user"`) and for any `appkit.files("key").asUser(req)` programmatic call. Pure SP volumes accessed only via HTTP routes don't need it. ```yaml # app.yaml @@ -340,7 +341,7 @@ env: | Error | Cause | Solution | | ------------------------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `Unknown volume key "X"` | Volume env var not set or misspelled | Check `DATABRICKS_VOLUME_X` is set in `app.yaml` or `.env` | +| `Unknown volume "X"` | Volume env var not set or misspelled | Check `DATABRICKS_VOLUME_X` is set in `app.yaml` or `.env` | | 413 on upload | File exceeds `maxUploadSize` | Increase `maxUploadSize` in plugin config or per-volume config | | `read()` rejects large file | File > 10 MB default limit | Use `download()` for large files or pass `{ maxSize: }` | | Blocked content type on `/raw` | Dangerous MIME type (html, js, svg) | Use `/download` instead — these types are forced to attachment | From 66a44ffe63f9ddf9fe7c4224655fef9264ec498d Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 3 Jun 2026 11:58:49 +0200 Subject: [PATCH 4/4] docs(AppKit): trim Files skill API duplication --- .../references/appkit/files.md | 90 +++++++------------ 1 file changed, 31 insertions(+), 59 deletions(-) diff --git a/skills/databricks-apps/references/appkit/files.md b/skills/databricks-apps/references/appkit/files.md index cb9d7a9..27ca210 100644 --- a/skills/databricks-apps/references/appkit/files.md +++ b/skills/databricks-apps/references/appkit/files.md @@ -1,6 +1,6 @@ # Files: Unity Catalog Volume Operations -**For full Files plugin API (routes, types, config options)**: run `npx @databricks/appkit docs` → Files plugin. +**For full Files plugin API (routes, types, config options)**: run `npx @databricks/appkit docs ./docs/plugins/files.md`. Use the `files()` plugin when your app needs to **browse, upload, download, or manage files** in Databricks Unity Catalog Volumes. For analytics dashboards reading from a SQL warehouse, use `config/queries/` instead. For persistent CRUD storage, use Lakebase. @@ -47,106 +47,78 @@ await createApp({ }); ``` -### Configuration +### Configuration Overrides -Plugin-level options inherit to every volume; per-volume config overrides them: +Only add plugin config when you need to override defaults from the discovered `DATABRICKS_VOLUME_*` env vars: ```typescript files({ - maxUploadSize: 5_000_000_000, // 5 GB default (all volumes) - customContentTypes: { ".avro": "application/avro" }, + maxUploadSize: 5_000_000_000, // plugin-level default volumes: { - uploads: { maxUploadSize: 100_000_000 }, // 100 MB override - user_data: { auth: "on-behalf-of-user" }, // SDK calls run as end user - exports: {}, // inherits plugin-level + uploads: { + maxUploadSize: 100_000_000, + policy: files.policy.allowAll(), // required for writes + }, + user_data: { + auth: "on-behalf-of-user", // HTTP routes run SDK calls as end user + }, }, }); ``` -`auth` resolves per volume: `VolumeConfig.auth` > plugin-level `auth` > `"service-principal"`. Auto-discovered volumes merge with explicit config — `volumes: {}` is only needed for overrides. +Auto-discovered volumes merge with explicit config, so `volumes: {}` is only needed for overrides. Check the AppKit docs for the current `IFilesConfig` / `VolumeConfig` shape. ## Permission Model -Three layers gate file access — understand all three before deploying: +Three layers gate file access: -1. **Unity Catalog grants** — SP volumes need the SP to hold `WRITE_VOLUME`; OBO volumes need the end user to hold it. Set at deploy time via `app.yaml` resource bindings. -2. **Execution identity** — set by the volume's `auth` field. `"service-principal"` (default) runs SDK calls as the SP. `"on-behalf-of-user"` runs them as the request's end user via `runInUserContext`, using the `x-forwarded-access-token` header injected by the Databricks Apps reverse proxy. Programmatic `asUser(req)` is a hard override that forces OBO regardless of the volume's `auth`. -3. **File policies** — per-volume function `(action, resource, user) → boolean` evaluated **before** every operation, on every code path (HTTP and programmatic, SP and OBO). +1. **Unity Catalog grants** — service-principal volumes need the app SP to hold `WRITE_VOLUME`; OBO volumes need each end user to hold it. +2. **Execution identity** — HTTP routes use the volume's `auth` mode. Programmatic user-driven handlers must call `.asUser(req)` to run SDK calls as the request user. +3. **File policies** — app-level allow/deny functions evaluated before every operation. -> On SP volumes, UC grants gate the SP — removing a user's UC grant has no effect on HTTP access, so policies are the only per-user gate. On OBO volumes, UC grants gate the end user directly, and policies stack on top. +For SP volumes, removing a user's UC grant has no effect on HTTP access because the SDK call uses the SP. Use policies for per-user restrictions. For OBO volumes, UC grants gate the end user and policies stack on top. ## Access Policies -Volumes without an explicit `policy` default to `files.policy.publicRead()` (reads allowed, writes denied) and log a startup warning. Set an explicit policy on every volume that needs writes. +Volumes without an explicit `policy` default to `files.policy.publicRead()` (reads allowed, writes denied) and log a startup warning. Set an explicit policy on every volume that accepts uploads, directory creation, or deletes. ```typescript import { files } from "@databricks/appkit"; files({ volumes: { - public_data: { policy: files.policy.publicRead() }, // reads only - uploads: { policy: files.policy.allowAll() }, // anyone can write - archive: { policy: files.policy.denyAll() }, // locked down + public_data: { policy: files.policy.publicRead() }, + uploads: { policy: files.policy.allowAll() }, + archive: { policy: files.policy.denyAll() }, }, }); ``` -Built-in policies: - -| Helper | Allows | -| --------------------------- | ------------------------------------------------------------------ | -| `files.policy.publicRead()` | `list`, `read`, `download`, `raw`, `exists`, `metadata`, `preview` | -| `files.policy.allowAll()` | every action | -| `files.policy.denyAll()` | no action (yes — even `list`) | - -Combinators: `policy.all(...)` (AND, short-circuits on deny), `policy.any(...)` (OR, short-circuits on allow), `policy.not(p)` (e.g. `not(publicRead())` = write-only drop-box). - -A `FilePolicy` is `(action, resource, user) => boolean | Promise`. Exported `READ_ACTIONS` / `WRITE_ACTIONS` are `ReadonlySet` for action-class checks. `user.id` comes from the `x-forwarded-user` header (HTTP) or `req` (`asUser(req)`); `user.isServicePrincipal === true` when the programmatic API skipped `asUser()`. Full `FileAction` / `FileResource` / `FilePolicyUser` shape: `npx @databricks/appkit docs Files plugin`. - -```typescript -import { type FilePolicy, WRITE_ACTIONS } from "@databricks/appkit"; - -const ADMIN_IDS = ["admin@company.com"]; - -// Writes admin-only; reads open. Wrap with `policy.any(spBypass, adminWrite)` for SP bypass. -const adminWrite: FilePolicy = (action, _resource, user) => { - if (WRITE_ACTIONS.has(action)) return ADMIN_IDS.includes(user.id); - return true; -}; - -files({ volumes: { reports: { policy: adminWrite } } }); -``` - -**Enforcement**: HTTP denial → `403 { error: "Policy denied \"{action}\" on volume \"{volume}\"", plugin: "files" }`. Programmatic denial → throws `PolicyDeniedError` (exported from `@databricks/appkit`, has `.action` / `.volumeKey`). Both SP and `asUser(req)` calls are gated. +Use custom policies when access depends on the requesting user or action. For exact built-ins, combinators, `FileAction`, `FileResource`, `FilePolicyUser`, and `PolicyDeniedError` behavior, check `npx @databricks/appkit docs ./docs/plugins/files.md`. ## Server-Side API (Programmatic) -Access volumes through the `files()` callable, which returns a `VolumeHandle`. Without `asUser(req)`, calls follow the volume's `auth` setting (SP by default). With `asUser(req)`, the call is wrapped in `runInUserContext` — the SDK call runs as the request's end user **and** the policy sees that user, regardless of the volume's `auth`. +Access volumes through the `files()` callable, which returns a `VolumeHandle`. Direct programmatic calls do not have request headers available, so they normally run as the service principal. Use `.asUser(req)` in user-driven route handlers when the SDK call must run as the request user. ```typescript -// Forced OBO. SDK call runs as user; policy sees user.id from req. +// User-driven handler: SDK call runs as user; policy sees user.id from req. await appkit.files("uploads").asUser(req).list(); -// Follows the volume's auth. On SP volumes: runs as SP, policy sees isServicePrincipal: true. +// Background or trusted server code: runs as SP. await appkit.files("uploads").list(); - -// Named accessor equivalent. -await appkit.files.volume("uploads").asUser(req).list(); ``` -**Use `.asUser(req)` in user-driven route handlers** when you want UC grants enforced against the actual user. In production, `asUser(req)` throws `AuthenticationError.missingToken` if the `x-forwarded-user` header is missing; in dev (`NODE_ENV === "development"`) it logs a warning and falls back to SP. Policy denial throws `PolicyDeniedError`. +**Use `.asUser(req)` in user-driven route handlers** when you want UC grants enforced against the actual user. In production, `asUser(req)` throws `AuthenticationError.missingToken` if the forwarded user or access-token header is missing; in dev (`NODE_ENV === "development"`) it logs a warning and falls back to SP. Policy denial throws `PolicyDeniedError`. -**`VolumeAPI` methods**: `list`, `read`, `download`, `exists`, `metadata`, `preview`, `upload`, `createDirectory`, `delete`. `read()` caps files at 10 MB by default — pass `{ maxSize: }` or use `download()` for larger files. Paths are absolute (`/Volumes/...`) or relative to the volume root; `../` and null bytes are rejected. Reads cache 60 s with 3 retries; writes have a 600 s timeout, no retry, no cache; cache keys include the volume key, and writes auto-invalidate the parent directory's `list` cache. Full method signatures: `npx @databricks/appkit docs Files plugin`. +For method signatures, path rules, cache behavior, and retry/timeout defaults, check `npx @databricks/appkit docs ./docs/plugins/files.md`. ## HTTP Routes -Mounted at `/api/files/*`. Execution identity follows the volume's `auth` field: SP volumes run SDK calls as the service principal; OBO volumes run them as the end user using the token from `x-forwarded-access-token`. User identity (from `x-forwarded-user`) is always passed to the volume's policy (denial → `403`). Reads are GET (`list`, `read`, `download`, `raw`, `exists`, `metadata`, `preview`); writes are POST (`upload`, `mkdir`) and DELETE — full route shape, request bodies, and response types: `npx @databricks/appkit docs Files plugin`. - -The `/raw` endpoint sets `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`. Inline streaming uses an allowlist (images, plain text, CSV, markdown, JSON, PDF); anything else is forced to attachment. +Mounted at `/api/files/*`. Routes use the volume's `auth` mode and run the policy before the operation. Use the AppKit docs for the exact route list, request bodies, response types, and `/raw` content-security behavior. ## Frontend Components -Import file browser components from `@databricks/appkit-ui/react`. Full component props: `npx @databricks/appkit docs "FileBreadcrumb"`. +Import file browser components from `@databricks/appkit-ui/react`. Full component props: `npx @databricks/appkit docs ./docs/api/appkit-ui/files/DirectoryList.md` and the related component pages. ### File Browser Example @@ -310,7 +282,7 @@ const handleCreateDirectory = async (name: string) => { ## Resource Requirements -The plugin **auto-generates** volume resource requirements from `DATABRICKS_VOLUME_*` env vars — setting them in `app.yaml` is usually all you need. Each discovered volume key becomes a required `WRITE_VOLUME` resource validated at startup. +The plugin auto-generates volume resource requirements from `DATABRICKS_VOLUME_*` env vars. Setting them in `app.yaml` is usually all you need. Declare the volume explicitly in `databricks.yml` only when you need to pin it as a managed resource, then wire the env var via `valueFrom` in `app.yaml`: @@ -328,7 +300,7 @@ resources: permission: WRITE_VOLUME ``` -> **Note:** `user_api_scopes` is required for OBO volumes (`auth: "on-behalf-of-user"`) and for any `appkit.files("key").asUser(req)` programmatic call. Pure SP volumes accessed only via HTTP routes don't need it. +> **Note:** `user_api_scopes` is required for OBO volumes (`auth: "on-behalf-of-user"`) and for any `appkit.files("key").asUser(req)` programmatic call. Pure SP volumes accessed only via HTTP routes don't need it. The plugin docs have the latest resource-requirement behavior. ```yaml # app.yaml