Yicong-Huang commented on code in PR #6447:
URL: https://github.com/apache/texera/pull/6447#discussion_r3741174276
##########
amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala:
##########
@@ -782,6 +787,41 @@ class WorkflowResource extends LazyLogging {
.fetchOneInto(classOf[String])
}
+ @POST
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{wid}/export/drive")
+ def exportWorkflowToDrive(
+ @PathParam("wid") wid: Integer,
+ request: WorkflowResource.DriveExportRequest,
+ @Auth sessionUser: SessionUser
+ ): Unit = {
+ if
(!request.sessionUri.startsWith("https://www.googleapis.com/upload/drive/"))
+ throw new BadRequestException("Invalid session URI")
+ if (!WorkflowAccessResource.hasReadAccess(wid, sessionUser.getUid))
+ throw new ForbiddenException("No sufficient access privilege.")
+ val content = workflowDao.fetchOneByWid(wid).getContent.getBytes("UTF-8")
+ if (content.length > WorkflowResource.DRIVE_EXPORT_MAX_BYTES)
+ throw new BadRequestException("Workflow content exceeds the 500 MB
export limit.")
+ val conn = new
URL(request.sessionUri).openConnection().asInstanceOf[HttpURLConnection]
+ try {
+ conn.setDoOutput(true)
+ conn.setRequestMethod("PUT")
+ conn.setRequestProperty("Content-Type", "application/json")
+ conn.setRequestProperty("Content-Length", content.length.toString)
+ conn.getOutputStream.write(content)
+ val code = conn.getResponseCode
+ if (code < 200 || code >= 300) {
+ val body = Option(conn.getErrorStream).map(s => new
String(s.readAllBytes())).getOrElse("")
+ if (body.contains("storageQuotaExceeded"))
+ throw new ForbiddenException("Google Drive storage quota exceeded.")
+ throw new InternalServerErrorException(s"Google Drive upload failed:
$body")
Review Comment:
This is the third copy of the same Drive upload;
`DatasetResource.scala:1319` and `:1425` carry the other two. They have already
drifted. Both DatasetResource copies return `502 BAD_GATEWAY` with just the
status code. This one returns 500 and echoes Google's raw error body to the
caller — so our client is told we broke, not the upstream.
I'd extract one helper holding the allowlist check, the PUT and the status
handling. Patching the three copies to agree instead leaves the next edit free
to split them again.
##########
file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala:
##########
@@ -1298,6 +1305,187 @@ class DatasetResource extends LazyLogging {
}
}
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{did}/drive-export")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def exportDatasetToDrive(
+ @PathParam("did") did: Integer,
+ @QueryParam("dvid") dvid: Integer,
+ @QueryParam("latest") latest: java.lang.Boolean,
+ request: DriveExportRequest,
+ @Auth user: SessionUser
+ ): Response = {
+ if
(!request.sessionUri.startsWith("https://www.googleapis.com/upload/drive/")) {
+ throw new BadRequestException("Invalid session URI")
+ }
+ withTransaction(context) { ctx =>
Review Comment:
Confirming @xuang7's thread rather than reopening it. At head `febaff79`
this `withTransaction` still closes at line 1417. The LakeFS downloads
(`:1373`), the in-memory zip (`:1367`) and the Drive PUT (`:1387-1414`) all run
inside it, holding one pooled connection for the whole transfer with no read
timeout.
`getDatasetVersionZip` avoids this by returning a `StreamingOutput`
(`:1278-1301`). The full resumable-upload rework isn't needed here: keep the
access checks and `retrieveObjectsOfVersion` inside the transaction, and move
everything from `val totalBytes` onward outside it.
##########
file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala:
##########
@@ -1298,6 +1305,187 @@ class DatasetResource extends LazyLogging {
}
}
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{did}/drive-export")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def exportDatasetToDrive(
+ @PathParam("did") did: Integer,
+ @QueryParam("dvid") dvid: Integer,
+ @QueryParam("latest") latest: java.lang.Boolean,
+ request: DriveExportRequest,
+ @Auth user: SessionUser
+ ): Response = {
+ if
(!request.sessionUri.startsWith("https://www.googleapis.com/upload/drive/")) {
+ throw new BadRequestException("Invalid session URI")
+ }
+ withTransaction(context) { ctx =>
+ if ((dvid != null && latest != null) || (dvid == null && latest ==
null)) {
+ throw new BadRequestException("Specify exactly one: dvid=<ID> OR
latest=true")
+ }
+
+ val uid = user.getUid
+ if (!userHasReadAccess(ctx, did, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ }
+
+ val dataset = getDatasetByID(ctx, did)
+ if (!userOwnDataset(ctx, did, uid) && !dataset.getIsDownloadable) {
+ throw new ForbiddenException("Dataset download is not allowed")
+ }
+
+ val datasetVersion = if (dvid != null) {
+ getDatasetVersionByID(ctx, dvid)
+ } else {
+ getLatestDatasetVersion(ctx, did).getOrElse(
+ throw new NotFoundException(ERR_DATASET_VERSION_NOT_FOUND_MESSAGE)
+ )
+ }
Review Comment:
`?latest=false` reaches this branch and exports the latest version. The
guard above only rejects "both set" and "neither set", so a non-null `false`
falls straight through — an explicit "not the latest one" is honoured as "yes,
the latest one". `getDatasetVersionZip` gates on `Boolean.TRUE.equals` and
rejects everything else (`:1253`, `:1258`).
```suggestion
} else if (java.lang.Boolean.TRUE.equals(latest)) {
getLatestDatasetVersion(ctx, did).getOrElse(
throw new NotFoundException(ERR_DATASET_VERSION_NOT_FOUND_MESSAGE)
)
} else {
throw new BadRequestException("Specify exactly one: dvid=<ID> OR
latest=true")
}
```
##########
amber/src/main/scala/org/apache/texera/web/resource/auth/GoogleAuthResource.scala:
##########
@@ -48,11 +48,17 @@ object GoogleAuthResource {
@Path("/auth/google")
class GoogleAuthResource {
final private lazy val clientId = UserSystemConfig.googleClientId
+ final private lazy val driveApiKey = UserSystemConfig.googleApiKey
@GET
@Path("/clientid")
def getClientId: String = clientId
+ @GET
+ @Path("/drive/apikey")
+ @Produces(Array(MediaType.TEXT_PLAIN))
+ def getDriveApiKey: String = driveApiKey
Review Comment:
This endpoint has no `@RolesAllowed`. Amber is also the one service that
doesn't call `RoleAnnotationEnforcer.enforce`, so nothing rejects an anonymous
request — any caller can read the deployment's Google API key and spend its
quota.
The neighbouring `getClientId` is unannotated for a reason: the OAuth client
id must be readable before login. The Picker key is only needed after login, so
it doesn't inherit that. Adding `@RolesAllowed(Array("REGULAR", "ADMIN"))` also
needs the `javax.annotation.security.RolesAllowed` import.
##########
file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala:
##########
@@ -2752,6 +2752,95 @@ class DatasetResourceSpec
fetchPartRows(uploadId) shouldBe empty
}
+ //
===========================================================================
+ // exportDatasetToDrive tests
+ //
===========================================================================
+
+ "exportDatasetToDrive" should "reject a session URI that does not start with
the googleapis upload prefix" in {
+ val request = DatasetResource.DriveExportRequest("http://test.com/upload")
+ intercept[BadRequestException] {
+ datasetResource.exportDatasetToDrive(baseDataset.getDid, null, null,
request, sessionUser)
Review Comment:
This test passes whether or not the session-URI check exists. With `dvid`
and `latest` both null it trips the "Specify exactly one" guard first, which
throws the same `BadRequestException`. The call is byte-identical to the one at
`:2782` that tests that other guard, so `exportDatasetToDrive`'s SSRF control
is currently unverified.
```suggestion
val latest: java.lang.Boolean = true
datasetResource.exportDatasetToDrive(baseDataset.getDid, null, latest,
request, sessionUser)
```
##########
file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala:
##########
@@ -1298,6 +1305,187 @@ class DatasetResource extends LazyLogging {
}
}
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{did}/drive-export")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def exportDatasetToDrive(
+ @PathParam("did") did: Integer,
+ @QueryParam("dvid") dvid: Integer,
+ @QueryParam("latest") latest: java.lang.Boolean,
+ request: DriveExportRequest,
+ @Auth user: SessionUser
+ ): Response = {
+ if
(!request.sessionUri.startsWith("https://www.googleapis.com/upload/drive/")) {
+ throw new BadRequestException("Invalid session URI")
+ }
+ withTransaction(context) { ctx =>
+ if ((dvid != null && latest != null) || (dvid == null && latest ==
null)) {
+ throw new BadRequestException("Specify exactly one: dvid=<ID> OR
latest=true")
+ }
+
+ val uid = user.getUid
+ if (!userHasReadAccess(ctx, did, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+ }
+
+ val dataset = getDatasetByID(ctx, did)
+ if (!userOwnDataset(ctx, did, uid) && !dataset.getIsDownloadable) {
+ throw new ForbiddenException("Dataset download is not allowed")
+ }
+
+ val datasetVersion = if (dvid != null) {
+ getDatasetVersionByID(ctx, dvid)
+ } else {
+ getLatestDatasetVersion(ctx, did).getOrElse(
+ throw new NotFoundException(ERR_DATASET_VERSION_NOT_FOUND_MESSAGE)
+ )
+ }
+
+ val repositoryName = dataset.getRepositoryName
+ val versionHash = datasetVersion.getVersionHash
+ val objects = withLakeFSErrorHandling(
+ s"listing files of version '$versionHash' of dataset
'${dataset.getName}'"
+ ) {
+ LakeFSStorageClient.retrieveObjectsOfVersion(repositoryName,
versionHash)
+ }
+
+ if (objects.isEmpty) {
+ throw new NotFoundException(s"No objects found in version
$versionHash")
+ }
+
+ val totalBytes = objects.map(_.getSizeBytes.longValue()).sum
+ // TODO: remove this limit once chunked resumable upload to Drive is
implemented
+ if (totalBytes > DRIVE_EXPORT_MAX_DATASET_BYTES) {
+ throw new BadRequestException("Dataset version exceeds the 500 MB
export limit.")
+ }
+ if (totalBytes > DRIVE_EXPORT_MAX_FILE_BYTES) {
+ throw new ForbiddenException("Dataset version exceeds the 5 TB Google
Drive export limit.")
+ }
+
+ // Build the zip in memory
+ val baos = new ByteArrayOutputStream()
+ val zipOut = new java.util.zip.ZipOutputStream(baos)
+ try {
+ objects.foreach { obj =>
+ val filePath = obj.getPath
+ val file = withLakeFSErrorHandling(s"downloading file '$filePath'
for Drive export") {
+ LakeFSStorageClient.getFileFromRepo(repositoryName, versionHash,
filePath)
+ }
+ zipOut.putNextEntry(new java.util.zip.ZipEntry(filePath))
+ Files.copy(Paths.get(file.toURI), zipOut)
+ zipOut.closeEntry()
+ }
+ } finally {
+ zipOut.close()
+ }
+
+ val zipBytes = baos.toByteArray
+ val sessionUri = request.sessionUri
+
+ // PUT the zip to the Google Drive session URI
+ val conn = new
URL(sessionUri).openConnection().asInstanceOf[HttpURLConnection]
+ try {
+ conn.setDoOutput(true)
+ conn.setRequestMethod("PUT")
+ conn.setRequestProperty("Content-Type", "application/zip")
+ conn.setFixedLengthStreamingMode(zipBytes.length)
+ val out = conn.getOutputStream
+ out.write(zipBytes)
+ out.close()
+
+ val code = conn.getResponseCode
+ if (code < 200 || code >= 300) {
+ val body =
+ Option(conn.getErrorStream).map(s => new
String(s.readAllBytes())).getOrElse("")
+ if (body.contains("storageQuotaExceeded")) {
+ throw new ForbiddenException("Google Drive storage quota
exceeded.")
+ }
+ throw new WebApplicationException(
+ s"Google Drive upload failed with HTTP $code",
+ Response.Status.BAD_GATEWAY
+ )
+ }
+ } finally {
+ conn.disconnect()
+ }
+
+ Response.ok(Map("message" -> "Dataset exported to Google Drive")).build()
+ }
+ }
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/drive-export/file")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def exportFileToDrive(
+ request: DriveFileExportRequest,
+ @Auth user: SessionUser
+ ): Response = {
+ if
(!request.sessionUri.startsWith("https://www.googleapis.com/upload/drive/")) {
+ throw new BadRequestException("Invalid session URI")
+ }
+ val uid = user.getUid
+ resolveDatasetAndPath(request.filePath, null, null, uid) match {
+ case Left(errorResponse) => errorResponse
+ case Right((repositoryName, commitHash, filePath)) =>
+ val presignedUrl =
+ withLakeFSErrorHandling(s"generating presigned URL for file
'$filePath'") {
+ LakeFSStorageClient.getFilePresignedUrl(repositoryName,
commitHash, filePath)
+ }
+
+ val minioConn = new
URL(presignedUrl).openConnection().asInstanceOf[HttpURLConnection]
+ try {
+ minioConn.setRequestMethod("GET")
+ val contentLength = minioConn.getContentLengthLong
+ if (contentLength > DRIVE_EXPORT_MAX_FILE_BYTES) {
+ throw new ForbiddenException("File exceeds the 5 TB Google Drive
export limit.")
+ }
+ val minioStream = minioConn.getInputStream
+
+ val driveConn =
+ new
URL(request.sessionUri).openConnection().asInstanceOf[HttpURLConnection]
+ try {
+ driveConn.setDoOutput(true)
+ driveConn.setRequestMethod("PUT")
+ driveConn.setRequestProperty("Content-Type",
"application/octet-stream")
+ if (contentLength > 0) {
+ driveConn.setFixedLengthStreamingMode(contentLength)
+ val out = driveConn.getOutputStream
+ minioStream.transferTo(out)
+ out.close()
+ } else {
+ val bytes = minioStream.readAllBytes()
Review Comment:
A question rather than a claim. This branch runs when `getContentLengthLong`
returns `-1`. By then the `DRIVE_EXPORT_MAX_FILE_BYTES` check at line 1441 has
already passed, since `-1` is not greater than the cap, so `readAllBytes()`
pulls the whole object onto the heap unbounded.
Can a MinIO presigned GET omit `Content-Length`? If it can, this path needs
its own cap; if not, the branch is dead.
##########
amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala:
##########
@@ -782,6 +787,41 @@ class WorkflowResource extends LazyLogging {
.fetchOneInto(classOf[String])
}
+ @POST
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{wid}/export/drive")
+ def exportWorkflowToDrive(
+ @PathParam("wid") wid: Integer,
+ request: WorkflowResource.DriveExportRequest,
+ @Auth sessionUser: SessionUser
+ ): Unit = {
+ if
(!request.sessionUri.startsWith("https://www.googleapis.com/upload/drive/"))
+ throw new BadRequestException("Invalid session URI")
+ if (!WorkflowAccessResource.hasReadAccess(wid, sessionUser.getUid))
+ throw new ForbiddenException("No sufficient access privilege.")
+ val content = workflowDao.fetchOneByWid(wid).getContent.getBytes("UTF-8")
+ if (content.length > WorkflowResource.DRIVE_EXPORT_MAX_BYTES)
+ throw new BadRequestException("Workflow content exceeds the 500 MB
export limit.")
+ val conn = new
URL(request.sessionUri).openConnection().asInstanceOf[HttpURLConnection]
+ try {
+ conn.setDoOutput(true)
+ conn.setRequestMethod("PUT")
+ conn.setRequestProperty("Content-Type", "application/json")
+ conn.setRequestProperty("Content-Length", content.length.toString)
+ conn.getOutputStream.write(content)
Review Comment:
`HttpURLConnection` writes its own `Content-Length` from the buffered body,
so this header never takes effect and the payload is buffered rather than
streamed. Both sibling endpoints in this PR use `setFixedLengthStreamingMode`
and close the stream (`DatasetResource.scala:1392`/`:1395`, `:1453`/`:1456`);
this one does neither.
```suggestion
conn.setFixedLengthStreamingMode(content.length)
val out = conn.getOutputStream
out.write(content)
out.close()
```
##########
common/config/src/main/resources/user-system.conf:
##########
@@ -27,6 +27,12 @@ user-sys {
clientId = ""
clientId = ${?USER_SYS_GOOGLE_CLIENT_ID}
+ clientSecret = ""
+ clientSecret = ${?USER_SYS_GOOGLE_CLIENT_SECRET}
+
+ apiKey = ""
+ apiKey = ${?USER_SYS_GOOGLE_API_KEY}
Review Comment:
`USER_SYS_GOOGLE_API_KEY` needs the same three companion entries its sibling
`USER_SYS_GOOGLE_CLIENT_ID` has: `EnvironmentalVariable.scala:122`,
`bin/k8s/values.yaml:321` and `bin/k8s/values-development.yaml:347`.
Without the k8s entries the variable is never injected. `googleApiKey` then
resolves to `""` and `/auth/google/drive/apikey` serves an empty string. So the
Picker breaks in Kubernetes only, while local dev keeps working because you
export the variable by hand.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]