This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7760-bf0e7779ecbe64a918beadf20181306105449677 in repository https://gitbox.apache.org/repos/asf/texera.git
commit de5d7d1043b87a1d11b9db9f6dbde82302ada6ff Author: Tanishq Gandhi <[email protected]> AuthorDate: Wed Aug 19 18:45:54 2026 +0000 refactor(file-service): share the dataset access and naming rules (#7760) ### What changes were proposed in this PR? Datasets and models need the same three rules: who owns a resource, who it is shared with, and whether a name is already taken. Today those rules live inside the dataset code, so adding models means copying them. This PR moves them into one shared implementation and points datasets at it. Since jOOQ generates an unrelated class per table, there is no common parent to inherit from — instead a small descriptor says which columns to look at, and the shared code works off that. The same duplication sat one layer down: `DatasetFileDocument` and `ModelFileDocument` each existed only to supply a presign-download URL, since the read logic was already shared in `LakeFSFileDocument`. That class now takes the resource type and works out its own endpoint, so both subclasses are deleted and one document class serves every resource type. `DatasetFileDocument`'s only other method deleted files from the pre-LakeFS local git store, which nothing reaches any more. No behaviour change for datasets, apart from one fix: `GET /access/dataset/owner/{did}` and `GET /access/dataset/list/{did}` had no authorization, so any signed-in user could read any dataset's owner email and full share list. Both now require read access. Anyone who could already see the dataset is unaffected. ### Any related issues, documentation, discussions? Prepares the shared layer for #6498 (umbrella #6494). ### How was this PR tested? The existing dataset suites are the regression evidence, since this changes how the dataset code is wired without changing what it does: - `sbt "FileService/test"` — 236/236, including `DatasetResourceSpec` (118) - `sbt "WorkflowCore/test"` — 773/773 - `scalafmtCheckAll` and `scalafixAll --check` clean `DatasetAccessResourceSpec` gains tests for the two guards. One existing expectation changed: a nonexistent id returned an empty string and now returns 403. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 5) --------- Co-authored-by: Claude Opus 5 <[email protected]> --- .../amber/core/storage/DocumentFactory.scala | 8 +- .../core/storage/model/DatasetFileDocument.scala | 64 ---- .../core/storage/model/LakeFSFileDocument.scala | 42 ++- .../core/storage/model/ModelFileDocument.scala | 43 --- .../amber/core/storage/DocumentFactorySpec.scala | 23 +- ...mentSpec.scala => LakeFSFileDocumentSpec.scala} | 56 ++-- .../service/resource/DatasetAccessResource.scala | 158 +++------- .../texera/service/resource/DatasetResource.scala | 135 ++------- .../texera/service/resource/ResourceAccess.scala | 321 +++++++++++++++++++++ .../texera/service/resource/ResourceNaming.scala | 99 +++++++ .../texera/service/resource/ResourceTables.scala | 69 +++++ .../resource/DatasetAccessResourceSpec.scala | 60 +++- 12 files changed, 696 insertions(+), 382 deletions(-) diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala index dfc63dc6a0..8481906c9c 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/DocumentFactory.scala @@ -60,8 +60,8 @@ object DocumentFactory { */ def openReadonlyDocument(fileUri: URI): ReadonlyVirtualDocument[_] = { fileUri.getScheme match { - case DATASET_FILE_URI_SCHEME => new DatasetFileDocument(fileUri) - case MODEL_FILE_URI_SCHEME => new ModelFileDocument(fileUri) + case DATASET_FILE_URI_SCHEME => new LakeFSFileDocument(fileUri, ResourceType.Datasets) + case MODEL_FILE_URI_SCHEME => new LakeFSFileDocument(fileUri, ResourceType.Models) case "file" => new ReadonlyLocalFileDocument(fileUri) case unsupportedScheme => throw new UnsupportedOperationException( @@ -184,8 +184,8 @@ object DocumentFactory { */ def openDocument(uri: URI): (VirtualDocument[_], Option[Schema]) = { uri.getScheme match { - case DATASET_FILE_URI_SCHEME => (new DatasetFileDocument(uri), None) - case MODEL_FILE_URI_SCHEME => (new ModelFileDocument(uri), None) + case DATASET_FILE_URI_SCHEME => (new LakeFSFileDocument(uri, ResourceType.Datasets), None) + case MODEL_FILE_URI_SCHEME => (new LakeFSFileDocument(uri, ResourceType.Models), None) case VFS_FILE_URI_SCHEME => val IcebergLocation(warehouse, namespace, storageKey) = resolveIcebergLocation(uri) diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala deleted file mode 100644 index f2862c87ee..0000000000 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocument.scala +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package org.apache.texera.amber.core.storage.model - -import org.apache.texera.common.config.EnvironmentalVariable -import org.apache.texera.amber.core.storage.model.DatasetFileDocument.fileServiceGetPresignURLEndpoint -import org.apache.texera.amber.core.storage.util.dataset.GitVersionControlLocalFileStorage - -import java.net.URI -import java.nio.file.Path - -object DatasetFileDocument { - // The endpoint of getting presigned url from the file service, also stored in the environment vars. - lazy val fileServiceGetPresignURLEndpoint: String = - sys.env - .getOrElse( - EnvironmentalVariable.ENV_FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT, - "http://localhost:9092/api/dataset/presign-download" - ) - .trim -} - -private[storage] class DatasetFileDocument(uri: URI) - extends LakeFSFileDocument(uri, fileServiceGetPresignURLEndpoint) { - - override def clear(): Unit = { - // first remove the temporary file (handled by the shared base) - super.clear() - - lazy val datasetsRootPath = - Path - .of(sys.env.getOrElse("TEXERA_HOME", ".")) - .resolve("amber") - .resolve("user-resources") - .resolve("datasets") - - def getDatasetPath(did: Integer): Path = { - datasetsRootPath.resolve(did.toString) - } - - // then remove the dataset file from the local git-backed storage - GitVersionControlLocalFileStorage.removeFileFromRepo( - getDatasetPath(0), - getDatasetPath(0).resolve(fileRelativePath) - ) - } -} diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocument.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocument.scala index 044437889e..b821ff72f5 100644 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocument.scala +++ b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocument.scala @@ -21,6 +21,7 @@ package org.apache.texera.amber.core.storage.model import com.typesafe.scalalogging.LazyLogging import org.apache.texera.common.config.EnvironmentalVariable +import org.apache.texera.amber.core.storage.ResourceType import org.apache.texera.amber.core.storage.model.LakeFSFileDocument.userJwtToken import org.apache.texera.amber.core.storage.util.LakeFSStorageClient @@ -36,21 +37,50 @@ object LakeFSFileDocument { // In the local development or other architectures, this token can be empty. lazy val userJwtToken: String = sys.env.getOrElse(EnvironmentalVariable.ENV_USER_JWT_TOKEN, "").trim + + private lazy val datasetPresignEndpoint: String = + sys.env + .getOrElse( + EnvironmentalVariable.ENV_FILE_SERVICE_GET_DATASET_PRESIGNED_URL_ENDPOINT, + "http://localhost:9092/api/dataset/presign-download" + ) + .trim + + private lazy val modelPresignEndpoint: String = + sys.env + .getOrElse( + EnvironmentalVariable.ENV_FILE_SERVICE_GET_MODEL_PRESIGNED_URL_ENDPOINT, + "http://localhost:9092/api/model/presign-download" + ) + .trim + + /** + * The file-service presign-download endpoint serving this resource type. Each resource type + * owns an endpoint because they enforce different access control + */ + def presignEndpointOf(resourceType: ResourceType.Value): String = + resourceType match { + case ResourceType.Datasets => datasetPresignEndpoint + case ResourceType.Models => modelPresignEndpoint + } } /** * A read-only document over a single file stored in a LakeFS repository, addressed by the URI - * {scheme}:///{repositoryName}/{versionHash}/{fileRelativePath}. This is the shared behavior - * for every versioned-file resource (datasets, models, …): the file bytes are fetched via a - * presigned URL, falling back to a direct LakeFS fetch. + * {scheme}:///{repositoryName}/{versionHash}/{fileRelativePath}. + * + * Every versioned-file resource (datasets, models, …) reads its files the same way — fetch the + * bytes through a presigned URL, falling back to a direct LakeFS fetch * - * @param uri the resolved {scheme}:///{repositoryName}/{versionHash}/{file} URI - * @param presignEndpoint the file-service presign-download endpoint for this resource kind + * @param uri the resolved {scheme}:///{repositoryName}/{versionHash}/{file} URI + * @param resourceType which resource this file belongs to, selecting the presign endpoint */ -private[storage] abstract class LakeFSFileDocument(uri: URI, presignEndpoint: String) +private[storage] class LakeFSFileDocument(uri: URI, val resourceType: ResourceType.Value) extends VirtualDocument[Nothing] with OnVersionedFileResource with LazyLogging { + + private val presignEndpoint: String = LakeFSFileDocument.presignEndpointOf(resourceType) // Utility function to parse and decode URI segments into individual components private def parseUri(uri: URI): (String, String, Path) = { val segments = Paths.get(uri.getPath).iterator().asScala.map(_.toString).toArray diff --git a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/ModelFileDocument.scala b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/ModelFileDocument.scala deleted file mode 100644 index e67ec92992..0000000000 --- a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/model/ModelFileDocument.scala +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package org.apache.texera.amber.core.storage.model - -import org.apache.texera.common.config.EnvironmentalVariable -import org.apache.texera.amber.core.storage.model.ModelFileDocument.fileServiceGetModelPresignURLEndpoint - -import java.net.URI - -object ModelFileDocument { - // The endpoint of getting a presigned url for a model file from the file service. - lazy val fileServiceGetModelPresignURLEndpoint: String = - sys.env - .getOrElse( - EnvironmentalVariable.ENV_FILE_SERVICE_GET_MODEL_PRESIGNED_URL_ENDPOINT, - "http://localhost:9092/api/model/presign-download" - ) - .trim -} - -/** - * A read-only document over a single file in a model's LakeFS repository (`model-{mid}`), - * addressed by a `model:///{repositoryName}/{versionHash}/{fileRelativePath}` URI. - */ -private[storage] class ModelFileDocument(uri: URI) - extends LakeFSFileDocument(uri, fileServiceGetModelPresignURLEndpoint) diff --git a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala index eb18e87762..a957819b31 100644 --- a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala +++ b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/DocumentFactorySpec.scala @@ -20,8 +20,7 @@ package org.apache.texera.amber.core.storage import org.apache.texera.amber.core.storage.model.{ - DatasetFileDocument, - ModelFileDocument, + LakeFSFileDocument, OnVersionedFileResource, ReadonlyLocalFileDocument, VirtualDocument @@ -134,17 +133,19 @@ class DocumentFactorySpec extends AnyFlatSpec with Matchers with BeforeAndAfterA private val versionHash = "97fd4c2a755b69b7c66d322eab40b7e5c2ad5d10" - "openReadonlyDocument" should "return a DatasetFileDocument for the dataset scheme" in { + "openReadonlyDocument" should "return a dataset-typed LakeFSFileDocument for the dataset scheme" in { val datasetUri = new URI(s"dataset:///repo/$versionHash/file.txt") val doc = DocumentFactory.openReadonlyDocument(datasetUri) - doc shouldBe a[DatasetFileDocument] + doc shouldBe a[LakeFSFileDocument] + doc.asInstanceOf[LakeFSFileDocument].resourceType shouldBe ResourceType.Datasets doc.getURI shouldBe datasetUri } - it should "return a ModelFileDocument for the model scheme and parse its URI components" in { + it should "return a model-typed LakeFSFileDocument for the model scheme and parse its URI components" in { val modelUri = new URI(s"model:///model-1/$versionHash/weights/model.pt") val doc = DocumentFactory.openReadonlyDocument(modelUri) - doc shouldBe a[ModelFileDocument] + doc shouldBe a[LakeFSFileDocument] + doc.asInstanceOf[LakeFSFileDocument].resourceType shouldBe ResourceType.Models doc.getURI shouldBe modelUri val resource = doc.asInstanceOf[OnVersionedFileResource] @@ -176,17 +177,19 @@ class DocumentFactorySpec extends AnyFlatSpec with Matchers with BeforeAndAfterA // openDocument / createDocument / documentExists -- unsupported schemes // --------------------------------------------------------------------------- - "openDocument" should "return a DatasetFileDocument and no schema for the dataset scheme" in { + "openDocument" should "return a dataset-typed LakeFSFileDocument and no schema for the dataset scheme" in { val datasetUri = new URI(s"dataset:///repo/$versionHash/file.txt") val (doc, schemaOpt) = DocumentFactory.openDocument(datasetUri) - doc shouldBe a[DatasetFileDocument] + doc shouldBe a[LakeFSFileDocument] + doc.asInstanceOf[LakeFSFileDocument].resourceType shouldBe ResourceType.Datasets schemaOpt shouldBe None } - it should "return a ModelFileDocument and no schema for the model scheme" in { + it should "return a model-typed LakeFSFileDocument and no schema for the model scheme" in { val modelUri = new URI(s"model:///model-1/$versionHash/weights/model.pt") val (doc, schemaOpt) = DocumentFactory.openDocument(modelUri) - doc shouldBe a[ModelFileDocument] + doc shouldBe a[LakeFSFileDocument] + doc.asInstanceOf[LakeFSFileDocument].resourceType shouldBe ResourceType.Models schemaOpt shouldBe None } diff --git a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocumentSpec.scala similarity index 77% rename from common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala rename to common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocumentSpec.scala index 5a313c2763..56d5cd36ce 100644 --- a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/DatasetFileDocumentSpec.scala +++ b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/model/LakeFSFileDocumentSpec.scala @@ -20,6 +20,7 @@ package org.apache.texera.amber.core.storage.model import org.apache.texera.common.config.EnvironmentalVariable +import org.apache.texera.amber.core.storage.ResourceType import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers @@ -27,15 +28,18 @@ import java.net.{URI, URLEncoder} import java.nio.charset.StandardCharsets import java.nio.file.Paths -class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { +class LakeFSFileDocumentSpec extends AnyFlatSpec with Matchers { // Realistic 40-char git commit hash, mirroring the URIs produced by FileResolver // (format: dataset:///{repositoryName}/{versionHash}/{fileRelativePath}). private val versionHash = "97fd4c2a755b69b7c66d322eab40b7e5c2ad5d10" - "DatasetFileDocument" should "parse a valid 3-segment dataset URI into its components" in { + // URI parsing is shared by every resource type; exercise it through the dataset type. + private def datasetDoc(uri: URI) = new LakeFSFileDocument(uri, ResourceType.Datasets) + + "LakeFSFileDocument" should "parse a valid 3-segment dataset URI into its components" in { val uri = new URI(s"dataset:///test_dataset/$versionHash/1.txt") - val doc = new DatasetFileDocument(uri) + val doc = datasetDoc(uri) doc.getRepositoryName() shouldBe "test_dataset" doc.getVersionHash() shouldBe versionHash @@ -44,7 +48,7 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { it should "join multi-segment relative paths correctly" in { val uri = new URI(s"dataset:///my_repo/$versionHash/some/nested/dir/data.csv") - val doc = new DatasetFileDocument(uri) + val doc = datasetDoc(uri) doc.getRepositoryName() shouldBe "my_repo" doc.getVersionHash() shouldBe versionHash @@ -55,7 +59,7 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { // FileResolver URL-encodes segments and then builds the URI with the multi-arg // constructor, so uri.getPath still contains URLEncoder-encoded segments. val uri = new URI("dataset", "", "/repo/hash%20with%2Bspecials/file.txt", null) - val doc = new DatasetFileDocument(uri) + val doc = datasetDoc(uri) doc.getVersionHash() shouldBe "hash with+specials" doc.getFileRelativePath() shouldBe "file.txt" @@ -63,7 +67,7 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { it should "URL-decode each relative path segment" in { val uri = new URI("dataset", "", "/repo/hash/dir+one/file%23two%20a.csv", null) - val doc = new DatasetFileDocument(uri) + val doc = datasetDoc(uri) doc.getRepositoryName() shouldBe "repo" doc.getVersionHash() shouldBe "hash" @@ -72,7 +76,7 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { it should "return the parsed components and the original URI through its getters" in { val uri = new URI("dataset", "", s"/repo/$versionHash/a%20b/c.csv", null) - val doc = new DatasetFileDocument(uri) + val doc = datasetDoc(uri) doc.getRepositoryName() shouldBe "repo" doc.getVersionHash() shouldBe versionHash @@ -89,7 +93,7 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { // URI constructor is required here: a single-arg URI already percent-decodes // getPath, so "%20" in a raw URI string would reach parseUri as a space. val uri = new URI("dataset", "", "/repo%20name/hash%20value/file.txt", null) - val doc = new DatasetFileDocument(uri) + val doc = datasetDoc(uri) doc.getRepositoryName() shouldBe "repo%20name" // Same encoded token in the version-hash position IS decoded (asymmetry pin). @@ -102,7 +106,7 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { val encodedPath = rawSegments.map(URLEncoder.encode(_, StandardCharsets.UTF_8)).mkString("/") val uri = new URI("dataset", "", s"/repo/$versionHash/$encodedPath", null) - val doc = new DatasetFileDocument(uri) + val doc = datasetDoc(uri) doc.getFileRelativePath() shouldBe Paths.get(rawSegments.head, rawSegments.tail: _*).toString } @@ -110,7 +114,7 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { it should "collapse redundant and trailing slashes in the URI path" in { // Paths.get collapses duplicate separators and ignores a trailing slash, // so this still yields exactly the three segments [repo, hash, file.txt]. - val doc = new DatasetFileDocument(new URI("dataset:///repo//hash///file.txt/")) + val doc = datasetDoc(new URI("dataset:///repo//hash///file.txt/")) doc.getRepositoryName() shouldBe "repo" doc.getVersionHash() shouldBe "hash" @@ -120,10 +124,10 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { it should "preserve dot segments in the relative path without normalization (current behavior)" in { // "." and ".." segments are kept verbatim (current behavior): the relative // path is passed downstream un-normalized, with no sanitization applied. - val parentDoc = new DatasetFileDocument(new URI("dataset:///repo/hash/../x.csv")) + val parentDoc = datasetDoc(new URI("dataset:///repo/hash/../x.csv")) parentDoc.getFileRelativePath() shouldBe Paths.get("..", "x.csv").toString - val dotDoc = new DatasetFileDocument(new URI("dataset:///repo/hash/./sub/../x.csv")) + val dotDoc = datasetDoc(new URI("dataset:///repo/hash/./sub/../x.csv")) dotDoc.getFileRelativePath() shouldBe Paths.get(".", "sub", "..", "x.csv").toString } @@ -136,7 +140,7 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { ) invalidUris.foreach { uri => val thrown = intercept[IllegalArgumentException] { - new DatasetFileDocument(uri) + datasetDoc(uri) } thrown.getMessage shouldBe "URI format is incorrect" } @@ -147,8 +151,8 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { // asInputStream needs to fetch a file; assert its fallback behavior without // requiring a live FileService or LakeFS. The check is guarded so it holds // regardless of whether the env override is present. - "DatasetFileDocument companion" should - "expose the default presigned-URL endpoint when the env override is absent" in { + "the companion" should + "resolve the dataset presigned-URL endpoint, defaulting when the env override is absent" in { val expected = sys.env .getOrElse( @@ -156,12 +160,26 @@ class DatasetFileDocumentSpec extends AnyFlatSpec with Matchers { "http://localhost:9092/api/dataset/presign-download" ) .trim - DatasetFileDocument.fileServiceGetPresignURLEndpoint shouldBe expected + LakeFSFileDocument.presignEndpointOf(ResourceType.Datasets) shouldBe expected + } + + // Each resource type resolves its own endpoint: a dataset grant must not authorize a model + // file, so the two presign endpoints stay distinct. + it should "resolve the model presigned-URL endpoint, defaulting when the env override is absent" in { + val expected = + sys.env + .getOrElse( + EnvironmentalVariable.ENV_FILE_SERVICE_GET_MODEL_PRESIGNED_URL_ENDPOINT, + "http://localhost:9092/api/model/presign-download" + ) + .trim + LakeFSFileDocument.presignEndpointOf(ResourceType.Models) shouldBe expected + LakeFSFileDocument.presignEndpointOf(ResourceType.Models) should not be + LakeFSFileDocument.presignEndpointOf(ResourceType.Datasets) } - // The user JWT token is shared by every LakeFS-backed document, so it now lives on - // the LakeFSFileDocument base object rather than on DatasetFileDocument. - "LakeFSFileDocument companion" should "expose a trimmed user JWT token defaulting to empty" in { + // The user JWT token is shared by every LakeFS-backed document, whatever its resource type. + it should "expose a trimmed user JWT token defaulting to empty" in { val expected = sys.env.getOrElse(EnvironmentalVariable.ENV_USER_JWT_TOKEN, "").trim LakeFSFileDocument.userJwtToken shouldBe expected diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetAccessResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetAccessResource.scala index e03529fd7c..ad26338968 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetAccessResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetAccessResource.scala @@ -26,18 +26,11 @@ import jakarta.ws.rs._ import org.apache.texera.auth.SessionUser import org.apache.texera.dao.SqlServer import org.apache.texera.dao.SqlServer.withTransaction -import org.apache.texera.dao.jooq.generated.Tables.USER import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum -import org.apache.texera.dao.jooq.generated.tables.DatasetUserAccess.DATASET_USER_ACCESS -import org.apache.texera.dao.jooq.generated.tables.daos.{DatasetDao, DatasetUserAccessDao, UserDao} -import org.apache.texera.dao.jooq.generated.tables.pojos.{DatasetUserAccess, User} -import org.apache.texera.service.resource.DatasetAccessResource.{ - AccessEntry, - context, - getOwner, - userHasWriteAccess -} -import org.jooq.{DSLContext, EnumType} +import org.apache.texera.dao.jooq.generated.tables.pojos.User +import org.apache.texera.service.resource.DatasetAccessResource.context +import org.apache.texera.service.resource.ResourceTables.{Dataset => DATASET_RESOURCE} +import org.jooq.DSLContext object DatasetAccessResource { private def context: DSLContext = @@ -45,61 +38,29 @@ object DatasetAccessResource { .getInstance() .createDSLContext() - def isDatasetPublic(ctx: DSLContext, did: Integer): Boolean = { - val datasetDao = new DatasetDao(ctx.configuration()) - Option(datasetDao.fetchOneByDid(did)) - .flatMap(dataset => Option(dataset.getIsPublic)) - .contains(true) - } + type AccessEntry = ResourceAccess.AccessEntry + val AccessEntry: ResourceAccess.AccessEntry.type = ResourceAccess.AccessEntry - def userHasReadAccess(ctx: DSLContext, did: Integer, uid: Integer): Boolean = { - isDatasetPublic(ctx, did) || - userHasWriteAccess(ctx, did, uid) || - getDatasetUserAccessPrivilege(ctx, did, uid) == PrivilegeEnum.READ - } + def isDatasetPublic(ctx: DSLContext, did: Integer): Boolean = + ResourceAccess.isPublic(ctx, DATASET_RESOURCE, did) - def userOwnDataset(ctx: DSLContext, did: Integer, uid: Integer): Boolean = { - val datasetDao = new DatasetDao(ctx.configuration()) + def userHasReadAccess(ctx: DSLContext, did: Integer, uid: Integer): Boolean = + ResourceAccess.userHasReadAccess(ctx, DATASET_RESOURCE, did, uid) - Option(datasetDao.fetchOneByDid(did)) - .exists(_.getOwnerUid == uid) - } + def userOwnDataset(ctx: DSLContext, did: Integer, uid: Integer): Boolean = + ResourceAccess.userOwns(ctx, DATASET_RESOURCE, did, uid) - def userHasWriteAccess(ctx: DSLContext, did: Integer, uid: Integer): Boolean = { - userOwnDataset(ctx, did, uid) || - getDatasetUserAccessPrivilege(ctx, did, uid) == PrivilegeEnum.WRITE - } + def userHasWriteAccess(ctx: DSLContext, did: Integer, uid: Integer): Boolean = + ResourceAccess.userHasWriteAccess(ctx, DATASET_RESOURCE, did, uid) def getDatasetUserAccessPrivilege( ctx: DSLContext, did: Integer, uid: Integer - ): PrivilegeEnum = { - Option( - ctx - .select(DATASET_USER_ACCESS.PRIVILEGE) - .from(DATASET_USER_ACCESS) - .where( - DATASET_USER_ACCESS.DID - .eq(did) - .and(DATASET_USER_ACCESS.UID.eq(uid)) - ) - .fetchOneInto(classOf[PrivilegeEnum]) - ).getOrElse(PrivilegeEnum.NONE) - } - - def getOwner(ctx: DSLContext, did: Integer): User = { - val datasetDao = new DatasetDao(ctx.configuration()) - val userDao = new UserDao(ctx.configuration()) - - Option(datasetDao.fetchOneByDid(did)) - .flatMap(dataset => Option(dataset.getOwnerUid)) - .map(ownerUid => userDao.fetchOneByUid(ownerUid)) - .orNull - } - - case class AccessEntry(email: String, name: String, privilege: EnumType) {} + ): PrivilegeEnum = ResourceAccess.privilegeOf(ctx, DATASET_RESOURCE, did, uid) + def getOwner(ctx: DSLContext, did: Integer): User = + ResourceAccess.owner(ctx, DATASET_RESOURCE, did) } @Produces(Array(MediaType.APPLICATION_JSON)) @@ -115,16 +76,13 @@ class DatasetAccessResource { */ @GET @Path("/owner/{did}") - def getOwnerEmailOfDataset(@PathParam("did") did: Integer): String = { - var email = "" - withTransaction(context) { ctx => - val owner = getOwner(ctx, did) - if (owner != null) { - email = owner.getEmail - } - } - email - } + def getOwnerEmailOfDataset( + @PathParam("did") did: Integer, + @Auth user: SessionUser + ): String = + withTransaction(context)(ctx => + ResourceAccess.ownerEmail(ctx, DATASET_RESOURCE, did, user.getUid) + ) /** * Returns information about all current shared access of the given dataset @@ -135,27 +93,12 @@ class DatasetAccessResource { @GET @Path("/list/{did}") def getAccessList( - @PathParam("did") did: Integer - ): java.util.List[AccessEntry] = { - withTransaction(context) { ctx => - val datasetDao = new DatasetDao(ctx.configuration()) - ctx - .select( - USER.EMAIL, - USER.NAME, - DATASET_USER_ACCESS.PRIVILEGE - ) - .from(DATASET_USER_ACCESS) - .join(USER) - .on(USER.UID.eq(DATASET_USER_ACCESS.UID)) - .where( - DATASET_USER_ACCESS.DID - .eq(did) - .and(DATASET_USER_ACCESS.UID.notEqual(datasetDao.fetchOneByDid(did).getOwnerUid)) - ) - .fetchInto(classOf[AccessEntry]) - } - } + @PathParam("did") did: Integer, + @Auth user: SessionUser + ): java.util.List[DatasetAccessResource.AccessEntry] = + withTransaction(context)(ctx => + ResourceAccess.accessList(ctx, DATASET_RESOURCE, did, user.getUid) + ) /** * This method shares a dataset to a user with a specific access type @@ -172,27 +115,10 @@ class DatasetAccessResource { @PathParam("email") email: String, @PathParam("privilege") privilege: String, @Auth user: SessionUser - ): Response = { + ): Response = withTransaction(context) { ctx => - if (!userHasWriteAccess(ctx, did, user.getUid)) { - throw new ForbiddenException(s"You do not have permission to modify dataset $did") - } - val datasetUserAccessDao = new DatasetUserAccessDao(ctx.configuration()) - val userDao = new UserDao(ctx.configuration()) - val targetUser = userDao.fetchOneByEmail(email) - if (targetUser == null || targetUser.getIsPlaceholder) { - throw new BadRequestException(s"No registered user with email $email") - } - datasetUserAccessDao.merge( - new DatasetUserAccess( - did, - targetUser.getUid, - PrivilegeEnum.valueOf(privilege) - ) - ) - Response.ok().build() + ResourceAccess.grant(ctx, DATASET_RESOURCE, did, email, privilege, user.getUid) } - } /** * This method revoke the user's access of the given dataset @@ -207,24 +133,8 @@ class DatasetAccessResource { @PathParam("did") did: Integer, @PathParam("email") email: String, @Auth user: SessionUser - ): Response = { + ): Response = withTransaction(context) { ctx => - if (!userHasWriteAccess(ctx, did, user.getUid)) { - throw new ForbiddenException(s"You do not have permission to modify dataset $did") - } - - val userDao = new UserDao(ctx.configuration()) - - ctx - .delete(DATASET_USER_ACCESS) - .where( - DATASET_USER_ACCESS.UID - .eq(userDao.fetchOneByEmail(email).getUid) - .and(DATASET_USER_ACCESS.DID.eq(did)) - ) - .execute() - - Response.ok().build() + ResourceAccess.revoke(ctx, DATASET_RESOURCE, did, email, user.getUid) } - } } diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index 75391149c6..8bb1a70661 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -51,6 +51,7 @@ import org.apache.texera.dao.jooq.generated.tables.pojos.{ } import org.apache.texera.service.`type`.DatasetFileNode import org.apache.texera.service.resource.DatasetAccessResource._ +import org.apache.texera.service.resource.ResourceTables.{Dataset => DATASET_RESOURCE} import org.apache.texera.service.resource.DatasetResource.{context, _} import org.apache.texera.service.util.S3StorageClient import org.apache.texera.service.util.S3StorageClient.{ @@ -407,17 +408,7 @@ class DatasetResource extends LazyLogging { val isDatasetDownloadable = request.isDatasetDownloadable validateDatasetName(datasetName) - - // Check if a dataset with the same name already exists - val duplicateExists = ctx.fetchExists( - ctx - .selectFrom(DATASET) - .where(DATASET.OWNER_UID.eq(uid)) - .and(DATASET.NAME.eq(datasetName)) - ) - if (duplicateExists) { - throw new BadRequestException("Dataset with the same name already exists") - } + ResourceNaming.requireNameAvailable(ctx, DATASET_RESOURCE, uid, datasetName) // insert the dataset into the database val dataset = new Dataset() @@ -672,18 +663,13 @@ class DatasetResource extends LazyLogging { } validateDatasetName(modificator.name) - - // Check if the owner already has another dataset with the same name - val duplicateExists = ctx.fetchExists( - ctx - .selectFrom(DATASET) - .where(DATASET.OWNER_UID.eq(dataset.getOwnerUid)) - .and(DATASET.NAME.eq(modificator.name)) - .and(DATASET.DID.notEqual(dataset.getDid)) + ResourceNaming.requireNameAvailable( + ctx, + DATASET_RESOURCE, + dataset.getOwnerUid, + modificator.name, + excludingId = Some(dataset.getDid) ) - if (duplicateExists) { - throw new BadRequestException("Dataset with the same name already exists") - } dataset.setName(modificator.name) failOnDuplicateDatasetName { @@ -1305,49 +1291,24 @@ class DatasetResource extends LazyLogging { ): List[DashboardDataset] = { val uid = user.getUid withTransaction(context)(ctx => { - var accessibleDatasets: ListBuffer[DashboardDataset] = ListBuffer() - // first fetch all datasets user have explicit access to - accessibleDatasets = ListBuffer.from( - ctx - .select() - .from( - DATASET - .leftJoin(DATASET_USER_ACCESS) - .on(DATASET_USER_ACCESS.DID.eq(DATASET.DID)) - .leftJoin(USER) - .on(USER.UID.eq(DATASET.OWNER_UID)) - ) - .where(DATASET_USER_ACCESS.UID.eq(uid)) - .fetch() - .map(record => { - val dataset = record.into(DATASET).into(classOf[Dataset]) - val datasetAccess = record.into(DATASET_USER_ACCESS).into(classOf[DatasetUserAccess]) - val ownerEmail = record.into(USER).getEmail + ResourceAccess.listVisible( + ctx, + DATASET_RESOURCE, + uid, + classOf[Dataset], + (dataset: Dataset) => dataset.getDid + )( + fromGrant = (dataset, ownerEmail, privilege, isOwner) => + Some( DashboardDataset( - isOwner = dataset.getOwnerUid == uid, + isOwner = isOwner, dataset = dataset, - accessPrivilege = datasetAccess.getPrivilege, + accessPrivilege = privilege, ownerEmail = ownerEmail, size = 0 ) - }) - .asScala - ) - - // then we fetch the public datasets and merge it as a part of the result if not exist - val publicDatasets = ctx - .select() - .from( - DATASET - .leftJoin(USER) - .on(USER.UID.eq(DATASET.OWNER_UID)) - ) - .where(DATASET.IS_PUBLIC.eq(true)) - .fetch() - .asScala - .flatMap { record => - val dataset = record.into(DATASET).into(classOf[Dataset]) - val ownerEmail = record.into(USER).getEmail + ), + fromPublic = (dataset, ownerEmail) => try { Some( DashboardDataset( @@ -1366,13 +1327,7 @@ class DatasetResource extends LazyLogging { ) None } - } - publicDatasets.foreach { publicDataset => - if (!accessibleDatasets.exists(_.dataset.getDid == publicDataset.dataset.getDid)) { - accessibleDatasets = accessibleDatasets :+ publicDataset - } - } - accessibleDatasets.toList + ) }) } @@ -1606,47 +1561,13 @@ class DatasetResource extends LazyLogging { .fetchInto(classOf[String]) } - private val DATASET_NAME_MAX_LENGTH = 128 - private val DATASET_NAME_PATTERN = "^[A-Za-z0-9_-]+$".r + /** @see [[ResourceNaming.validateName]] */ + private def validateDatasetName(name: String): Unit = + ResourceNaming.validateName(DATASET_RESOURCE.label, name) - /** - * Validates the dataset name. - * - * Rules: - * - Must be 1 to 128 characters long. - * - Only letters, numbers, underscores, and hyphens are allowed. - * - * @param name The dataset name to validate. - * @throws jakarta.ws.rs.BadRequestException if the name is invalid. - */ - private def validateDatasetName(name: String): Unit = { - if (name == null || !DATASET_NAME_PATTERN.matches(name)) { - throw new BadRequestException( - "Invalid dataset name: only letters, numbers, underscores, and hyphens are allowed." - ) - } - if (name.length > DATASET_NAME_MAX_LENGTH) { - throw new BadRequestException( - s"Invalid dataset name: name must be at most $DATASET_NAME_MAX_LENGTH characters long." - ) - } - } - - /** - * Runs a dataset write and translates a (owner_uid, name) unique-constraint - * violation into the same BadRequestException the pre-checks throw, so - * requests losing a concurrent race get a 400 instead of a 500. - */ - private[resource] def failOnDuplicateDatasetName[T](op: => T): T = { - try op - catch { - case e: DataAccessException => - if (e.sqlState() == "23505") { - throw new BadRequestException("Dataset with the same name already exists") - } - throw e - } - } + /** @see [[ResourceNaming.failOnDuplicateName]] */ + private[resource] def failOnDuplicateDatasetName[T](op: => T): T = + ResourceNaming.failOnDuplicateName(DATASET_RESOURCE.label)(op) private def fetchDatasetVersions(ctx: DSLContext, did: Integer): List[DatasetVersion] = { ctx diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala new file mode 100644 index 0000000000..6fffd1d335 --- /dev/null +++ b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceAccess.scala @@ -0,0 +1,321 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.resource + +import jakarta.ws.rs.{BadRequestException, ForbiddenException} +import jakarta.ws.rs.core.Response +import org.apache.texera.dao.jooq.generated.Tables.USER +import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum +import org.apache.texera.dao.jooq.generated.tables.daos.UserDao +import org.apache.texera.dao.jooq.generated.tables.pojos.User +import org.jooq.{DSLContext, EnumType, Record} + +import scala.jdk.CollectionConverters._ + +/** + * Ownership and privilege rules shared by every access-controlled resource. + * + * A resource is readable when it is public, or the caller owns it, or the caller holds an + * explicit grant; it is writable when the caller owns it or holds a WRITE grant. + */ +object ResourceAccess { + + /** One shared grant, as returned by the access-list endpoints. */ + case class AccessEntry(email: String, name: String, privilege: EnumType) {} + + def isPublic[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer + ): Boolean = + Option( + ctx + .select(resource.isPublicField) + .from(resource.table) + .where(resource.idField.eq(id)) + .fetchOne() + ).flatMap(record => Option(record.value1())) + .exists(_.booleanValue()) + + def userOwns[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + uid: Integer + ): Boolean = + Option( + ctx + .select(resource.ownerUidField) + .from(resource.table) + .where(resource.idField.eq(id)) + .fetchOne() + ).flatMap(record => Option(record.value1())) + .contains(uid) + + def privilegeOf[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + uid: Integer + ): PrivilegeEnum = + Option( + ctx + .select(resource.privilegeField) + .from(resource.accessTable) + .where( + resource.accessIdField + .eq(id) + .and(resource.accessUidField.eq(uid)) + ) + .fetchOneInto(classOf[PrivilegeEnum]) + ).getOrElse(PrivilegeEnum.NONE) + + def userHasWriteAccess[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + uid: Integer + ): Boolean = + userOwns(ctx, resource, id, uid) || + privilegeOf(ctx, resource, id, uid) == PrivilegeEnum.WRITE + + def userHasReadAccess[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + uid: Integer + ): Boolean = + isPublic(ctx, resource, id) || + userHasWriteAccess(ctx, resource, id, uid) || + privilegeOf(ctx, resource, id, uid) == PrivilegeEnum.READ + + /** The owning user, or null when the resource does not exist. */ + def owner[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer + ): User = { + val userDao = new UserDao(ctx.configuration()) + Option( + ctx + .select(resource.ownerUidField) + .from(resource.table) + .where(resource.idField.eq(id)) + .fetchOne() + ).flatMap(record => Option(record.value1())) + .map(ownerUid => userDao.fetchOneByUid(ownerUid)) + .orNull + } + + /** + * The owner's email. + * + * @throws jakarta.ws.rs.ForbiddenException if the caller cannot read the resource. + */ + def ownerEmail[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + requesterUid: Integer + ): String = { + requireReadAccess(ctx, resource, id, requesterUid) + Option(owner(ctx, resource, id)).map(_.getEmail).getOrElse("") + } + + /** + * Everyone the resource is shared with, excluding the owner's own row. + * + * Read access is required rather than write, + * + * @throws jakarta.ws.rs.ForbiddenException if the caller cannot read the resource. + */ + def accessList[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + requesterUid: Integer + ): java.util.List[AccessEntry] = { + requireReadAccess(ctx, resource, id, requesterUid) + val ownerUid = ctx + .select(resource.ownerUidField) + .from(resource.table) + .where(resource.idField.eq(id)) + .fetchOne() + .value1() + + ctx + .select(USER.EMAIL, USER.NAME, resource.privilegeField) + .from(resource.accessTable) + .join(USER) + .on(USER.UID.eq(resource.accessUidField)) + .where( + resource.accessIdField + .eq(id) + .and(resource.accessUidField.notEqual(ownerUid)) + ) + .fetchInto(classOf[AccessEntry]) + } + + /** + * Every resource of this type the user may see: the ones they hold an explicit grant on, plus + * every public one, with public entries dropped when they duplicate a granted entry. + * + * @param pojoClass the generated POJO the resource table maps into + * @param idOf reads the resource's id, used to de-duplicate the two passes + * @param fromGrant builds an entry the user has an explicit grant on + * @param fromPublic builds an entry visible only because the resource is public + */ + def listVisible[R <: Record, A <: Record, P, D]( + ctx: DSLContext, + resource: ResourceTables[R, A], + uid: Integer, + pojoClass: Class[P], + idOf: P => Integer + )( + fromGrant: (P, String, PrivilegeEnum, Boolean) => Option[D], + fromPublic: (P, String) => Option[D] + ): List[D] = { + // (id, entry) pairs so the public pass can skip ids already granted, without re-querying + val granted: List[(Integer, D)] = ctx + .select() + .from( + resource.table + .leftJoin(resource.accessTable) + .on(resource.accessIdField.eq(resource.idField)) + .leftJoin(USER) + .on(USER.UID.eq(resource.ownerUidField)) + ) + .where(resource.accessUidField.eq(uid)) + .fetch() + .asScala + .toList + .flatMap { record => + val entity = record.into(resource.table).into(pojoClass) + val privilege = record.into(resource.accessTable).get(resource.privilegeField) + val isOwner = record.into(resource.table).get(resource.ownerUidField) == uid + fromGrant(entity, record.into(USER).getEmail, privilege, isOwner) + .map(entry => (idOf(entity), entry)) + } + + val grantedIds = granted.map(_._1).toSet + + val public = ctx + .select() + .from( + resource.table + .leftJoin(USER) + .on(USER.UID.eq(resource.ownerUidField)) + ) + .where(resource.isPublicField.eq(true)) + .fetch() + .asScala + .toList + .flatMap { record => + val entity = record.into(resource.table).into(pojoClass) + if (grantedIds.contains(idOf(entity))) None + else fromPublic(entity, record.into(USER).getEmail) + } + + granted.map(_._2) ++ public + } + + /** + * Grants `privilege` to the user with `email`, replacing any privilege they already hold. + */ + def grant[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + email: String, + privilege: String, + requesterUid: Integer + ): Response = { + requireWriteAccess(ctx, resource, id, requesterUid) + val grantee = new UserDao(ctx.configuration()).fetchOneByEmail(email) + if (grantee == null || grantee.getIsPlaceholder) { + throw new BadRequestException(s"No registered user with email $email") + } + val granteeUid = grantee.getUid + val granted = PrivilegeEnum.valueOf(privilege) + + ctx + .insertInto(resource.accessTable) + .set(resource.accessIdField, id) + .set(resource.accessUidField, granteeUid) + .set(resource.privilegeField, granted) + .onConflict(resource.accessIdField, resource.accessUidField) + .doUpdate() + .set(resource.privilegeField, granted) + .execute() + + Response.ok().build() + } + + /** + * Removes the user's explicit grant; a no-op when they hold none. + * + * @throws jakarta.ws.rs.ForbiddenException if the caller cannot modify the resource. + */ + def revoke[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + email: String, + requesterUid: Integer + ): Response = { + requireWriteAccess(ctx, resource, id, requesterUid) + val granteeUid = new UserDao(ctx.configuration()).fetchOneByEmail(email).getUid + + ctx + .delete(resource.accessTable) + .where( + resource.accessUidField + .eq(granteeUid) + .and(resource.accessIdField.eq(id)) + ) + .execute() + + Response.ok().build() + } + + private def requireWriteAccess[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + uid: Integer + ): Unit = + if (!userHasWriteAccess(ctx, resource, id, uid)) { + throw new ForbiddenException( + s"You do not have permission to modify ${resource.label} $id" + ) + } + + private def requireReadAccess[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + id: Integer, + uid: Integer + ): Unit = + if (!userHasReadAccess(ctx, resource, id, uid)) { + throw new ForbiddenException( + s"You do not have access to ${resource.label} $id" + ) + } +} diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceNaming.scala b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceNaming.scala new file mode 100644 index 0000000000..269cbce917 --- /dev/null +++ b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceNaming.scala @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.resource + +import jakarta.ws.rs.BadRequestException +import org.apache.texera.dao.SqlStates +import org.jooq.{DSLContext, Record} +import org.jooq.exception.DataAccessException + +/** + * Naming rules shared by every user-owned resource: what a name may contain, and that a name + * is unique among one owner's resources of that type. + */ +object ResourceNaming { + + private val NAME_MAX_LENGTH = 128 + private val NAME_PATTERN = "^[A-Za-z0-9_-]+$".r + + /** + * Rules: + * - Must be 1 to 128 characters long. + * - Only letters, numbers, underscores, and hyphens are allowed. + * + * @throws jakarta.ws.rs.BadRequestException if the name is invalid. + */ + def validateName(label: String, name: String): Unit = { + if (name == null || !NAME_PATTERN.matches(name)) { + throw new BadRequestException( + s"Invalid $label name: only letters, numbers, underscores, and hyphens are allowed." + ) + } + if (name.length > NAME_MAX_LENGTH) { + throw new BadRequestException( + s"Invalid $label name: name must be at most $NAME_MAX_LENGTH characters long." + ) + } + } + + /** + * Rejects a name the owner already uses for another resource of the same type. + * + * @param excludingId the resource being renamed, so it does not conflict with itself + */ + def requireNameAvailable[R <: Record, A <: Record]( + ctx: DSLContext, + resource: ResourceTables[R, A], + ownerUid: Integer, + name: String, + excludingId: Option[Integer] = None + ): Unit = { + val taken = ctx.fetchExists( + excludingId.foldLeft( + ctx + .selectFrom(resource.table) + .where(resource.ownerUidField.eq(ownerUid)) + .and(resource.nameField.eq(name)) + )((query, id) => query.and(resource.idField.notEqual(id))) + ) + if (taken) { + throw duplicateName(resource.label) + } + } + + /** + * Runs a write and translates an (owner_uid, name) unique-constraint violation into the same + * BadRequestException the pre-check throws, so requests losing a concurrent race get a 400 + * instead of a 500. + */ + def failOnDuplicateName[T](label: String)(op: => T): T = { + try op + catch { + case e: DataAccessException => + if (e.sqlState() == SqlStates.UNIQUE_VIOLATION) { + throw duplicateName(label) + } + throw e + } + } + + private def duplicateName(label: String): BadRequestException = + new BadRequestException(s"${label.capitalize} with the same name already exists") +} diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ResourceTables.scala b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceTables.scala new file mode 100644 index 0000000000..605ed934c9 --- /dev/null +++ b/file-service/src/main/scala/org/apache/texera/service/resource/ResourceTables.scala @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.service.resource + +import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum +import org.apache.texera.dao.jooq.generated.tables.Dataset.DATASET +import org.apache.texera.dao.jooq.generated.tables.DatasetUserAccess.DATASET_USER_ACCESS +import org.apache.texera.dao.jooq.generated.tables.records.{DatasetRecord, DatasetUserAccessRecord} +import org.jooq.{Record, Table, TableField} + +/** + * The tables and columns that define one resource type, so the rules in [[ResourceAccess]] and + * [[ResourceNaming]] can be written once and told which columns to read. + * + * A resource fits only if its own table carries the owner, the name and the `is_public` flag, and + * it has a companion `*_user_access` table — today that is `dataset` and `model`. This is not a + * model of every shareable resource: `workflow` keeps ownership in `workflow_of_user`, and + * neither `project` nor `workflow_computing_unit` has `is_public`. + * + * @param label how the resource is named in user-facing messages ("dataset", "model") + * @tparam R record type of the resource table + * @tparam A record type of the companion user-access table + */ +case class ResourceTables[R <: Record, A <: Record]( + label: String, + idField: TableField[R, Integer], + ownerUidField: TableField[R, Integer], + nameField: TableField[R, String], + isPublicField: TableField[R, java.lang.Boolean], + accessIdField: TableField[A, Integer], + accessUidField: TableField[A, Integer], + privilegeField: TableField[A, PrivilegeEnum] +) { + def table: Table[R] = idField.getTable + def accessTable: Table[A] = accessIdField.getTable +} + +object ResourceTables { + + val Dataset: ResourceTables[DatasetRecord, DatasetUserAccessRecord] = + ResourceTables( + label = "dataset", + idField = DATASET.DID, + ownerUidField = DATASET.OWNER_UID, + nameField = DATASET.NAME, + isPublicField = DATASET.IS_PUBLIC, + accessIdField = DATASET_USER_ACCESS.DID, + accessUidField = DATASET_USER_ACCESS.UID, + privilegeField = DATASET_USER_ACCESS.PRIVILEGE + ) + +} diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetAccessResourceSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetAccessResourceSpec.scala index f73c7adf64..709ede7184 100644 --- a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetAccessResourceSpec.scala +++ b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetAccessResourceSpec.scala @@ -113,8 +113,11 @@ class DatasetAccessResourceSpec .insert(new DatasetUserAccess(did, uid, privilege)) } - private def accessList(did: Integer): List[DatasetAccessResource.AccessEntry] = - accessResource.getAccessList(did).asScala.toList + private def accessList( + did: Integer, + user: SessionUser = ownerSession + ): List[DatasetAccessResource.AccessEntry] = + accessResource.getAccessList(did, user).asScala.toList override protected def beforeAll(): Unit = { super.beforeAll() @@ -436,10 +439,57 @@ class DatasetAccessResourceSpec // =========================================================================== "getOwnerEmailOfDataset" should "return the owner's email" in { - accessResource.getOwnerEmailOfDataset(privateDataset.getDid) shouldEqual ownerUser.getEmail + accessResource.getOwnerEmailOfDataset( + privateDataset.getDid, + ownerSession + ) shouldEqual ownerUser.getEmail + } + + it should "be readable by a READ grantee" in { + grantDirectly(privateDataset.getDid, readGranteeUser.getUid, PrivilegeEnum.READ) + + accessResource.getOwnerEmailOfDataset( + privateDataset.getDid, + readGranteeSession + ) shouldEqual ownerUser.getEmail + } + + it should "be forbidden for a user with no access to a private dataset" in { + assertThrows[ForbiddenException] { + accessResource.getOwnerEmailOfDataset(privateDataset.getDid, strangerSession) + } + } + + it should "be readable by anyone for a public dataset" in { + accessResource.getOwnerEmailOfDataset( + publicDataset.getDid, + strangerSession + ) shouldEqual ownerUser.getEmail + } + + it should "be forbidden for a nonexistent dataset" in { + assertThrows[ForbiddenException] { + accessResource.getOwnerEmailOfDataset(nonExistentDid, ownerSession) + } } - it should "return an empty string for a nonexistent dataset" in { - accessResource.getOwnerEmailOfDataset(nonExistentDid) shouldEqual "" + // =========================================================================== + // getAccessList -- read guard + // =========================================================================== + + "getAccessList" should "be forbidden for a user with no access to a private dataset" in { + grantDirectly(privateDataset.getDid, readGranteeUser.getUid, PrivilegeEnum.READ) + + assertThrows[ForbiddenException] { + accessList(privateDataset.getDid, strangerSession) + } + } + + it should "be readable by a READ grantee" in { + grantDirectly(privateDataset.getDid, readGranteeUser.getUid, PrivilegeEnum.READ) + + accessList(privateDataset.getDid, readGranteeSession).map(_.email) should contain( + readGranteeUser.getEmail + ) } }
