Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 9 additions & 2 deletions apps/dev-playground/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import "reflect-metadata";
import { analytics, createApp, files, genie, server } from "@databricks/appkit";
import {
analytics,
createApp,
files,
genie,
policy,
server,
} from "@databricks/appkit";
import { WorkspaceClient } from "@databricks/sdk-experimental";
import { lakebaseExamples } from "./lakebase-examples-plugin";
import { reconnect } from "./reconnect-plugin";
Expand All @@ -25,7 +32,7 @@ createApp({
spaces: { demo: process.env.DATABRICKS_GENIE_SPACE_ID ?? "placeholder" },
}),
lakebaseExamples(),
files(),
files({ volumes: { default: { policy: policy.allowAll() } } }),
],
...(process.env.APPKIT_E2E_TEST && { client: createMockClient() }),
}).then((appkit) => {
Expand Down
119 changes: 119 additions & 0 deletions docs/docs/api/appkit/Variable.policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Variable: policy

```ts
const policy: {
all: FilePolicy;
allowAll: FilePolicy;
any: FilePolicy;
denyAll: FilePolicy;
not: FilePolicy;
publicRead: FilePolicy;
publicReadAndList: FilePolicy;
};
```

Utility namespace with common policy combinators.

## Type Declaration

### all()

```ts
readonly all(...policies: FilePolicy[]): FilePolicy;
```

AND — all policies must allow. Short-circuits on first denial.

#### Parameters

| Parameter | Type |
| ------ | ------ |
| ...`policies` | `FilePolicy`[] |

#### Returns

`FilePolicy`

### allowAll()

```ts
readonly allowAll(): FilePolicy;
```

Allow every action.

#### Returns

`FilePolicy`

### any()

```ts
readonly any(...policies: FilePolicy[]): FilePolicy;
```

OR — at least one policy must allow. Short-circuits on first allow.

#### Parameters

| Parameter | Type |
| ------ | ------ |
| ...`policies` | `FilePolicy`[] |

#### Returns

`FilePolicy`

### denyAll()

```ts
readonly denyAll(): FilePolicy;
```

Deny every action.

#### Returns

`FilePolicy`

### not()

```ts
readonly not(p: FilePolicy): FilePolicy;
```

Negates a policy.

#### Parameters

| Parameter | Type |
| ------ | ------ |
| `p` | `FilePolicy` |

#### Returns

`FilePolicy`

### publicRead()

```ts
readonly publicRead(): FilePolicy;
```

Allow all read actions (list, read, download, raw, exists, metadata, preview).

#### Returns

`FilePolicy`

### publicReadAndList()

```ts
readonly publicReadAndList(): FilePolicy;
```

Alias for `publicRead()` — included for discoverability.

#### Returns

`FilePolicy`
1 change: 1 addition & 0 deletions docs/docs/api/appkit/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ plugin architecture, and React integration.

| Variable | Description |
| ------ | ------ |
| [policy](Variable.policy.md) | Utility namespace with common policy combinators. |
| [sql](Variable.sql.md) | SQL helper namespace |

## Functions
Expand Down
5 changes: 5 additions & 0 deletions docs/docs/api/appkit/typedoc-sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ const typedocSidebar: SidebarsConfig = {
type: "category",
label: "Variables",
items: [
{
type: "doc",
id: "api/appkit/Variable.policy",
label: "policy"
},
{
type: "doc",
id: "api/appkit/Variable.sql",
Expand Down
142 changes: 142 additions & 0 deletions docs/docs/plugins/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ File operations against Databricks Unity Catalog Volumes. Supports listing, read
- Automatic cache invalidation on write operations
- Custom content type mappings
- Per-user execution context (OBO)
- **Access policies**: Per-volume policy functions that gate read and write operations

## Basic usage

Expand Down Expand Up @@ -75,6 +76,8 @@ interface IFilesConfig {
}

interface VolumeConfig {
/** Access policy for this volume. */
policy?: FilePolicy;
/** Maximum upload size in bytes for this volume. Overrides plugin-level default. */
maxUploadSize?: number;
/** Map of file extensions to MIME types for this volume. Overrides plugin-level default. */
Expand All @@ -97,6 +100,115 @@ files({
});
```

### Permission model

There are three layers of access control in the files plugin. Understanding how they interact is critical for securing your app:

```
┌─────────────────────────────────────────────────┐
│ Unity Catalog grants │
│ (WRITE_VOLUME on the SP — set at deploy time) │
├─────────────────────────────────────────────────┤
│ Execution identity │
│ HTTP routes → always service principal │
│ Programmatic → SP by default, asUser() for OBO │
├─────────────────────────────────────────────────┤
│ File policies │
│ Per-volume (action, resource, user) → boolean │
│ Only app-level gate for HTTP routes │
└─────────────────────────────────────────────────┘
```

- **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.

:::warning

Since HTTP routes always execute as the service principal, removing a user's UC `WRITE_VOLUME` grant has **no effect** on HTTP access — the SP's grant is what's used. Policies are how you restrict what individual users can do through your app.

:::

#### Access policies

Attach a policy to a volume to control which actions are allowed:

```ts
import { files, policy } from "@databricks/appkit";

files({
volumes: {
uploads: { policy: policy.publicRead() },
},
});
```

#### Actions

Policies receive an action string. The full list, split by category:

| Category | Actions |
|----------|---------|
| Read | `list`, `read`, `download`, `raw`, `exists`, `metadata`, `preview` |
| Write | `upload`, `mkdir`, `delete` |

#### Built-in policies

| Helper | Allows | Denies |
|--------|--------|--------|
| `policy.publicRead()` | all read actions | all write actions |
| `policy.allowAll()` | everything | nothing |
| `policy.denyAll()` | nothing | everything |

#### Composing policies

Combine built-in and custom policies with three combinators:

- **`policy.all(a, b)`** — AND: all policies must allow. Short-circuits on first denial.
- **`policy.any(a, b)`** — OR: at least one policy must allow. Short-circuits on first allow.
- **`policy.not(p)`** — Inverts a policy. For example, `not(publicRead())` yields a write-only policy (useful for ingestion/drop-box volumes).

```ts
// Read-only for regular users, full access for the service principal
files({
volumes: {
shared: {
policy: policy.any(
(_action, _resource, user) => !!user.isServicePrincipal,
policy.publicRead(),
),
},
},
});
```

#### Custom policies

`FilePolicy` is a function `(action, resource, user) → boolean | Promise<boolean>`, so you can inline arbitrary logic:

```ts
import { type FilePolicy, WRITE_ACTIONS } from "@databricks/appkit";

const ADMIN_IDS = ["admin-sp-id", "lead-user-id"];

const adminOnly: FilePolicy = (action, _resource, user) => {
if (WRITE_ACTIONS.has(action)) {
return ADMIN_IDS.includes(user.id);
}
return true; // reads allowed for everyone
};

files({
volumes: { reports: { policy: adminOnly } },
});
```

#### Enforcement

- **HTTP routes**: Policy checked before every operation. Denied → `403` JSON response with `"Action denied by volume policy"`.
- **Programmatic API**: Policy checked on both `appkit.files("vol").list()` (SP identity, `isServicePrincipal: true`) and `appkit.files("vol").asUser(req).list()` (user identity). Denied → throws `PolicyDeniedError`.
- **No policy configured**: Defaults to `publicRead()` — read actions are allowed, write actions are denied. A startup warning is logged encouraging you to set an explicit policy.

### Custom content types

Override or extend the built-in extension → MIME map:
Expand Down Expand Up @@ -236,7 +348,36 @@ interface FilePreview extends FileMetadata {
isImage: boolean;
}

type FileAction =
| "list" | "read" | "download" | "raw"
| "exists" | "metadata" | "preview"
| "upload" | "mkdir" | "delete";

interface FileResource {
/** Relative path within the volume. */
path: string;
/** The volume key (e.g. `"uploads"`). */
volume: string;
/** Content length in bytes — only present for uploads. */
size?: number;
}

interface FilePolicyUser {
/** User ID from the `x-forwarded-user` header. */
id: string;
/** `true` when the caller is the service principal (direct SDK call, not `asUser`). */
isServicePrincipal?: boolean;
}

type FilePolicy = (
action: FileAction,
resource: FileResource,
user: FilePolicyUser,
) => boolean | Promise<boolean>;

interface VolumeConfig {
/** Access policy for this volume. */
policy?: FilePolicy;
/** Maximum upload size in bytes for this volume. */
maxUploadSize?: number;
/** Map of file extensions to MIME types for this volume. */
Expand Down Expand Up @@ -297,6 +438,7 @@ All errors return JSON:
| Status | Description |
| ------ | -------------------------------------------------------------- |
| 400 | Missing or invalid `path` parameter |
| 403 | Policy denied "`{action}`" on volume "`{volumeKey}`" |
| 404 | Unknown volume key |
| 413 | Upload exceeds `maxUploadSize` |
| 500 | Operation failed (SDK, network, upstream, or unhandled error) |
Expand Down
5 changes: 4 additions & 1 deletion docs/static/appkit-ui/styles.gen.css
Original file line number Diff line number Diff line change
Expand Up @@ -5578,7 +5578,10 @@
}
::selection {
background-color: var(--primary);
opacity: 0.2;
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklch, var(--primary) 30%, transparent);
}
color: var(--primary-foreground);
}
@media (prefers-reduced-motion: reduce) {
*,
Expand Down
8 changes: 0 additions & 8 deletions packages/appkit/src/context/execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,3 @@ export function getWarehouseId(): Promise<string> {
export function getWorkspaceId(): Promise<string> {
return getExecutionContext().workspaceId;
}

/**
* Check if currently running in a user context.
*/
export function isInUserContext(): boolean {
const ctx = executionContextStorage.getStore();
return ctx !== undefined;
}
1 change: 0 additions & 1 deletion packages/appkit/src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export {
getWarehouseId,
getWorkspaceClient,
getWorkspaceId,
isInUserContext,
runInUserContext,
} from "./execution-context";
export { ServiceContext } from "./service-context";
Expand Down
12 changes: 6 additions & 6 deletions packages/appkit/src/context/user-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ export interface UserContext {
}

/**
* Execution context can be either service or user context.
*/
export type ExecutionContext = ServiceContextState | UserContext;

/**
* Check if an execution context is a user context.
* Type guard to check if a context is a UserContext.
*/
export function isUserContext(ctx: ExecutionContext): ctx is UserContext {
return "isUserContext" in ctx && ctx.isUserContext === true;
}

/**
* Execution context can be either service or user context.
*/
export type ExecutionContext = ServiceContextState | UserContext;
Loading
Loading