diff --git a/apps/dev-playground/app.yaml b/apps/dev-playground/app.yaml index 85d20f65..e58e71a3 100644 --- a/apps/dev-playground/app.yaml +++ b/apps/dev-playground/app.yaml @@ -1,9 +1,29 @@ env: - name: DATABRICKS_WAREHOUSE_ID valueFrom: sql-warehouse - - name: DATABRICKS_VOLUME_PLAYGROUND + - name: DATABRICKS_GENIE_SPACE_ID + valueFrom: genie-space + - name: DATABRICKS_SERVING_ENDPOINT_NAME + valueFrom: serving-endpoint + # Files plugin manifest declares a static DATABRICKS_VOLUME_FILES + # requirement; keep it bound so appkit's runtime validation passes + # even though the policy harness below uses its own keys. + - name: DATABRICKS_VOLUME_FILES + valueFrom: volume + # Policy test harness: seven logical volumes, all bound to the same + # underlying UC volume. Policy enforcement runs in-process, so the + # shared physical path is fine. + - name: DATABRICKS_VOLUME_ALLOW_ALL + valueFrom: volume + - name: DATABRICKS_VOLUME_PUBLIC_READ + valueFrom: volume + - name: DATABRICKS_VOLUME_DENY_ALL + valueFrom: volume + - name: DATABRICKS_VOLUME_SP_ONLY + valueFrom: volume + - name: DATABRICKS_VOLUME_ADMIN_ONLY + valueFrom: volume + - name: DATABRICKS_VOLUME_WRITE_ONLY + valueFrom: volume + - name: DATABRICKS_VOLUME_IMPLICIT valueFrom: volume - - name: DATABRICKS_VOLUME_OTHER - valueFrom: other-volume - - name: DATABRICKS_VS_INDEX_NAME - valueFrom: vs-index diff --git a/apps/dev-playground/client/src/routeTree.gen.ts b/apps/dev-playground/client/src/routeTree.gen.ts index 99ac75fc..a4669cbd 100644 --- a/apps/dev-playground/client/src/routeTree.gen.ts +++ b/apps/dev-playground/client/src/routeTree.gen.ts @@ -9,11 +9,13 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as VectorSearchRouteRouteImport } from './routes/vector-search.route' import { Route as TypeSafetyRouteRouteImport } from './routes/type-safety.route' import { Route as TelemetryRouteRouteImport } from './routes/telemetry.route' import { Route as SqlHelpersRouteRouteImport } from './routes/sql-helpers.route' import { Route as ServingRouteRouteImport } from './routes/serving.route' import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route' +import { Route as PolicyMatrixRouteRouteImport } from './routes/policy-matrix.route' import { Route as LakebaseRouteRouteImport } from './routes/lakebase.route' import { Route as GenieRouteRouteImport } from './routes/genie.route' import { Route as FilesRouteRouteImport } from './routes/files.route' @@ -23,6 +25,11 @@ import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytic import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route' import { Route as IndexRouteImport } from './routes/index' +const VectorSearchRouteRoute = VectorSearchRouteRouteImport.update({ + id: '/vector-search', + path: '/vector-search', + getParentRoute: () => rootRouteImport, +} as any) const TypeSafetyRouteRoute = TypeSafetyRouteRouteImport.update({ id: '/type-safety', path: '/type-safety', @@ -48,6 +55,11 @@ const ReconnectRouteRoute = ReconnectRouteRouteImport.update({ path: '/reconnect', getParentRoute: () => rootRouteImport, } as any) +const PolicyMatrixRouteRoute = PolicyMatrixRouteRouteImport.update({ + id: '/policy-matrix', + path: '/policy-matrix', + getParentRoute: () => rootRouteImport, +} as any) const LakebaseRouteRoute = LakebaseRouteRouteImport.update({ id: '/lakebase', path: '/lakebase', @@ -98,11 +110,13 @@ export interface FileRoutesByFullPath { '/files': typeof FilesRouteRoute '/genie': typeof GenieRouteRoute '/lakebase': typeof LakebaseRouteRoute + '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute + '/vector-search': typeof VectorSearchRouteRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -113,11 +127,13 @@ export interface FileRoutesByTo { '/files': typeof FilesRouteRoute '/genie': typeof GenieRouteRoute '/lakebase': typeof LakebaseRouteRoute + '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute + '/vector-search': typeof VectorSearchRouteRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -129,11 +145,13 @@ export interface FileRoutesById { '/files': typeof FilesRouteRoute '/genie': typeof GenieRouteRoute '/lakebase': typeof LakebaseRouteRoute + '/policy-matrix': typeof PolicyMatrixRouteRoute '/reconnect': typeof ReconnectRouteRoute '/serving': typeof ServingRouteRoute '/sql-helpers': typeof SqlHelpersRouteRoute '/telemetry': typeof TelemetryRouteRoute '/type-safety': typeof TypeSafetyRouteRoute + '/vector-search': typeof VectorSearchRouteRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -146,11 +164,13 @@ export interface FileRouteTypes { | '/files' | '/genie' | '/lakebase' + | '/policy-matrix' | '/reconnect' | '/serving' | '/sql-helpers' | '/telemetry' | '/type-safety' + | '/vector-search' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -161,11 +181,13 @@ export interface FileRouteTypes { | '/files' | '/genie' | '/lakebase' + | '/policy-matrix' | '/reconnect' | '/serving' | '/sql-helpers' | '/telemetry' | '/type-safety' + | '/vector-search' id: | '__root__' | '/' @@ -176,11 +198,13 @@ export interface FileRouteTypes { | '/files' | '/genie' | '/lakebase' + | '/policy-matrix' | '/reconnect' | '/serving' | '/sql-helpers' | '/telemetry' | '/type-safety' + | '/vector-search' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -192,15 +216,24 @@ export interface RootRouteChildren { FilesRouteRoute: typeof FilesRouteRoute GenieRouteRoute: typeof GenieRouteRoute LakebaseRouteRoute: typeof LakebaseRouteRoute + PolicyMatrixRouteRoute: typeof PolicyMatrixRouteRoute ReconnectRouteRoute: typeof ReconnectRouteRoute ServingRouteRoute: typeof ServingRouteRoute SqlHelpersRouteRoute: typeof SqlHelpersRouteRoute TelemetryRouteRoute: typeof TelemetryRouteRoute TypeSafetyRouteRoute: typeof TypeSafetyRouteRoute + VectorSearchRouteRoute: typeof VectorSearchRouteRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/vector-search': { + id: '/vector-search' + path: '/vector-search' + fullPath: '/vector-search' + preLoaderRoute: typeof VectorSearchRouteRouteImport + parentRoute: typeof rootRouteImport + } '/type-safety': { id: '/type-safety' path: '/type-safety' @@ -236,6 +269,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ReconnectRouteRouteImport parentRoute: typeof rootRouteImport } + '/policy-matrix': { + id: '/policy-matrix' + path: '/policy-matrix' + fullPath: '/policy-matrix' + preLoaderRoute: typeof PolicyMatrixRouteRouteImport + parentRoute: typeof rootRouteImport + } '/lakebase': { id: '/lakebase' path: '/lakebase' @@ -304,11 +344,13 @@ const rootRouteChildren: RootRouteChildren = { FilesRouteRoute: FilesRouteRoute, GenieRouteRoute: GenieRouteRoute, LakebaseRouteRoute: LakebaseRouteRoute, + PolicyMatrixRouteRoute: PolicyMatrixRouteRoute, ReconnectRouteRoute: ReconnectRouteRoute, ServingRouteRoute: ServingRouteRoute, SqlHelpersRouteRoute: SqlHelpersRouteRoute, TelemetryRouteRoute: TelemetryRouteRoute, TypeSafetyRouteRoute: TypeSafetyRouteRoute, + VectorSearchRouteRoute: VectorSearchRouteRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/dev-playground/client/src/routes/__root.tsx b/apps/dev-playground/client/src/routes/__root.tsx index bc7f1e34..4f30f234 100644 --- a/apps/dev-playground/client/src/routes/__root.tsx +++ b/apps/dev-playground/client/src/routes/__root.tsx @@ -104,6 +104,14 @@ function RootComponent() { Files + + + + + + + + {volumes.length === 0 ? ( +

+ No volumes configured. Set DATABRICKS_VOLUME_* env vars + and restart the server. +

+ ) : ( +
+ + + + + {ACTIONS.map((a) => ( + + ))} + + + + {volumes.map((volume) => ( + + + {ACTIONS.map((a) => ( + + ))} + + ))} + +
+ Volume + +
+ {a} + + {WRITE_ACTIONS.has(a) ? "write" : "read"} + +
+
+ {volume} + + runProbe(volume, a)} + /> +
+
+ )} + +
+

+ Programmatic API smoke tests +

+

+ Confirms PolicyDeniedError is thrown from the SDK path + (not just HTTP 403). These hit the dev-playground's{" "} + /policy/sp and /policy/obo routes. +

+
+ + +
+
+ + +
+
+ + + ); +} + +function Cell({ + outcome, + onRun, +}: { + outcome: ProbeOutcome; + onRun: () => void; +}) { + if (outcome.status === "idle") { + return ( + + ); + } + if (outcome.status === "loading") { + return ( + + ); + } + return ( + + ); +} + +function StatusBadge({ + httpStatus, + verdict, + message, +}: { + httpStatus: number; + verdict: PolicyVerdict; + message: string; +}) { + switch (verdict) { + case "allowed": + return {httpStatus} allowed; + case "allowed-missing": + return ( + + 404 allowed* + + ); + case "denied": + return ( + + 403 denied + + ); + case "error": + return ( + + {httpStatus || "err"} + + ); + } +} + +function Legend() { + return ( +
+ + 2xx allowed + policy passed, op succeeded + + + 404 allowed* + policy passed, probe file missing + + + 403 denied + policy rejection + + + err + other failure + +
+ ); +} + +function WhoAmI({ whoami }: { whoami: WhoAmI | null }) { + if (!whoami) return null; + return ( +
+
+ x-forwarded-user:{" "} + {whoami.xForwardedUser ?? "(none)"} +
+
+ ADMIN_USER_ID:{" "} + {whoami.adminUserId ?? "(unset)"} + {whoami.isAdmin && ( + + admin + + )} +
+
+ ); +} + +function SmokePanel({ title, body }: { title: string; body: string | null }) { + return ( +
+
{title}
+
+        {body ?? "(not run)"}
+      
+
+ ); +} + +/** + * Maps a logical action to the concrete HTTP request the files plugin + * exposes. Keeps this table in one place so the matrix stays honest + * about what it's actually testing. + */ +function probe(volume: string, action: Action): Promise { + const base = `/api/files/${volume}`; + const qs = (params: Record) => + new URLSearchParams(params).toString(); + + switch (action) { + case "list": + return fetch(`${base}/list`); + case "read": + return fetch(`${base}/read?${qs({ path: PROBE_PATH })}`); + case "download": + return fetch(`${base}/download?${qs({ path: PROBE_PATH })}`); + case "raw": + return fetch(`${base}/raw?${qs({ path: PROBE_PATH })}`); + case "exists": + return fetch(`${base}/exists?${qs({ path: PROBE_PATH })}`); + case "metadata": + return fetch(`${base}/metadata?${qs({ path: PROBE_PATH })}`); + case "preview": + return fetch(`${base}/preview?${qs({ path: PROBE_PATH })}`); + case "upload": + return fetch(`${base}/upload?${qs({ path: PROBE_PATH })}`, { + method: "POST", + body: new Blob(["policy probe"], { type: "text/plain" }), + }); + case "mkdir": + return fetch(`${base}/mkdir`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: PROBE_DIR }), + }); + case "delete": + return fetch(`${base}?${qs({ path: PROBE_PATH })}`, { + method: "DELETE", + }); + } +} diff --git a/apps/dev-playground/server/index.ts b/apps/dev-playground/server/index.ts index 2031c33a..8a77b76c 100644 --- a/apps/dev-playground/server/index.ts +++ b/apps/dev-playground/server/index.ts @@ -2,10 +2,13 @@ import "reflect-metadata"; import { analytics, createApp, + type FilePolicy, files, genie, + PolicyDeniedError, server, serving, + WRITE_ACTIONS, } from "@databricks/appkit"; import { WorkspaceClient } from "@databricks/sdk-experimental"; // TODO: re-enable once vector-search is exported from @databricks/appkit @@ -24,6 +27,27 @@ function createMockClient() { return client; } +/** + * Policy test harness. + * + * Each volume key below is backed by a `DATABRICKS_VOLUME_*` env var in + * `app.yaml` — all seven point at the same underlying UC volume path. + * The different policies are evaluated in-process, so the shared path + * is fine; the logical volume key is what drives enforcement. + * + * Exercises every policy shape the plugin ships with, plus the new + * "no policy configured" default (v0.21.0+). + */ +const ADMIN_USER_ID = process.env.ADMIN_USER_ID ?? ""; + +/** Writes allowed only for the configured admin user ID; reads open. */ +const adminOnly: FilePolicy = (action, _resource, user) => { + if (WRITE_ACTIONS.has(action)) { + return ADMIN_USER_ID !== "" && user.id === ADMIN_USER_ID; + } + return true; +}; + createApp({ plugins: [ server({ autoStart: false }), @@ -34,7 +58,29 @@ createApp({ spaces: { demo: process.env.DATABRICKS_GENIE_SPACE_ID ?? "placeholder" }, }), lakebaseExamples(), - files({ volumes: { default: { policy: files.policy.allowAll() } } }), + files({ + volumes: { + // baseline: everything allowed + allow_all: { policy: files.policy.allowAll() }, + // read-only: uploads/mkdir/delete return 403 + public_read: { policy: files.policy.publicRead() }, + // locked: every action returns 403 (yes, even list) + deny_all: { policy: files.policy.denyAll() }, + // SP can do everything, users can only read (docs example) + sp_only: { + policy: files.policy.any( + (_action, _resource, user) => !!user.isServicePrincipal, + files.policy.publicRead(), + ), + }, + // writes gated on ADMIN_USER_ID env var, reads open + admin_only: { policy: adminOnly }, + // drop-box: writes only, reads denied (not(publicRead)) + write_only: { policy: files.policy.not(files.policy.publicRead()) }, + // no explicit policy → falls back to publicRead() + startup warning + implicit: {}, + }, + }), serving(), // TODO: re-enable once vector-search is exported from @databricks/appkit // vectorSearch({ @@ -86,6 +132,99 @@ createApp({ }); }); }); + + /** + * Echoes the user identity the server sees. Useful for confirming + * that `x-forwarded-user` is forwarded in the deployed environment. + */ + app.get("/whoami", (req, res) => { + res.json({ + xForwardedUser: req.header("x-forwarded-user") ?? null, + adminUserId: ADMIN_USER_ID || null, + isAdmin: + ADMIN_USER_ID !== "" && + req.header("x-forwarded-user") === ADMIN_USER_ID, + }); + }); + + /** + * Programmatic API smoke test — service principal path. + * + * All probes are read-only and deny-oriented, so nothing is + * written to the UC volume. Expected results: + * - `allow_all.list` → ok (real SDK call) + * - `deny_all.list` → PolicyDeniedError (deny wins even for SP) + * - `write_only.list` → PolicyDeniedError (reads denied) + * + * Confirms `isServicePrincipal: true` is set on the SP path. + */ + app.get("/policy/sp", async (_req, res) => { + const results = await runProbes([ + ["allow_all", "list", () => appkit.files("allow_all").list()], + ["deny_all", "list", () => appkit.files("deny_all").list()], + ["write_only", "list", () => appkit.files("write_only").list()], + ]); + res.json({ identity: "service_principal", results }); + }); + + /** + * Programmatic API smoke test — OBO (on-behalf-of user) path. + * + * All probes are read-only; no files are written. Expected: + * - `public_read.list` → ok (reads open) + * - `deny_all.list` → PolicyDeniedError + * - `sp_only.list` → ok (publicRead arm of `any()` allows reads) + */ + app.get("/policy/obo", async (req, res) => { + const results = await runProbes([ + [ + "public_read", + "list", + () => appkit.files("public_read").asUser(req).list(), + ], + [ + "deny_all", + "list", + () => appkit.files("deny_all").asUser(req).list(), + ], + ["sp_only", "list", () => appkit.files("sp_only").asUser(req).list()], + ]); + res.json({ + identity: "user", + xForwardedUser: req.header("x-forwarded-user") ?? null, + results, + }); + }); }) .start(); }); + +type ProbeResult = { + volume: string; + action: string; + ok: boolean; + denied: boolean; + error?: string; +}; + +async function runProbes( + probes: Array<[string, string, () => Promise]>, +): Promise { + const out: ProbeResult[] = []; + for (const [volume, action, fn] of probes) { + try { + await fn(); + out.push({ volume, action, ok: true, denied: false }); + } catch (error) { + const denied = error instanceof PolicyDeniedError; + out.push({ + volume, + action, + ok: false, + denied, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return out; +}