@databricks/appkit 0.35.0 → 0.35.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/appkit/package.js +1 -1
- package/dist/connectors/files/client.js +28 -1
- package/dist/connectors/files/client.js.map +1 -1
- package/dist/connectors/files/index.js +1 -1
- package/dist/connectors/index.js +1 -1
- package/dist/plugins/files/plugin.d.ts +216 -20
- package/dist/plugins/files/plugin.d.ts.map +1 -1
- package/dist/plugins/files/plugin.js +618 -191
- package/dist/plugins/files/plugin.js.map +1 -1
- package/dist/plugins/files/policy.d.ts +30 -1
- package/dist/plugins/files/policy.d.ts.map +1 -1
- package/dist/plugins/files/policy.js.map +1 -1
- package/dist/plugins/files/types.d.ts +136 -5
- package/dist/plugins/files/types.d.ts.map +1 -1
- package/docs/api/appkit/Interface.FilePolicyUser.md +15 -1
- package/docs/development/llm-guide.md +0 -1
- package/docs/plugins/files.md +199 -19
- package/package.json +1 -1
- package/sbom.cdx.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createLogger } from "../../logging/logger.js";
|
|
2
2
|
import { AuthenticationError } from "../../errors/authentication.js";
|
|
3
3
|
import { init_errors } from "../../errors/index.js";
|
|
4
|
+
import { ServiceContext } from "../../context/service-context.js";
|
|
4
5
|
import { init_user_context, isUserContext } from "../../context/user-context.js";
|
|
5
|
-
import { getCurrentUserId, getExecutionContext, getWorkspaceClient } from "../../context/execution-context.js";
|
|
6
|
+
import { getCurrentUserId, getExecutionContext, getWorkspaceClient, runInUserContext } from "../../context/execution-context.js";
|
|
6
7
|
import { init_context } from "../../context/index.js";
|
|
7
8
|
import { Plugin } from "../../plugin/plugin.js";
|
|
8
9
|
import { ResourceType } from "../../registry/types.generated.js";
|
|
@@ -10,8 +11,8 @@ import "../../registry/index.js";
|
|
|
10
11
|
import { toPlugin } from "../../plugin/to-plugin.js";
|
|
11
12
|
import "../../plugin/index.js";
|
|
12
13
|
import { PolicyDeniedError, policy } from "./policy.js";
|
|
13
|
-
import { contentTypeFromPath, isSafeInlineContentType, validateCustomContentTypes } from "../../connectors/files/defaults.js";
|
|
14
|
-
import { FilesConnector } from "../../connectors/files/client.js";
|
|
14
|
+
import { FILES_MAX_READ_SIZE, contentTypeFromPath, isSafeInlineContentType, validateCustomContentTypes } from "../../connectors/files/defaults.js";
|
|
15
|
+
import { FilesConnector, runWithFilesSpanAttributes } from "../../connectors/files/client.js";
|
|
15
16
|
import "../../connectors/files/index.js";
|
|
16
17
|
import { buildToolkitEntries } from "../../core/agent/build-toolkit.js";
|
|
17
18
|
import { defineTool, executeFromRegistry, toolsFromRegistry } from "../../core/agent/tools/define-tool.js";
|
|
@@ -62,6 +63,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
62
63
|
/**
|
|
63
64
|
* Generates resource requirements dynamically from discovered + configured volumes.
|
|
64
65
|
* Each volume key maps to a `DATABRICKS_VOLUME_{KEY_UPPERCASE}` env var.
|
|
66
|
+
*
|
|
67
|
+
* ## Per-volume permission scope (SP vs OBO)
|
|
68
|
+
*
|
|
69
|
+
* The returned manifest entries describe a single permission grant per
|
|
70
|
+
* volume, but the *grantee* depends on the volume's `auth` setting at
|
|
71
|
+
* runtime — and that distinction is **not** expressed in the manifest
|
|
72
|
+
* today:
|
|
73
|
+
*
|
|
74
|
+
* - **Service-principal volumes** (the default, `auth: "service-principal"`):
|
|
75
|
+
* the app's service principal needs `WRITE_VOLUME` (or read-equivalent)
|
|
76
|
+
* on the UC volume. This matches the manifest entry as written.
|
|
77
|
+
* - **On-behalf-of-user volumes** (`auth: "on-behalf-of-user"`): SDK calls
|
|
78
|
+
* execute as the **end user**, so the *user* — not the SP — must hold
|
|
79
|
+
* `WRITE_VOLUME` (or read-equivalent) on the UC volume. The SP only
|
|
80
|
+
* needs to be allowed to mint user-token requests; it does not need
|
|
81
|
+
* direct volume permissions.
|
|
82
|
+
*
|
|
83
|
+
* The static manifest cannot currently express this per-volume split, so
|
|
84
|
+
* callers configuring OBO volumes must communicate the per-user permission
|
|
85
|
+
* requirement out-of-band (docs, runbooks, deployment scripts) until the
|
|
86
|
+
* manifest schema gains a per-volume auth scope field.
|
|
65
87
|
*/
|
|
66
88
|
static getResourceRequirements(config) {
|
|
67
89
|
const volumes = FilesPlugin.discoverVolumes(config);
|
|
@@ -79,17 +101,65 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
79
101
|
}));
|
|
80
102
|
}
|
|
81
103
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
104
|
+
* Extraction for `VolumeHandle.asUser(req)`. In production we require BOTH
|
|
105
|
+
* `x-forwarded-user` and `x-forwarded-access-token`, and throw
|
|
106
|
+
* `AuthenticationError.missingToken` if either is missing — otherwise a
|
|
107
|
+
* request with only `x-forwarded-user: alice` would let policies see Alice
|
|
108
|
+
* as a "real user" (`isServicePrincipal: false`) while the SDK call below
|
|
109
|
+
* falls through to the SP client because `_buildUserContextOrNull` returns
|
|
110
|
+
* `null`. Net effect: policy approves the user, SDK runs as SP, privilege
|
|
111
|
+
* confusion (CWE-639/863).
|
|
112
|
+
*
|
|
113
|
+
* In development (`NODE_ENV === "development"`) we keep a local-loop
|
|
114
|
+
* convenience: if either header is missing we emit a single warning and
|
|
115
|
+
* return a policy user explicitly marked `isServicePrincipal: true`, so
|
|
116
|
+
* even in dev a `usersOnly`-style policy that gates on
|
|
117
|
+
* `!user.isServicePrincipal` cannot be tricked. The matching SDK execution
|
|
118
|
+
* path also falls through to the SP client (no `runInUserContext` wrap),
|
|
119
|
+
* so the policy user and the SDK identity stay aligned.
|
|
84
120
|
*/
|
|
85
121
|
_extractUser(req) {
|
|
86
122
|
const userId = req.header("x-forwarded-user")?.trim();
|
|
87
|
-
|
|
123
|
+
const token = req.header("x-forwarded-access-token")?.trim();
|
|
124
|
+
if (userId && token) return {
|
|
125
|
+
id: userId,
|
|
126
|
+
isServicePrincipal: false
|
|
127
|
+
};
|
|
88
128
|
if (process.env.NODE_ENV === "development") {
|
|
89
|
-
logger.warn("
|
|
90
|
-
return {
|
|
129
|
+
logger.warn("asUser(req) called without complete x-forwarded-user + x-forwarded-access-token headers — falling back to service principal identity (dev mode). In production this request would 401.");
|
|
130
|
+
return {
|
|
131
|
+
id: getCurrentUserId(),
|
|
132
|
+
isServicePrincipal: true
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (!token) throw AuthenticationError.missingToken("Missing x-forwarded-access-token header for asUser(req). Both x-forwarded-user and x-forwarded-access-token are required.");
|
|
136
|
+
throw AuthenticationError.missingToken("Missing x-forwarded-user header. Cannot resolve user ID for asUser(req).");
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Extraction for OBO (on-behalf-of-user) volumes on the HTTP path. Both the
|
|
140
|
+
* `x-forwarded-access-token` and `x-forwarded-user` headers must be present
|
|
141
|
+
* for a valid end-user identity. When the token is missing:
|
|
142
|
+
* - In production we throw `AuthenticationError.missingToken` so the route
|
|
143
|
+
* responds with 401 (no SDK call is made).
|
|
144
|
+
* - In development (`NODE_ENV === "development"`) we emit a single warning
|
|
145
|
+
* and fall back to the service principal identity so local testing
|
|
146
|
+
* without a reverse proxy continues to work.
|
|
147
|
+
*/
|
|
148
|
+
_extractOboUser(req) {
|
|
149
|
+
const token = req.header("x-forwarded-access-token")?.trim();
|
|
150
|
+
const userId = req.header("x-forwarded-user")?.trim();
|
|
151
|
+
if (token && userId) return {
|
|
152
|
+
id: userId,
|
|
153
|
+
isServicePrincipal: false
|
|
154
|
+
};
|
|
155
|
+
if (!token && process.env.NODE_ENV === "development") {
|
|
156
|
+
logger.warn("OBO volume requested without x-forwarded-access-token — falling back to service principal identity (dev mode). In production this request would 401.");
|
|
157
|
+
return {
|
|
158
|
+
id: getCurrentUserId(),
|
|
159
|
+
isServicePrincipal: true
|
|
160
|
+
};
|
|
91
161
|
}
|
|
92
|
-
throw AuthenticationError.missingToken("Missing x-forwarded-user
|
|
162
|
+
throw AuthenticationError.missingToken(!token ? "Missing x-forwarded-access-token header for on-behalf-of-user volume." : "Missing x-forwarded-user header for on-behalf-of-user volume.");
|
|
93
163
|
}
|
|
94
164
|
/**
|
|
95
165
|
* Check the policy for a volume. No-op if no policy is configured.
|
|
@@ -110,17 +180,43 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
110
180
|
}
|
|
111
181
|
/**
|
|
112
182
|
* HTTP-level wrapper around `_checkPolicy`.
|
|
113
|
-
*
|
|
183
|
+
* Selects the policy user based on the volume's auth mode (resolved via
|
|
184
|
+
* `_resolveAuth`):
|
|
185
|
+
* - `"service-principal"` (default): use the `x-forwarded-user` header when
|
|
186
|
+
* present, otherwise fall back to the SP identity (legacy behavior).
|
|
187
|
+
* - `"on-behalf-of-user"`: require `x-forwarded-access-token` (and the
|
|
188
|
+
* matching `x-forwarded-user`); 401 in production when missing,
|
|
189
|
+
* dev-fallback to SP identity in development.
|
|
190
|
+
* Then runs the volume policy (403 on denial, 500 on unexpected error).
|
|
114
191
|
* Returns `true` if the request may proceed, `false` if a response was sent.
|
|
192
|
+
*
|
|
193
|
+
* NOTE: This method only selects which identity the *policy* sees. The
|
|
194
|
+
* matching SDK execution identity is selected separately by
|
|
195
|
+
* `_resolveAuthForRequest` and applied via `_runWithAuth` /
|
|
196
|
+
* `runInUserContext` in each handler. The two selections are designed to
|
|
197
|
+
* converge on the same identity per the policy-user matrix in the docs —
|
|
198
|
+
* see `docs/docs/plugins/files.md#policy-user-matrix`.
|
|
115
199
|
*/
|
|
116
200
|
async _enforcePolicy(req, res, volumeKey, action, path, resourceOverrides) {
|
|
117
201
|
let user;
|
|
118
202
|
try {
|
|
119
|
-
user = this.
|
|
203
|
+
if (this._resolveAuth(volumeKey) === "on-behalf-of-user") user = this._extractOboUser(req);
|
|
204
|
+
else {
|
|
205
|
+
const headerUserId = req.header("x-forwarded-user")?.trim();
|
|
206
|
+
if (headerUserId) user = { id: headerUserId };
|
|
207
|
+
else {
|
|
208
|
+
logger.debug("No x-forwarded-user header — proceeding with service principal identity for policy evaluation.");
|
|
209
|
+
user = {
|
|
210
|
+
id: getCurrentUserId(),
|
|
211
|
+
isServicePrincipal: true
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
120
215
|
} catch (error) {
|
|
121
216
|
if (error instanceof AuthenticationError) {
|
|
217
|
+
logger.warn("Authentication failed during policy evaluation for volume %s: %O", volumeKey, error);
|
|
122
218
|
res.status(401).json({
|
|
123
|
-
error:
|
|
219
|
+
error: "Unauthorized",
|
|
124
220
|
plugin: this.name
|
|
125
221
|
});
|
|
126
222
|
return false;
|
|
@@ -158,8 +254,10 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
158
254
|
const volumePath = process.env[envVar];
|
|
159
255
|
const mergedConfig = {
|
|
160
256
|
maxUploadSize: volumeCfg.maxUploadSize ?? config.maxUploadSize,
|
|
257
|
+
maxReadSize: volumeCfg.maxReadSize ?? config.maxReadSize,
|
|
161
258
|
customContentTypes: volumeCfg.customContentTypes ?? config.customContentTypes,
|
|
162
|
-
policy: volumeCfg.policy ?? policy.publicRead()
|
|
259
|
+
policy: volumeCfg.policy ?? policy.publicRead(),
|
|
260
|
+
auth: volumeCfg.auth
|
|
163
261
|
};
|
|
164
262
|
this.volumeConfigs[key] = mergedConfig;
|
|
165
263
|
this.volumeConnectors[key] = new FilesConnector({
|
|
@@ -169,7 +267,7 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
169
267
|
customContentTypes: mergedConfig.customContentTypes
|
|
170
268
|
});
|
|
171
269
|
Object.assign(this.tools, this._defineVolumeTools(key));
|
|
172
|
-
if (!volumeCfg.policy) logger.warn("Volume \"%s\" has no explicit policy — defaulting to publicRead(). Set a policy in files({ volumes: { %s: { policy: ... } } }) to silence this warning.", key, key);
|
|
270
|
+
if (!volumeCfg.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);
|
|
173
271
|
}
|
|
174
272
|
}
|
|
175
273
|
injectRoutes(router) {
|
|
@@ -307,6 +405,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
307
405
|
};
|
|
308
406
|
}
|
|
309
407
|
/**
|
|
408
|
+
* Extract `req.query.path` as a single string when present.
|
|
409
|
+
*
|
|
410
|
+
* Express coerces repeated query parameters (`?path=a&path=b`) to a string
|
|
411
|
+
* array and dotted/nested params (`?path[k]=v`) to an object. Reject those
|
|
412
|
+
* with `400` instead of letting non-string values reach `_isValidPath` /
|
|
413
|
+
* `connector.resolvePath`, which would misbehave on arrays or objects.
|
|
414
|
+
*
|
|
415
|
+
* Returns `{ path }` (with `path` either a string or `undefined` when the
|
|
416
|
+
* query parameter was absent) on success. Returns `undefined` and writes a
|
|
417
|
+
* `400` response when the value is not a single string — callers must
|
|
418
|
+
* check the return for `undefined` before continuing.
|
|
419
|
+
*/
|
|
420
|
+
_readPathQuery(req, res) {
|
|
421
|
+
const value = req.query.path;
|
|
422
|
+
if (value === void 0 || typeof value === "string") return { path: value };
|
|
423
|
+
res.status(400).json({
|
|
424
|
+
error: "path query parameter must be a single string",
|
|
425
|
+
plugin: this.name
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
310
429
|
* Validate a file/directory path from user input.
|
|
311
430
|
* Returns `true` if valid, or an error message string if invalid.
|
|
312
431
|
*/
|
|
@@ -316,29 +435,104 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
316
435
|
if (path.includes("\0")) return "path must not contain null bytes";
|
|
317
436
|
return true;
|
|
318
437
|
}
|
|
319
|
-
_readSettings(cacheKey) {
|
|
438
|
+
_readSettings(cacheKey, authMode) {
|
|
439
|
+
const cache = authMode === "on-behalf-of-user" ? {
|
|
440
|
+
...FILES_READ_DEFAULTS.cache,
|
|
441
|
+
enabled: false,
|
|
442
|
+
cacheKey
|
|
443
|
+
} : {
|
|
444
|
+
...FILES_READ_DEFAULTS.cache,
|
|
445
|
+
cacheKey
|
|
446
|
+
};
|
|
320
447
|
return { default: {
|
|
321
448
|
...FILES_READ_DEFAULTS,
|
|
322
|
-
cache
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
449
|
+
cache,
|
|
450
|
+
telemetryInterceptor: { attributes: this._authModeAttributes(authMode) }
|
|
451
|
+
} };
|
|
452
|
+
}
|
|
453
|
+
_writeSettings(authMode) {
|
|
454
|
+
return { default: {
|
|
455
|
+
...FILES_WRITE_DEFAULTS,
|
|
456
|
+
telemetryInterceptor: { attributes: this._authModeAttributes(authMode) }
|
|
457
|
+
} };
|
|
458
|
+
}
|
|
459
|
+
_downloadSettings(authMode) {
|
|
460
|
+
return { default: {
|
|
461
|
+
...FILES_DOWNLOAD_DEFAULTS,
|
|
462
|
+
telemetryInterceptor: { attributes: this._authModeAttributes(authMode) }
|
|
326
463
|
} };
|
|
327
464
|
}
|
|
328
465
|
/**
|
|
329
466
|
* Invalidate cached list entries for a directory after a write operation.
|
|
330
|
-
*
|
|
331
|
-
*
|
|
467
|
+
* Must produce the SAME cache-key shape that `_handleList` stored under.
|
|
468
|
+
* `_handleList` builds its key from `req.query.path`: when `path` is
|
|
469
|
+
* provided it uses `connector.resolvePath(path)`, otherwise it uses the
|
|
470
|
+
* sentinel `"__root__"`. The invalidation here must derive the matching
|
|
471
|
+
* directory from the FILE path being written:
|
|
472
|
+
*
|
|
473
|
+
* - `"/Volumes/c/s/v/foo/bar.txt"` → `parentDirectory` returns
|
|
474
|
+
* `"/Volumes/c/s/v/foo"` → resolved path key.
|
|
475
|
+
* - `"/bar.txt"` and `"bar.txt"` → root-level files: matching list cache
|
|
476
|
+
* was a rootless `list()` call → `"__root__"` sentinel.
|
|
477
|
+
* - `"/Volumes/c/s/v/bar.txt"` → `parentDirectory` returns the UC
|
|
478
|
+
* volume path (`"/Volumes/c/s/v"`). That's also root-level — a
|
|
479
|
+
* rootless `list()` would have cached under `"__root__"`, while
|
|
480
|
+
* `list("/Volumes/c/s/v")` and `list("/Volumes/c/s/v/")` would have
|
|
481
|
+
* cached under the volume path with and without trailing slash. All
|
|
482
|
+
* three are invalidated.
|
|
332
483
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
484
|
+
* On OBO volumes the read cache is disabled (see `_readSettings`), so
|
|
485
|
+
* invalidation is a no-op here for `mode === "on-behalf-of-user"`. The
|
|
486
|
+
* cache layer is keyed by `getCurrentUserId()`, so user A's writes can
|
|
487
|
+
* only invalidate user A's cache entry — user B would otherwise see stale
|
|
488
|
+
* data for the same volume/path until TTL. Disabling the cache on OBO
|
|
489
|
+
* trades read performance for correctness; the alternative is a
|
|
490
|
+
* per-(volume, path) generation counter folded into the cache key on
|
|
491
|
+
* writes (a future enhancement).
|
|
492
|
+
*
|
|
493
|
+
* Best-effort: a thrown `connector.resolvePath` (e.g. on malformed input)
|
|
494
|
+
* is swallowed here. Invalidation is purely an optimization signal — a
|
|
495
|
+
* missed delete only costs read freshness, not correctness, and
|
|
496
|
+
* propagating the error would convert a successful write into an HTTP
|
|
497
|
+
* 500.
|
|
498
|
+
*
|
|
499
|
+
* Returns a `Promise<void>`; callers MUST `await` this before sending the
|
|
500
|
+
* HTTP success response so a follow-up `GET /list` issued in the same tick
|
|
501
|
+
* cannot race the underlying `cache.delete()` and observe stale data.
|
|
336
502
|
*/
|
|
337
|
-
_invalidateListCache(volumeKey,
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
const
|
|
341
|
-
|
|
503
|
+
async _invalidateListCache(volumeKey, writtenPath, connector, mode = "service-principal") {
|
|
504
|
+
if (mode === "on-behalf-of-user") return;
|
|
505
|
+
const parent = parentDirectory(writtenPath);
|
|
506
|
+
const userKey = getCurrentUserId();
|
|
507
|
+
const tryDelete = async (segment) => {
|
|
508
|
+
try {
|
|
509
|
+
await this.cache.delete(this.cache.generateKey([`files:${volumeKey}:list`, segment], userKey));
|
|
510
|
+
} catch (err) {
|
|
511
|
+
logger.debug("List-cache invalidation failed for volume=%s segment=%s: %O", volumeKey, segment, err);
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
let volumeRoot = null;
|
|
515
|
+
try {
|
|
516
|
+
volumeRoot = connector.resolvePath("").replace(/\/+$/, "");
|
|
517
|
+
} catch (err) {
|
|
518
|
+
logger.debug("List-cache invalidation: resolvePath(\"\") failed for volume=%s: %O", volumeKey, err);
|
|
519
|
+
}
|
|
520
|
+
if (!parent || parent === "/" || volumeRoot !== null && parent === volumeRoot) {
|
|
521
|
+
await tryDelete("__root__");
|
|
522
|
+
if (volumeRoot !== null) {
|
|
523
|
+
await tryDelete(volumeRoot);
|
|
524
|
+
await tryDelete(`${volumeRoot}/`);
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
let resolved;
|
|
529
|
+
try {
|
|
530
|
+
resolved = connector.resolvePath(parent);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
logger.debug("List-cache invalidation: resolvePath(%s) failed for volume=%s: %O", parent, volumeKey, err);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
await tryDelete(resolved);
|
|
342
536
|
}
|
|
343
537
|
_handleApiError(res, error, fallbackMessage) {
|
|
344
538
|
if (error instanceof PolicyDeniedError) {
|
|
@@ -349,8 +543,9 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
349
543
|
return;
|
|
350
544
|
}
|
|
351
545
|
if (error instanceof AuthenticationError) {
|
|
546
|
+
logger.warn("Authentication failed in %s: %O", this.name, error);
|
|
352
547
|
res.status(401).json({
|
|
353
|
-
error:
|
|
548
|
+
error: "Unauthorized",
|
|
354
549
|
plugin: this.name
|
|
355
550
|
});
|
|
356
551
|
return;
|
|
@@ -358,8 +553,9 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
358
553
|
if (error instanceof ApiError) {
|
|
359
554
|
const status = error.statusCode ?? 500;
|
|
360
555
|
if (status >= 400 && status < 500) {
|
|
556
|
+
logger.warn("Upstream %d in %s: %O", status, this.name, error);
|
|
361
557
|
res.status(status).json({
|
|
362
|
-
error:
|
|
558
|
+
error: STATUS_CODES[status] ?? "Client Error",
|
|
363
559
|
statusCode: status,
|
|
364
560
|
plugin: this.name
|
|
365
561
|
});
|
|
@@ -385,22 +581,29 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
385
581
|
});
|
|
386
582
|
}
|
|
387
583
|
async _handleList(req, res, connector, volumeKey) {
|
|
388
|
-
const
|
|
584
|
+
const query = this._readPathQuery(req, res);
|
|
585
|
+
if (!query) return;
|
|
586
|
+
const path = query.path;
|
|
389
587
|
if (!await this._enforcePolicy(req, res, volumeKey, "list", path ?? "/")) return;
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
this.
|
|
394
|
-
|
|
588
|
+
const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
|
|
589
|
+
await this._runWithAuth(userCtx, async () => {
|
|
590
|
+
try {
|
|
591
|
+
const result = await this.execute(async () => connector.list(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:list`, path ? connector.resolvePath(path) : "__root__"], mode));
|
|
592
|
+
if (!result.ok) {
|
|
593
|
+
this._sendStatusError(res, result.status);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
res.json(result.data);
|
|
597
|
+
} catch (error) {
|
|
598
|
+
this._handleApiError(res, error, "List failed");
|
|
395
599
|
}
|
|
396
|
-
|
|
397
|
-
} catch (error) {
|
|
398
|
-
this._handleApiError(res, error, "List failed");
|
|
399
|
-
}
|
|
600
|
+
});
|
|
400
601
|
}
|
|
401
602
|
async _handleRead(req, res, connector, volumeKey) {
|
|
402
|
-
const
|
|
403
|
-
|
|
603
|
+
const query = this._readPathQuery(req, res);
|
|
604
|
+
if (!query) return;
|
|
605
|
+
const rawPath = query.path;
|
|
606
|
+
const valid = this._isValidPath(rawPath);
|
|
404
607
|
if (valid !== true) {
|
|
405
608
|
res.status(400).json({
|
|
406
609
|
error: valid,
|
|
@@ -408,17 +611,51 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
408
611
|
});
|
|
409
612
|
return;
|
|
410
613
|
}
|
|
614
|
+
const path = rawPath;
|
|
411
615
|
if (!await this._enforcePolicy(req, res, volumeKey, "read", path)) return;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
616
|
+
const maxReadSize = this.volumeConfigs[volumeKey].maxReadSize ?? FILES_MAX_READ_SIZE;
|
|
617
|
+
const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
|
|
618
|
+
await this._runWithAuth(userCtx, async () => {
|
|
619
|
+
try {
|
|
620
|
+
const response = await this.execute(async () => connector.download(getWorkspaceClient(), path), this._downloadSettings(mode));
|
|
621
|
+
if (!response.ok) {
|
|
622
|
+
this._sendStatusError(res, response.status);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (!response.data.contents) {
|
|
626
|
+
res.type("text/plain").send("");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const reader = response.data.contents.getReader();
|
|
630
|
+
const chunks = [];
|
|
631
|
+
let bytesRead = 0;
|
|
632
|
+
try {
|
|
633
|
+
while (true) {
|
|
634
|
+
const { value, done } = await reader.read();
|
|
635
|
+
if (done) break;
|
|
636
|
+
bytesRead += value.byteLength;
|
|
637
|
+
if (bytesRead > maxReadSize) {
|
|
638
|
+
res.status(413).json({
|
|
639
|
+
error: `File exceeds maxReadSize (${maxReadSize} bytes). Use /download for large files.`,
|
|
640
|
+
plugin: this.name
|
|
641
|
+
});
|
|
642
|
+
try {
|
|
643
|
+
await reader.cancel();
|
|
644
|
+
} catch {}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
chunks.push(value);
|
|
648
|
+
}
|
|
649
|
+
} finally {
|
|
650
|
+
try {
|
|
651
|
+
reader.releaseLock();
|
|
652
|
+
} catch {}
|
|
653
|
+
}
|
|
654
|
+
res.type("text/plain").send(Buffer.concat(chunks, bytesRead));
|
|
655
|
+
} catch (error) {
|
|
656
|
+
this._handleApiError(res, error, "Read failed");
|
|
417
657
|
}
|
|
418
|
-
|
|
419
|
-
} catch (error) {
|
|
420
|
-
this._handleApiError(res, error, "Read failed");
|
|
421
|
-
}
|
|
658
|
+
});
|
|
422
659
|
}
|
|
423
660
|
async _handleDownload(req, res, connector, volumeKey) {
|
|
424
661
|
return this._serveFile(req, res, connector, volumeKey, { mode: "download" });
|
|
@@ -432,8 +669,10 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
432
669
|
* - `raw`: adds CSP sandbox; forces attachment only for unsafe content types.
|
|
433
670
|
*/
|
|
434
671
|
async _serveFile(req, res, connector, volumeKey, opts) {
|
|
435
|
-
const
|
|
436
|
-
|
|
672
|
+
const query = this._readPathQuery(req, res);
|
|
673
|
+
if (!query) return;
|
|
674
|
+
const rawPath = query.path;
|
|
675
|
+
const valid = this._isValidPath(rawPath);
|
|
437
676
|
if (valid !== true) {
|
|
438
677
|
res.status(400).json({
|
|
439
678
|
error: valid,
|
|
@@ -441,40 +680,46 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
441
680
|
});
|
|
442
681
|
return;
|
|
443
682
|
}
|
|
683
|
+
const path = rawPath;
|
|
444
684
|
if (!await this._enforcePolicy(req, res, volumeKey, opts.mode, path)) return;
|
|
445
685
|
const label = opts.mode === "download" ? "Download" : "Raw fetch";
|
|
446
686
|
const volumeCfg = this.volumeConfigs[volumeKey];
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
this.
|
|
452
|
-
|
|
687
|
+
const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
|
|
688
|
+
await this._runWithAuth(userCtx, async () => {
|
|
689
|
+
try {
|
|
690
|
+
const settings = this._downloadSettings(mode);
|
|
691
|
+
const response = await this.execute(async () => connector.download(getWorkspaceClient(), path), settings);
|
|
692
|
+
if (!response.ok) {
|
|
693
|
+
this._sendStatusError(res, response.status);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const resolvedType = contentTypeFromPath(path, void 0, volumeCfg.customContentTypes);
|
|
697
|
+
const fileName = sanitizeFilename(path.split("/").pop() ?? "download");
|
|
698
|
+
res.setHeader("Content-Type", resolvedType);
|
|
699
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
700
|
+
if (opts.mode === "raw") {
|
|
701
|
+
res.setHeader("Content-Security-Policy", "sandbox");
|
|
702
|
+
if (!isSafeInlineContentType(resolvedType)) res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
703
|
+
} else res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
704
|
+
if (response.data.contents) {
|
|
705
|
+
const nodeStream = Readable.fromWeb(response.data.contents);
|
|
706
|
+
nodeStream.on("error", (err) => {
|
|
707
|
+
logger.error("Stream error during %s: %O", opts.mode, err);
|
|
708
|
+
if (!res.headersSent) this._sendStatusError(res, 500);
|
|
709
|
+
else res.destroy();
|
|
710
|
+
});
|
|
711
|
+
nodeStream.pipe(res);
|
|
712
|
+
} else res.end();
|
|
713
|
+
} catch (error) {
|
|
714
|
+
this._handleApiError(res, error, `${label} failed`);
|
|
453
715
|
}
|
|
454
|
-
|
|
455
|
-
const fileName = sanitizeFilename(path.split("/").pop() ?? "download");
|
|
456
|
-
res.setHeader("Content-Type", resolvedType);
|
|
457
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
458
|
-
if (opts.mode === "raw") {
|
|
459
|
-
res.setHeader("Content-Security-Policy", "sandbox");
|
|
460
|
-
if (!isSafeInlineContentType(resolvedType)) res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
461
|
-
} else res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
|
|
462
|
-
if (response.data.contents) {
|
|
463
|
-
const nodeStream = Readable.fromWeb(response.data.contents);
|
|
464
|
-
nodeStream.on("error", (err) => {
|
|
465
|
-
logger.error("Stream error during %s: %O", opts.mode, err);
|
|
466
|
-
if (!res.headersSent) this._sendStatusError(res, 500);
|
|
467
|
-
else res.destroy();
|
|
468
|
-
});
|
|
469
|
-
nodeStream.pipe(res);
|
|
470
|
-
} else res.end();
|
|
471
|
-
} catch (error) {
|
|
472
|
-
this._handleApiError(res, error, `${label} failed`);
|
|
473
|
-
}
|
|
716
|
+
});
|
|
474
717
|
}
|
|
475
718
|
async _handleExists(req, res, connector, volumeKey) {
|
|
476
|
-
const
|
|
477
|
-
|
|
719
|
+
const query = this._readPathQuery(req, res);
|
|
720
|
+
if (!query) return;
|
|
721
|
+
const rawPath = query.path;
|
|
722
|
+
const valid = this._isValidPath(rawPath);
|
|
478
723
|
if (valid !== true) {
|
|
479
724
|
res.status(400).json({
|
|
480
725
|
error: valid,
|
|
@@ -482,21 +727,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
482
727
|
});
|
|
483
728
|
return;
|
|
484
729
|
}
|
|
730
|
+
const path = rawPath;
|
|
485
731
|
if (!await this._enforcePolicy(req, res, volumeKey, "exists", path)) return;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
this.
|
|
490
|
-
|
|
732
|
+
const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
|
|
733
|
+
await this._runWithAuth(userCtx, async () => {
|
|
734
|
+
try {
|
|
735
|
+
const result = await this.execute(async () => connector.exists(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:exists`, connector.resolvePath(path)], mode));
|
|
736
|
+
if (!result.ok) {
|
|
737
|
+
this._sendStatusError(res, result.status);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
res.json({ exists: result.data });
|
|
741
|
+
} catch (error) {
|
|
742
|
+
this._handleApiError(res, error, "Exists check failed");
|
|
491
743
|
}
|
|
492
|
-
|
|
493
|
-
} catch (error) {
|
|
494
|
-
this._handleApiError(res, error, "Exists check failed");
|
|
495
|
-
}
|
|
744
|
+
});
|
|
496
745
|
}
|
|
497
746
|
async _handleMetadata(req, res, connector, volumeKey) {
|
|
498
|
-
const
|
|
499
|
-
|
|
747
|
+
const query = this._readPathQuery(req, res);
|
|
748
|
+
if (!query) return;
|
|
749
|
+
const rawPath = query.path;
|
|
750
|
+
const valid = this._isValidPath(rawPath);
|
|
500
751
|
if (valid !== true) {
|
|
501
752
|
res.status(400).json({
|
|
502
753
|
error: valid,
|
|
@@ -504,21 +755,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
504
755
|
});
|
|
505
756
|
return;
|
|
506
757
|
}
|
|
758
|
+
const path = rawPath;
|
|
507
759
|
if (!await this._enforcePolicy(req, res, volumeKey, "metadata", path)) return;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
this.
|
|
512
|
-
|
|
760
|
+
const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
|
|
761
|
+
await this._runWithAuth(userCtx, async () => {
|
|
762
|
+
try {
|
|
763
|
+
const result = await this.execute(async () => connector.metadata(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:metadata`, connector.resolvePath(path)], mode));
|
|
764
|
+
if (!result.ok) {
|
|
765
|
+
this._sendStatusError(res, result.status);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
res.json(result.data);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
this._handleApiError(res, error, "Metadata fetch failed");
|
|
513
771
|
}
|
|
514
|
-
|
|
515
|
-
} catch (error) {
|
|
516
|
-
this._handleApiError(res, error, "Metadata fetch failed");
|
|
517
|
-
}
|
|
772
|
+
});
|
|
518
773
|
}
|
|
519
774
|
async _handlePreview(req, res, connector, volumeKey) {
|
|
520
|
-
const
|
|
521
|
-
|
|
775
|
+
const query = this._readPathQuery(req, res);
|
|
776
|
+
if (!query) return;
|
|
777
|
+
const rawPath = query.path;
|
|
778
|
+
const valid = this._isValidPath(rawPath);
|
|
522
779
|
if (valid !== true) {
|
|
523
780
|
res.status(400).json({
|
|
524
781
|
error: valid,
|
|
@@ -526,21 +783,27 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
526
783
|
});
|
|
527
784
|
return;
|
|
528
785
|
}
|
|
786
|
+
const path = rawPath;
|
|
529
787
|
if (!await this._enforcePolicy(req, res, volumeKey, "preview", path)) return;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
this.
|
|
534
|
-
|
|
788
|
+
const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
|
|
789
|
+
await this._runWithAuth(userCtx, async () => {
|
|
790
|
+
try {
|
|
791
|
+
const result = await this.execute(async () => connector.preview(getWorkspaceClient(), path), this._readSettings([`files:${volumeKey}:preview`, connector.resolvePath(path)], mode));
|
|
792
|
+
if (!result.ok) {
|
|
793
|
+
this._sendStatusError(res, result.status);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
res.json(result.data);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
this._handleApiError(res, error, "Preview failed");
|
|
535
799
|
}
|
|
536
|
-
|
|
537
|
-
} catch (error) {
|
|
538
|
-
this._handleApiError(res, error, "Preview failed");
|
|
539
|
-
}
|
|
800
|
+
});
|
|
540
801
|
}
|
|
541
802
|
async _handleUpload(req, res, connector, volumeKey) {
|
|
542
|
-
const
|
|
543
|
-
|
|
803
|
+
const query = this._readPathQuery(req, res);
|
|
804
|
+
if (!query) return;
|
|
805
|
+
const rawPath = query.path;
|
|
806
|
+
const valid = this._isValidPath(rawPath);
|
|
544
807
|
if (valid !== true) {
|
|
545
808
|
res.status(400).json({
|
|
546
809
|
error: valid,
|
|
@@ -548,6 +811,7 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
548
811
|
});
|
|
549
812
|
return;
|
|
550
813
|
}
|
|
814
|
+
const path = rawPath;
|
|
551
815
|
const maxSize = this.volumeConfigs[volumeKey].maxUploadSize ?? FILES_MAX_UPLOAD_SIZE;
|
|
552
816
|
const rawContentLength = req.headers["content-length"];
|
|
553
817
|
let contentLength;
|
|
@@ -570,41 +834,48 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
570
834
|
return;
|
|
571
835
|
}
|
|
572
836
|
logger.debug(req, "Upload started: volume=%s path=%s", volumeKey, path);
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
bytesReceived
|
|
578
|
-
|
|
579
|
-
|
|
837
|
+
const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
|
|
838
|
+
await this._runWithAuth(userCtx, async () => {
|
|
839
|
+
try {
|
|
840
|
+
const rawStream = Readable.toWeb(req);
|
|
841
|
+
let bytesReceived = 0;
|
|
842
|
+
const webStream = rawStream.pipeThrough(new TransformStream({ transform(chunk, controller) {
|
|
843
|
+
bytesReceived += chunk.byteLength;
|
|
844
|
+
if (bytesReceived > maxSize) {
|
|
845
|
+
controller.error(/* @__PURE__ */ new Error(`Upload stream exceeds maximum allowed size (${maxSize} bytes)`));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (contentLength !== void 0 && bytesReceived > contentLength) {
|
|
849
|
+
controller.error(/* @__PURE__ */ new Error(`Upload stream exceeds declared Content-Length (${contentLength} bytes)`));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
controller.enqueue(chunk);
|
|
853
|
+
} }));
|
|
854
|
+
logger.debug(req, "Upload body received: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
|
|
855
|
+
const settings = this._writeSettings(mode);
|
|
856
|
+
const result = await this.trackWrite(() => this.execute(async () => {
|
|
857
|
+
await connector.upload(getWorkspaceClient(), path, webStream);
|
|
858
|
+
return { success: true };
|
|
859
|
+
}, settings));
|
|
860
|
+
await this._invalidateListCache(volumeKey, path, connector, mode);
|
|
861
|
+
if (!result.ok) {
|
|
862
|
+
logger.error(req, "Upload failed: volume=%s path=%s, size=%d bytes", volumeKey, path, contentLength ?? 0);
|
|
863
|
+
this._sendStatusError(res, result.status);
|
|
580
864
|
return;
|
|
581
865
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
this._sendStatusError(res, result.status);
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
logger.debug(req, "Upload complete: volume=%s path=%s", volumeKey, path);
|
|
597
|
-
res.json(result.data);
|
|
598
|
-
} catch (error) {
|
|
599
|
-
if (error instanceof Error && error.message.includes("exceeds maximum allowed size")) {
|
|
600
|
-
res.status(413).json({
|
|
601
|
-
error: error.message,
|
|
602
|
-
plugin: this.name
|
|
603
|
-
});
|
|
604
|
-
return;
|
|
866
|
+
logger.debug(req, "Upload complete: volume=%s path=%s", volumeKey, path);
|
|
867
|
+
res.json(result.data);
|
|
868
|
+
} catch (error) {
|
|
869
|
+
if (error instanceof Error && (error.message.includes("exceeds maximum allowed size") || error.message.includes("exceeds declared Content-Length"))) {
|
|
870
|
+
res.status(413).json({
|
|
871
|
+
error: error.message,
|
|
872
|
+
plugin: this.name
|
|
873
|
+
});
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
this._handleApiError(res, error, "Upload failed");
|
|
605
877
|
}
|
|
606
|
-
|
|
607
|
-
}
|
|
878
|
+
});
|
|
608
879
|
}
|
|
609
880
|
async _handleMkdir(req, res, connector, volumeKey) {
|
|
610
881
|
const dirPath = typeof req.body?.path === "string" ? req.body.path : void 0;
|
|
@@ -617,24 +888,29 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
617
888
|
return;
|
|
618
889
|
}
|
|
619
890
|
if (!await this._enforcePolicy(req, res, volumeKey, "mkdir", dirPath)) return;
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
this.
|
|
629
|
-
|
|
891
|
+
const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
|
|
892
|
+
await this._runWithAuth(userCtx, async () => {
|
|
893
|
+
try {
|
|
894
|
+
const settings = this._writeSettings(mode);
|
|
895
|
+
const result = await this.trackWrite(() => this.execute(async () => {
|
|
896
|
+
await connector.createDirectory(getWorkspaceClient(), dirPath);
|
|
897
|
+
return { success: true };
|
|
898
|
+
}, settings));
|
|
899
|
+
await this._invalidateListCache(volumeKey, dirPath, connector, mode);
|
|
900
|
+
if (!result.ok) {
|
|
901
|
+
this._sendStatusError(res, result.status);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
res.json(result.data);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
this._handleApiError(res, error, "Create directory failed");
|
|
630
907
|
}
|
|
631
|
-
|
|
632
|
-
} catch (error) {
|
|
633
|
-
this._handleApiError(res, error, "Create directory failed");
|
|
634
|
-
}
|
|
908
|
+
});
|
|
635
909
|
}
|
|
636
910
|
async _handleDelete(req, res, connector, volumeKey) {
|
|
637
|
-
const
|
|
911
|
+
const query = this._readPathQuery(req, res);
|
|
912
|
+
if (!query) return;
|
|
913
|
+
const rawPath = query.path;
|
|
638
914
|
const valid = this._isValidPath(rawPath);
|
|
639
915
|
if (valid !== true) {
|
|
640
916
|
res.status(400).json({
|
|
@@ -645,36 +921,177 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
645
921
|
}
|
|
646
922
|
const path = rawPath;
|
|
647
923
|
if (!await this._enforcePolicy(req, res, volumeKey, "delete", path)) return;
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
this.
|
|
657
|
-
|
|
924
|
+
const { mode, userCtx } = this._resolveAuthForRequest(req, volumeKey);
|
|
925
|
+
await this._runWithAuth(userCtx, async () => {
|
|
926
|
+
try {
|
|
927
|
+
const settings = this._writeSettings(mode);
|
|
928
|
+
const result = await this.trackWrite(() => this.execute(async () => {
|
|
929
|
+
await connector.delete(getWorkspaceClient(), path);
|
|
930
|
+
return { success: true };
|
|
931
|
+
}, settings));
|
|
932
|
+
await this._invalidateListCache(volumeKey, path, connector, mode);
|
|
933
|
+
if (!result.ok) {
|
|
934
|
+
this._sendStatusError(res, result.status);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
res.json(result.data);
|
|
938
|
+
} catch (error) {
|
|
939
|
+
this._handleApiError(res, error, "Delete failed");
|
|
658
940
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
_resolveAuth(volumeKey) {
|
|
944
|
+
return this.volumeConfigs[volumeKey]?.auth ?? this.config.auth ?? "service-principal";
|
|
663
945
|
}
|
|
664
946
|
/**
|
|
665
|
-
*
|
|
947
|
+
* Build a `UserContext` from request headers when both
|
|
948
|
+
* `x-forwarded-access-token` and `x-forwarded-user` are present, otherwise
|
|
949
|
+
* return `null`. Used by OBO route handlers to wrap SDK calls in the
|
|
950
|
+
* end-user's identity. A `null` result means "fall back to the service
|
|
951
|
+
* principal client" — for OBO volumes in production, `_enforcePolicy` will
|
|
952
|
+
* already have responded 401 before we get here, so `null` is reachable
|
|
953
|
+
* only on the dev-fallback path.
|
|
954
|
+
*/
|
|
955
|
+
_buildUserContextOrNull(req) {
|
|
956
|
+
const token = req.header("x-forwarded-access-token")?.trim();
|
|
957
|
+
const userId = req.header("x-forwarded-user")?.trim();
|
|
958
|
+
if (!token || !userId) return null;
|
|
959
|
+
return ServiceContext.createUserContext(token, userId);
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Build the telemetry attribute hash for the `files.auth_mode` span
|
|
963
|
+
* attribute. The value reflects what operationally happened — i.e.
|
|
964
|
+
* whether `runInUserContext` actually wrapped the SDK call:
|
|
965
|
+
* - HTTP route on OBO volume + valid token → `"on-behalf-of-user"`.
|
|
966
|
+
* - HTTP route on OBO volume + dev-fallback (no token) →
|
|
967
|
+
* `"service-principal"` (the route falls through to the SP client).
|
|
968
|
+
* - HTTP route on SP volume → `"service-principal"`.
|
|
969
|
+
* - `asUser(req)` programmatic calls with a real user context →
|
|
970
|
+
* `"on-behalf-of-user"`.
|
|
971
|
+
* - Any unwrapped path → `"service-principal"`.
|
|
972
|
+
*/
|
|
973
|
+
_authModeAttributes(authMode) {
|
|
974
|
+
return { "files.auth_mode": authMode };
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* One-shot resolver for HTTP route handlers. Builds the request's
|
|
978
|
+
* `UserContext` AT MOST ONCE (when the volume is OBO and the headers are
|
|
979
|
+
* present) and returns both the operationally-effective auth mode and the
|
|
980
|
+
* pre-built `UserContext`.
|
|
666
981
|
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
982
|
+
* Handlers thread the `userCtx` into `_runWithAuth(userCtx, fn)` to avoid
|
|
983
|
+
* a second `ServiceContext.createUserContext()` allocation — that call
|
|
984
|
+
* builds a fresh `WorkspaceClient` per invocation, so doing it twice per
|
|
985
|
+
* request was pure throwaway overhead.
|
|
986
|
+
*/
|
|
987
|
+
_resolveAuthForRequest(req, volumeKey) {
|
|
988
|
+
if (this._resolveAuth(volumeKey) !== "on-behalf-of-user") return {
|
|
989
|
+
mode: "service-principal",
|
|
990
|
+
userCtx: null
|
|
991
|
+
};
|
|
992
|
+
const userCtx = this._buildUserContextOrNull(req);
|
|
993
|
+
return userCtx ? {
|
|
994
|
+
mode: "on-behalf-of-user",
|
|
995
|
+
userCtx
|
|
996
|
+
} : {
|
|
997
|
+
mode: "service-principal",
|
|
998
|
+
userCtx: null
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Run `fn` under the correct execution context.
|
|
1003
|
+
* - `userCtx` is `null`: invokes `fn` directly so the service-principal
|
|
1004
|
+
* `WorkspaceClient` and `getCurrentUserId()` are used — identical
|
|
1005
|
+
* behavior to pre-OBO releases. This covers both SP volumes and the
|
|
1006
|
+
* OBO dev-fallback path (where headers were missing).
|
|
1007
|
+
* - `userCtx` is a `UserContext`: wraps `fn` in `runInUserContext(userCtx)`,
|
|
1008
|
+
* so SDK calls execute as the end user and `getCurrentUserId()` (and
|
|
1009
|
+
* therefore cache keys) resolve to the user's ID.
|
|
670
1010
|
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
1011
|
+
* The caller is responsible for building `userCtx` exactly once per
|
|
1012
|
+
* request via `_resolveAuthForRequest`; this signature deliberately does
|
|
1013
|
+
* NOT take a `req` so it cannot accidentally re-build the context.
|
|
673
1014
|
*/
|
|
674
|
-
|
|
1015
|
+
async _runWithAuth(userCtx, fn) {
|
|
1016
|
+
if (userCtx) return runInUserContext(userCtx, fn);
|
|
1017
|
+
return fn();
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Tag the span that `FilesConnector.<operation>` opens with
|
|
1021
|
+
* `files.auth_mode`. Programmatic VolumeAPI methods bypass
|
|
1022
|
+
* `this.execute(...)` (and therefore the `TelemetryInterceptor`), so the
|
|
1023
|
+
* connector's own `files.<operation>` span is the natural place to land
|
|
1024
|
+
* this attribute. Rather than opening a parent `files.<operation>` span
|
|
1025
|
+
* (which would duplicate the connector's span — same name, doubled
|
|
1026
|
+
* allocation/export), we propagate the attribute via AsyncLocalStorage
|
|
1027
|
+
* and let the connector merge it into its existing span at creation
|
|
1028
|
+
* time.
|
|
1029
|
+
*
|
|
1030
|
+
* The `operation` parameter is unused by the propagation mechanism (the
|
|
1031
|
+
* connector knows its own operation), but kept in the signature for API
|
|
1032
|
+
* stability with the previous span-creation form.
|
|
1033
|
+
*/
|
|
1034
|
+
_withAuthModeAttributes(_operation, authMode, fn) {
|
|
1035
|
+
return runWithFilesSpanAttributes(this._authModeAttributes(authMode), fn);
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Wrap each `VolumeAPI` method so the `FilesConnector` span it produces is
|
|
1039
|
+
* tagged with `files.auth_mode = "service-principal"`. Used for
|
|
1040
|
+
* programmatic calls that don't go through `asUser(req)`.
|
|
1041
|
+
*
|
|
1042
|
+
* The attribute is attached to the connector's existing span via
|
|
1043
|
+
* AsyncLocalStorage propagation (see `_withAuthModeAttributes`); no
|
|
1044
|
+
* additional parent span is opened, so each call produces exactly one
|
|
1045
|
+
* `files.<operation>` span instead of two.
|
|
1046
|
+
*/
|
|
1047
|
+
_wrapVolumeAPIWithSPSpan(api) {
|
|
1048
|
+
const wrap = (operation, fn) => (...args) => this._withAuthModeAttributes(operation, "service-principal", () => fn(...args));
|
|
1049
|
+
return {
|
|
1050
|
+
list: wrap("list", api.list),
|
|
1051
|
+
read: wrap("read", api.read),
|
|
1052
|
+
download: wrap("download", api.download),
|
|
1053
|
+
exists: wrap("exists", api.exists),
|
|
1054
|
+
metadata: wrap("metadata", api.metadata),
|
|
1055
|
+
upload: wrap("upload", api.upload),
|
|
1056
|
+
createDirectory: wrap("createDirectory", api.createDirectory),
|
|
1057
|
+
delete: wrap("delete", api.delete),
|
|
1058
|
+
preview: wrap("preview", api.preview)
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Wrap each `VolumeAPI` method so its execution runs inside
|
|
1063
|
+
* `runInUserContext(userCtx, ...)`. Used by `VolumeHandle.asUser(req)` to
|
|
1064
|
+
* force the SDK identity to the end user regardless of the volume's
|
|
1065
|
+
* `auth` setting. The policy check baked into each method (via
|
|
1066
|
+
* `createVolumeAPI`) runs inside the same scope, so `getCurrentUserId()`
|
|
1067
|
+
* and any cache `userKey` derived from it also resolve to the user.
|
|
1068
|
+
*
|
|
1069
|
+
* Each wrapped invocation tags the connector's span with
|
|
1070
|
+
* `files.auth_mode = "on-behalf-of-user"` via AsyncLocalStorage
|
|
1071
|
+
* propagation — no additional parent span is opened.
|
|
1072
|
+
*/
|
|
1073
|
+
_wrapVolumeAPIInUserContext(api, userCtx) {
|
|
1074
|
+
const wrap = (operation, fn) => (...args) => this._withAuthModeAttributes(operation, "on-behalf-of-user", () => runInUserContext(userCtx, () => fn(...args)));
|
|
1075
|
+
return {
|
|
1076
|
+
list: wrap("list", api.list),
|
|
1077
|
+
read: wrap("read", api.read),
|
|
1078
|
+
download: wrap("download", api.download),
|
|
1079
|
+
exists: wrap("exists", api.exists),
|
|
1080
|
+
metadata: wrap("metadata", api.metadata),
|
|
1081
|
+
upload: wrap("upload", api.upload),
|
|
1082
|
+
createDirectory: wrap("createDirectory", api.createDirectory),
|
|
1083
|
+
delete: wrap("delete", api.delete),
|
|
1084
|
+
preview: wrap("preview", api.preview)
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Creates a VolumeAPI for a specific volume key.
|
|
1089
|
+
*
|
|
1090
|
+
* Enforces the volume's policy before each operation.
|
|
1091
|
+
*/
|
|
1092
|
+
createVolumeAPI(volumeKey, user) {
|
|
675
1093
|
const connector = this.volumeConnectors[volumeKey];
|
|
676
|
-
const
|
|
677
|
-
const check = options?.bypassPolicy ? noop : (action, path, overrides) => this._checkPolicy(volumeKey, action, path, user, overrides);
|
|
1094
|
+
const check = (action, path, overrides) => this._checkPolicy(volumeKey, action, path, user, overrides);
|
|
678
1095
|
return {
|
|
679
1096
|
list: async (directoryPath) => {
|
|
680
1097
|
await check("list", directoryPath ?? "/");
|
|
@@ -845,15 +1262,22 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
845
1262
|
* Returns the programmatic API for the Files plugin.
|
|
846
1263
|
* Callable with a volume key to get a volume-scoped handle.
|
|
847
1264
|
*
|
|
848
|
-
*
|
|
849
|
-
*
|
|
1265
|
+
* SP volumes (`auth: "service-principal"`, the default) execute as the
|
|
1266
|
+
* service principal. OBO volumes (`auth: "on-behalf-of-user"`) executed
|
|
1267
|
+
* through the HTTP routes run as the end user; for programmatic calls
|
|
1268
|
+
* outside a route, use `asUser(req)` to opt into per-user execution.
|
|
1269
|
+
* `asUser(req)` is a hard override at the SDK level: it forces every
|
|
1270
|
+
* subsequent call to execute as the end user inside `runInUserContext`,
|
|
1271
|
+
* regardless of the volume's `auth` setting. Policies control per-user
|
|
1272
|
+
* access in either mode.
|
|
850
1273
|
*
|
|
851
1274
|
* @example
|
|
852
1275
|
* ```ts
|
|
853
1276
|
* // Service principal access
|
|
854
1277
|
* appKit.files("uploads").list()
|
|
855
1278
|
*
|
|
856
|
-
* // With policy: pass user identity for access control
|
|
1279
|
+
* // With policy: pass user identity for access control. The SDK call
|
|
1280
|
+
* // also executes as the user (not the service principal).
|
|
857
1281
|
* appKit.files("uploads").asUser(req).list()
|
|
858
1282
|
* ```
|
|
859
1283
|
*/
|
|
@@ -861,15 +1285,18 @@ var FilesPlugin = class FilesPlugin extends Plugin {
|
|
|
861
1285
|
const resolveVolume = (volumeKey) => {
|
|
862
1286
|
if (!this.volumeKeys.includes(volumeKey)) throw new Error(`Unknown volume "${volumeKey}". Available volumes: ${this.volumeKeys.join(", ")}`);
|
|
863
1287
|
return {
|
|
864
|
-
...this.createVolumeAPI(volumeKey, {
|
|
1288
|
+
...this._wrapVolumeAPIWithSPSpan(this.createVolumeAPI(volumeKey, {
|
|
865
1289
|
get id() {
|
|
866
1290
|
return getCurrentUserId();
|
|
867
1291
|
},
|
|
868
1292
|
isServicePrincipal: true
|
|
869
|
-
}),
|
|
1293
|
+
})),
|
|
870
1294
|
asUser: (req) => {
|
|
871
1295
|
const user = this._extractUser(req);
|
|
872
|
-
|
|
1296
|
+
const api = this.createVolumeAPI(volumeKey, user);
|
|
1297
|
+
const userCtx = this._buildUserContextOrNull(req);
|
|
1298
|
+
if (!userCtx) return this._wrapVolumeAPIWithSPSpan(api);
|
|
1299
|
+
return this._wrapVolumeAPIInUserContext(api, userCtx);
|
|
873
1300
|
}
|
|
874
1301
|
};
|
|
875
1302
|
};
|