diff --git a/file-service/src/main/scala/org/apache/texera/service/FileService.scala b/file-service/src/main/scala/org/apache/texera/service/FileService.scala
index 1bb29f5dab3..337ff2ad1fe 100644
--- a/file-service/src/main/scala/org/apache/texera/service/FileService.scala
+++ b/file-service/src/main/scala/org/apache/texera/service/FileService.scala
@@ -35,7 +35,9 @@ import org.apache.texera.service.`type`.serde.DatasetFileNodeSerializer
import org.apache.texera.service.resource.{
DatasetAccessResource,
DatasetResource,
- HealthCheckResource
+ HealthCheckResource,
+ ModelAccessResource,
+ ModelResource
}
import org.apache.texera.service.util.S3StorageClient
import org.apache.texera.service.util.LargeBinaryManager
@@ -90,6 +92,8 @@ class FileService extends Application[FileServiceConfiguration] with LazyLogging
environment.jersey.register(classOf[DatasetResource])
environment.jersey.register(classOf[DatasetAccessResource])
+ environment.jersey.register(classOf[ModelResource])
+ environment.jersey.register(classOf[ModelAccessResource])
RoleAnnotationEnforcer.enforce(environment.jersey.getResourceConfig, "FileService")
diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ModelAccessResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/ModelAccessResource.scala
new file mode 100644
index 00000000000..390afbc0bf7
--- /dev/null
+++ b/file-service/src/main/scala/org/apache/texera/service/resource/ModelAccessResource.scala
@@ -0,0 +1,140 @@
+/*
+ * 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 io.dropwizard.auth.Auth
+import jakarta.annotation.security.RolesAllowed
+import jakarta.ws.rs.core.{MediaType, Response}
+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.enums.PrivilegeEnum
+import org.apache.texera.dao.jooq.generated.tables.pojos.User
+import org.apache.texera.service.resource.ModelAccessResource.context
+import org.apache.texera.service.resource.ResourceTables.{Model => MODEL_RESOURCE}
+import org.jooq.DSLContext
+
+object ModelAccessResource {
+ private def context: DSLContext =
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+
+ type AccessEntry = ResourceAccess.AccessEntry
+ val AccessEntry: ResourceAccess.AccessEntry.type = ResourceAccess.AccessEntry
+
+ def isModelPublic(ctx: DSLContext, mid: Integer): Boolean =
+ ResourceAccess.isPublic(ctx, MODEL_RESOURCE, mid)
+
+ def userHasReadAccess(ctx: DSLContext, mid: Integer, uid: Integer): Boolean =
+ ResourceAccess.userHasReadAccess(ctx, MODEL_RESOURCE, mid, uid)
+
+ def userOwnModel(ctx: DSLContext, mid: Integer, uid: Integer): Boolean =
+ ResourceAccess.userOwns(ctx, MODEL_RESOURCE, mid, uid)
+
+ def userHasWriteAccess(ctx: DSLContext, mid: Integer, uid: Integer): Boolean =
+ ResourceAccess.userHasWriteAccess(ctx, MODEL_RESOURCE, mid, uid)
+
+ def getModelUserAccessPrivilege(
+ ctx: DSLContext,
+ mid: Integer,
+ uid: Integer
+ ): PrivilegeEnum = ResourceAccess.privilegeOf(ctx, MODEL_RESOURCE, mid, uid)
+
+ def getOwner(ctx: DSLContext, mid: Integer): User =
+ ResourceAccess.owner(ctx, MODEL_RESOURCE, mid)
+}
+
+@Produces(Array(MediaType.APPLICATION_JSON))
+@RolesAllowed(Array("REGULAR", "ADMIN"))
+@Path("/access/model")
+class ModelAccessResource {
+
+ /**
+ * This method returns the owner of a model
+ *
+ * @param mid , model id
+ * @return ownerEmail, the owner's email
+ */
+ @GET
+ @Path("/owner/{mid}")
+ def getOwnerEmailOfModel(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): String =
+ withTransaction(context)(ctx =>
+ ResourceAccess.ownerEmail(ctx, MODEL_RESOURCE, mid, user.getUid)
+ )
+
+ /**
+ * Returns information about all current shared access of the given model
+ *
+ * @param mid model id
+ * @return a List of email/name/permission
+ */
+ @GET
+ @Path("/list/{mid}")
+ def getAccessList(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): java.util.List[ModelAccessResource.AccessEntry] =
+ withTransaction(context)(ctx =>
+ ResourceAccess.accessList(ctx, MODEL_RESOURCE, mid, user.getUid)
+ )
+
+ /**
+ * This method shares a model to a user with a specific access type
+ *
+ * @param mid the given model
+ * @param email the email which the access is given to
+ * @param privilege the type of Access given to the target user
+ * @return rejection if user not permitted to share the model or Success Message
+ */
+ @PUT
+ @Path("/grant/{mid}/{email}/{privilege}")
+ def grantAccess(
+ @PathParam("mid") mid: Integer,
+ @PathParam("email") email: String,
+ @PathParam("privilege") privilege: String,
+ @Auth user: SessionUser
+ ): Response =
+ withTransaction(context) { ctx =>
+ ResourceAccess.grant(ctx, MODEL_RESOURCE, mid, email, privilege, user.getUid)
+ }
+
+ /**
+ * This method revoke the user's access of the given model
+ *
+ * @param mid the given model
+ * @param email the email of the use whose access is about to be removed
+ * @return message indicating a success message
+ */
+ @DELETE
+ @Path("/revoke/{mid}/{email}")
+ def revokeAccess(
+ @PathParam("mid") mid: Integer,
+ @PathParam("email") email: String,
+ @Auth user: SessionUser
+ ): Response =
+ withTransaction(context) { ctx =>
+ ResourceAccess.revoke(ctx, MODEL_RESOURCE, mid, email, user.getUid)
+ }
+}
diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala
new file mode 100644
index 00000000000..e5e42563a9e
--- /dev/null
+++ b/file-service/src/main/scala/org/apache/texera/service/resource/ModelResource.scala
@@ -0,0 +1,411 @@
+/*
+ * 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 com.typesafe.scalalogging.LazyLogging
+import io.dropwizard.auth.Auth
+import jakarta.annotation.security.{PermitAll, RolesAllowed}
+import jakarta.ws.rs._
+import jakarta.ws.rs.core._
+import org.apache.texera.amber.core.storage.util.LakeFSStorageClient
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.common.config.StorageConfig
+import org.apache.texera.dao.SqlServer
+import org.apache.texera.dao.SqlServer.withTransaction
+import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
+import org.apache.texera.dao.jooq.generated.tables.Model.MODEL
+import org.apache.texera.dao.jooq.generated.tables.daos.{ModelDao, ModelUserAccessDao}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{Model, ModelUserAccess}
+import org.apache.texera.service.resource.ResourceTables.{Model => MODEL_RESOURCE}
+import org.apache.texera.service.resource.ModelAccessResource._
+import org.apache.texera.service.resource.ModelResource.{context, _}
+import org.apache.texera.service.util.S3StorageClient
+import org.apache.texera.service.util.LakeFSExceptionHandler.withLakeFSErrorHandling
+import org.jooq.{DSLContext, EnumType}
+
+object ModelResource {
+
+ // MVP supports a single framework; stored on the model so later frameworks can be added.
+ private val DEFAULT_FRAMEWORK = "pytorch"
+
+ private def context =
+ SqlServer
+ .getInstance()
+ .createDSLContext()
+
+ /**
+ * Helper function to get the model from DB using mid
+ */
+ private def getModelByID(ctx: DSLContext, mid: Integer): Model = {
+ val modelDao = new ModelDao(ctx.configuration())
+ val model = modelDao.fetchOneByMid(mid)
+ if (model == null) {
+ throw new NotFoundException(f"Model $mid not found")
+ }
+ model
+ }
+
+ case class DashboardModel(
+ model: Model,
+ ownerEmail: String,
+ accessPrivilege: EnumType,
+ isOwner: Boolean,
+ size: Long
+ )
+
+ case class CreateModelRequest(
+ modelName: String,
+ modelDescription: String,
+ isModelPublic: Boolean,
+ isModelDownloadable: Boolean,
+ framework: String,
+ format: String
+ )
+
+ case class ModelDescriptionModification(mid: Integer, description: String)
+
+ case class ModelNameModification(mid: Integer, name: String)
+}
+
+@Produces(Array(MediaType.APPLICATION_JSON))
+@Path("/model")
+class ModelResource extends LazyLogging {
+ private val ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE = "User has no access to this model"
+
+ /**
+ * Helper function to get the model from DB with additional information including
+ * user access privilege and owner email
+ */
+ private def getDashboardModel(
+ ctx: DSLContext,
+ mid: Integer,
+ requesterUid: Option[Integer]
+ ): DashboardModel = {
+ val targetModel = getModelByID(ctx, mid)
+
+ if (requesterUid.isEmpty && !targetModel.getIsPublic) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, mid, uid))) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ val userAccessPrivilege = requesterUid
+ .map(uid => getModelUserAccessPrivilege(ctx, mid, uid))
+ .getOrElse(PrivilegeEnum.READ)
+
+ val isOwner = requesterUid.contains(targetModel.getOwnerUid)
+
+ DashboardModel(
+ targetModel,
+ getOwner(ctx, mid).getEmail,
+ userAccessPrivilege,
+ isOwner,
+ withLakeFSErrorHandling(s"retrieving the size of model '${targetModel.getName}'") {
+ LakeFSStorageClient.retrieveRepositorySize(targetModel.getRepositoryName)
+ }
+ )
+ }
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/create")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ def createModel(
+ request: CreateModelRequest,
+ @Auth user: SessionUser
+ ): DashboardModel = {
+
+ withTransaction(context) { ctx =>
+ val uid = user.getUid
+ val modelUserAccessDao: ModelUserAccessDao = new ModelUserAccessDao(ctx.configuration())
+
+ val modelName = request.modelName
+ val modelDescription = request.modelDescription
+ val isModelPublic = request.isModelPublic
+ val isModelDownloadable = request.isModelDownloadable
+
+ ResourceNaming.validateName(MODEL_RESOURCE.label, modelName)
+ ResourceNaming.requireNameAvailable(ctx, MODEL_RESOURCE, uid, modelName)
+
+ // insert the model into the database
+ val model = new Model()
+ model.setName(modelName)
+ model.setDescription(modelDescription)
+ model.setIsPublic(isModelPublic)
+ model.setIsDownloadable(isModelDownloadable)
+ model.setOwnerUid(uid)
+ model.setFramework(Option(request.framework).filter(_.nonEmpty).getOrElse(DEFAULT_FRAMEWORK))
+ model.setFormat(request.format)
+
+ // insert record and get created model with mid
+ val createdModel = ResourceNaming.failOnDuplicateName(MODEL_RESOURCE.label) {
+ ctx
+ .insertInto(MODEL)
+ .set(ctx.newRecord(MODEL, model))
+ .returning()
+ .fetchOne()
+ }
+
+ // Initialize the repository in LakeFS
+ val repositoryName = s"model-${createdModel.getMid}"
+ try {
+ withLakeFSErrorHandling(s"creating the repository of model '${model.getName}'") {
+ LakeFSStorageClient.initRepo(repositoryName)
+ }
+ } catch {
+ case e: Exception =>
+ // roll back the model record so a failed LakeFS init leaves no orphan row
+ ctx
+ .deleteFrom(MODEL)
+ .where(MODEL.MID.eq(createdModel.getMid))
+ .execute()
+ e match {
+ case web: WebApplicationException => throw web
+ case other =>
+ throw new WebApplicationException(
+ s"Failed to create the model: ${other.getMessage}"
+ )
+ }
+ }
+
+ // update repository name of the created model
+ createdModel.setRepositoryName(repositoryName)
+ createdModel.update()
+
+ // Insert the requester as the WRITE access user for this model
+ val modelUserAccess = new ModelUserAccess()
+ modelUserAccess.setMid(createdModel.getMid)
+ modelUserAccess.setUid(uid)
+ modelUserAccess.setPrivilege(PrivilegeEnum.WRITE)
+ modelUserAccessDao.insert(modelUserAccess)
+
+ DashboardModel(
+ createdModel.into(classOf[Model]),
+ user.getEmail,
+ PrivilegeEnum.WRITE,
+ isOwner = true,
+ 0
+ )
+ }
+ }
+
+ @DELETE
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}")
+ def deleteModel(@PathParam("mid") mid: Integer, @Auth user: SessionUser): Response = {
+ val uid = user.getUid
+ withTransaction(context) { ctx =>
+ val modelDao = new ModelDao(ctx.configuration())
+ val model = getModelByID(ctx, mid)
+ if (!userOwnModel(ctx, model.getMid, uid)) {
+ // throw the exception that user has no access to certain model
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+ withLakeFSErrorHandling(s"deleting the repository of model '${model.getName}'") {
+ LakeFSStorageClient.deleteRepo(model.getRepositoryName)
+ }
+ // delete the directory on S3
+ if (
+ S3StorageClient.directoryExists(StorageConfig.lakefsBucketName, model.getRepositoryName)
+ ) {
+ S3StorageClient.deleteDirectory(StorageConfig.lakefsBucketName, model.getRepositoryName)
+ }
+
+ // delete the model from the DB
+ modelDao.deleteById(model.getMid)
+
+ Response.ok().build()
+ }
+ }
+
+ @POST
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/update/description")
+ def updateModelDescription(
+ modificator: ModelDescriptionModification,
+ @Auth sessionUser: SessionUser
+ ): Response = {
+ withTransaction(context) { ctx =>
+ val uid = sessionUser.getUid
+ val modelDao = new ModelDao(ctx.configuration())
+ val model = getModelByID(ctx, modificator.mid)
+ if (!userHasWriteAccess(ctx, modificator.mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ model.setDescription(modificator.description)
+ modelDao.update(model)
+ Response.ok().build()
+ }
+ }
+
+ @POST
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/update/name")
+ def updateModelName(
+ modificator: ModelNameModification,
+ @Auth sessionUser: SessionUser
+ ): Response = {
+ withTransaction(context) { ctx =>
+ val uid = sessionUser.getUid
+ val modelDao = new ModelDao(ctx.configuration())
+ val model = getModelByID(ctx, modificator.mid)
+ if (!userHasWriteAccess(ctx, modificator.mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ ResourceNaming.validateName(MODEL_RESOURCE.label, modificator.name)
+ ResourceNaming.requireNameAvailable(
+ ctx,
+ MODEL_RESOURCE,
+ model.getOwnerUid,
+ modificator.name,
+ excludingId = Some(model.getMid)
+ )
+
+ model.setName(modificator.name)
+ ResourceNaming.failOnDuplicateName(MODEL_RESOURCE.label) {
+ modelDao.update(model)
+ }
+ Response.ok().build()
+ }
+ }
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/update/publicity")
+ def toggleModelPublicity(
+ @PathParam("mid") mid: Integer,
+ @Auth sessionUser: SessionUser
+ ): Response = {
+ withTransaction(context) { ctx =>
+ val modelDao = new ModelDao(ctx.configuration())
+ val uid = sessionUser.getUid
+
+ if (!userHasWriteAccess(ctx, mid, uid)) {
+ throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_MODEL_MESSAGE)
+ }
+
+ val existedModel = getModelByID(ctx, mid)
+ val newPublicStatus = !existedModel.getIsPublic
+ existedModel.setIsPublic(newPublicStatus)
+
+ modelDao.update(existedModel)
+ Response.ok().build()
+ }
+ }
+
+ @POST
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}/update/downloadable")
+ def toggleModelDownloadable(
+ @PathParam("mid") mid: Integer,
+ @Auth sessionUser: SessionUser
+ ): Response = {
+ withTransaction(context) { ctx =>
+ val modelDao = new ModelDao(ctx.configuration())
+ val uid = sessionUser.getUid
+
+ if (!userOwnModel(ctx, mid, uid)) {
+ throw new ForbiddenException("Only model owners can modify download permissions")
+ }
+
+ val existedModel = getModelByID(ctx, mid)
+ val newDownloadableStatus = !existedModel.getIsDownloadable
+
+ existedModel.setIsDownloadable(newDownloadableStatus)
+
+ modelDao.update(existedModel)
+ Response.ok().build()
+ }
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/list")
+ def listModels(
+ @Auth user: SessionUser
+ ): List[DashboardModel] = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => {
+ ResourceAccess.listVisible(
+ ctx,
+ MODEL_RESOURCE,
+ uid,
+ classOf[Model],
+ (model: Model) => model.getMid
+ )(
+ fromGrant = (model, ownerEmail, privilege, isOwner) =>
+ Some(
+ DashboardModel(
+ isOwner = isOwner,
+ model = model,
+ accessPrivilege = privilege,
+ ownerEmail = ownerEmail,
+ size = 0
+ )
+ ),
+ fromPublic = (model, ownerEmail) =>
+ try {
+ Some(
+ DashboardModel(
+ isOwner = false,
+ model = model,
+ accessPrivilege = PrivilegeEnum.READ,
+ ownerEmail = ownerEmail,
+ size = LakeFSStorageClient.retrieveRepositorySize(model.getRepositoryName)
+ )
+ )
+ } catch {
+ case e: io.lakefs.clients.sdk.ApiException =>
+ logger.error(
+ s"LakeFS ApiException for model repository '${model.getRepositoryName}': ${e.getMessage}",
+ e
+ )
+ None
+ }
+ )
+ })
+ }
+
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{mid}")
+ def getModel(
+ @PathParam("mid") mid: Integer,
+ @Auth user: SessionUser
+ ): DashboardModel = {
+ val uid = user.getUid
+ withTransaction(context)(ctx => getDashboardModel(ctx, mid, Some(uid)))
+ }
+
+ @GET
+ @PermitAll
+ @Path("/public/{mid}")
+ def getPublicModel(
+ @PathParam("mid") mid: Integer
+ ): DashboardModel = {
+ withTransaction(context)(ctx => getDashboardModel(ctx, mid, None))
+ }
+}
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
index 605ed934c9d..30ecbca73a4 100644
--- 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
@@ -22,7 +22,14 @@ 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.apache.texera.dao.jooq.generated.tables.Model.MODEL
+import org.apache.texera.dao.jooq.generated.tables.ModelUserAccess.MODEL_USER_ACCESS
+import org.apache.texera.dao.jooq.generated.tables.records.{
+ DatasetRecord,
+ DatasetUserAccessRecord,
+ ModelRecord,
+ ModelUserAccessRecord
+}
import org.jooq.{Record, Table, TableField}
/**
@@ -66,4 +73,16 @@ object ResourceTables {
privilegeField = DATASET_USER_ACCESS.PRIVILEGE
)
+ val Model: ResourceTables[ModelRecord, ModelUserAccessRecord] =
+ ResourceTables(
+ label = "model",
+ idField = MODEL.MID,
+ ownerUidField = MODEL.OWNER_UID,
+ nameField = MODEL.NAME,
+ isPublicField = MODEL.IS_PUBLIC,
+ accessIdField = MODEL_USER_ACCESS.MID,
+ accessUidField = MODEL_USER_ACCESS.UID,
+ privilegeField = MODEL_USER_ACCESS.PRIVILEGE
+ )
+
}
diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/ModelAccessResourceSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/ModelAccessResourceSpec.scala
new file mode 100644
index 00000000000..a3db382711a
--- /dev/null
+++ b/file-service/src/test/scala/org/apache/texera/service/resource/ModelAccessResourceSpec.scala
@@ -0,0 +1,509 @@
+/*
+ * 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 org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum}
+import org.apache.texera.dao.jooq.generated.tables.ModelUserAccess.MODEL_USER_ACCESS
+import org.apache.texera.dao.jooq.generated.tables.daos.{ModelDao, ModelUserAccessDao, UserDao}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{Model, ModelUserAccess, User}
+import org.apache.texera.service.resource.ModelAccessResource.{
+ getModelUserAccessPrivilege,
+ getOwner,
+ isModelPublic,
+ userHasReadAccess,
+ userHasWriteAccess,
+ userOwnModel
+}
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import scala.jdk.CollectionConverters._
+
+class ModelAccessResourceSpec
+ extends AnyFlatSpec
+ with Matchers
+ with MockTexeraDB
+ with BeforeAndAfterAll
+ with BeforeAndAfterEach {
+
+ private val ownerUser: User = {
+ val user = new User
+ user.setName("model_owner")
+ user.setEmail("model_owner@test.com")
+ user.setRole(UserRoleEnum.REGULAR)
+ user
+ }
+
+ private val readGranteeUser: User = {
+ val user = new User
+ user.setName("read_grantee")
+ user.setEmail("read_grantee@test.com")
+ user.setRole(UserRoleEnum.REGULAR)
+ user
+ }
+
+ private val writeGranteeUser: User = {
+ val user = new User
+ user.setName("write_grantee")
+ user.setEmail("write_grantee@test.com")
+ user.setRole(UserRoleEnum.REGULAR)
+ user
+ }
+
+ private val strangerUser: User = {
+ val user = new User
+ user.setName("stranger")
+ user.setEmail("stranger@test.com")
+ user.setRole(UserRoleEnum.REGULAR)
+ user
+ }
+
+ private val privateModel: Model = {
+ val model = new Model
+ model.setName("private-model")
+ model.setRepositoryName("private-model")
+ model.setIsPublic(false)
+ model.setIsDownloadable(true)
+ model.setDescription("private model for access tests")
+ model.setFramework("pytorch")
+ model
+ }
+
+ private val publicModel: Model = {
+ val model = new Model
+ model.setName("public-model")
+ model.setRepositoryName("public-model")
+ model.setIsPublic(true)
+ model.setIsDownloadable(true)
+ model.setDescription("public model for access tests")
+ model.setFramework("pytorch")
+ model
+ }
+
+ private val nonExistentMid: Integer = 999999
+
+ lazy val accessResource = new ModelAccessResource()
+
+ lazy val ownerSession = new SessionUser(ownerUser)
+ lazy val writeGranteeSession = new SessionUser(writeGranteeUser)
+ lazy val readGranteeSession = new SessionUser(readGranteeUser)
+ lazy val strangerSession = new SessionUser(strangerUser)
+
+ private def grantDirectly(mid: Integer, uid: Integer, privilege: PrivilegeEnum): Unit = {
+ new ModelUserAccessDao(getDSLContext.configuration())
+ .insert(new ModelUserAccess(mid, uid, privilege))
+ }
+
+ private def accessList(
+ mid: Integer,
+ user: SessionUser = ownerSession
+ ): List[ModelAccessResource.AccessEntry] =
+ accessResource.getAccessList(mid, user).asScala.toList
+
+ override protected def beforeAll(): Unit = {
+ super.beforeAll()
+ initializeDBAndReplaceDSLContext()
+
+ val userDao = new UserDao(getDSLContext.configuration())
+ userDao.insert(ownerUser)
+ userDao.insert(readGranteeUser)
+ userDao.insert(writeGranteeUser)
+ userDao.insert(strangerUser)
+
+ privateModel.setOwnerUid(ownerUser.getUid)
+ publicModel.setOwnerUid(ownerUser.getUid)
+ val modelDao = new ModelDao(getDSLContext.configuration())
+ modelDao.insert(privateModel)
+ modelDao.insert(publicModel)
+ }
+
+ override protected def beforeEach(): Unit = {
+ super.beforeEach()
+ // every test starts with no explicit grants
+ getDSLContext.deleteFrom(MODEL_USER_ACCESS).execute()
+ }
+
+ override protected def afterAll(): Unit = {
+ try shutdownDB()
+ finally super.afterAll()
+ }
+
+ // ===========================================================================
+ // Privilege helpers
+ // ===========================================================================
+
+ "isModelPublic" should "be true for a public model and false for a private one" in {
+ isModelPublic(getDSLContext, publicModel.getMid) shouldBe true
+ isModelPublic(getDSLContext, privateModel.getMid) shouldBe false
+ }
+
+ "userOwnModel" should "be true only for the owner" in {
+ userOwnModel(getDSLContext, privateModel.getMid, ownerUser.getUid) shouldBe true
+ userOwnModel(getDSLContext, privateModel.getMid, strangerUser.getUid) shouldBe false
+ }
+
+ "getModelUserAccessPrivilege" should "return NONE for a user without an explicit grant" in {
+ getModelUserAccessPrivilege(
+ getDSLContext,
+ privateModel.getMid,
+ strangerUser.getUid
+ ) shouldEqual PrivilegeEnum.NONE
+ }
+
+ it should "return the granted privilege for a grantee" in {
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+ grantDirectly(privateModel.getMid, writeGranteeUser.getUid, PrivilegeEnum.WRITE)
+
+ getModelUserAccessPrivilege(
+ getDSLContext,
+ privateModel.getMid,
+ readGranteeUser.getUid
+ ) shouldEqual PrivilegeEnum.READ
+ getModelUserAccessPrivilege(
+ getDSLContext,
+ privateModel.getMid,
+ writeGranteeUser.getUid
+ ) shouldEqual PrivilegeEnum.WRITE
+ }
+
+ "the owner" should "have both read and write access to the model" in {
+ userHasReadAccess(getDSLContext, privateModel.getMid, ownerUser.getUid) shouldBe true
+ userHasWriteAccess(getDSLContext, privateModel.getMid, ownerUser.getUid) shouldBe true
+ }
+
+ "a READ grantee" should "have read but not write access" in {
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+
+ userHasReadAccess(getDSLContext, privateModel.getMid, readGranteeUser.getUid) shouldBe true
+ userHasWriteAccess(getDSLContext, privateModel.getMid, readGranteeUser.getUid) shouldBe false
+ }
+
+ "a WRITE grantee" should "have both read and write access" in {
+ grantDirectly(privateModel.getMid, writeGranteeUser.getUid, PrivilegeEnum.WRITE)
+
+ userHasReadAccess(getDSLContext, privateModel.getMid, writeGranteeUser.getUid) shouldBe true
+ userHasWriteAccess(getDSLContext, privateModel.getMid, writeGranteeUser.getUid) shouldBe true
+ }
+
+ "a user with no grant" should "have no access to a private model" in {
+ userHasReadAccess(getDSLContext, privateModel.getMid, strangerUser.getUid) shouldBe false
+ userHasWriteAccess(getDSLContext, privateModel.getMid, strangerUser.getUid) shouldBe false
+ }
+
+ it should "have read but not write access to a public model" in {
+ userHasReadAccess(getDSLContext, publicModel.getMid, strangerUser.getUid) shouldBe true
+ userHasWriteAccess(getDSLContext, publicModel.getMid, strangerUser.getUid) shouldBe false
+ }
+
+ it should "have no explicit privilege row on a public model" in {
+ // public read access comes from is_public, not from a model_user_access row
+ getModelUserAccessPrivilege(
+ getDSLContext,
+ publicModel.getMid,
+ strangerUser.getUid
+ ) shouldEqual PrivilegeEnum.NONE
+ }
+
+ "an explicit WRITE grant on a public model" should "give a non-owner write access" in {
+ grantDirectly(publicModel.getMid, writeGranteeUser.getUid, PrivilegeEnum.WRITE)
+
+ userHasWriteAccess(getDSLContext, publicModel.getMid, writeGranteeUser.getUid) shouldBe true
+ }
+
+ "the privilege helpers" should "treat a nonexistent model as private, unowned, and ungranted" in {
+ isModelPublic(getDSLContext, nonExistentMid) shouldBe false
+ userOwnModel(getDSLContext, nonExistentMid, ownerUser.getUid) shouldBe false
+ getModelUserAccessPrivilege(
+ getDSLContext,
+ nonExistentMid,
+ ownerUser.getUid
+ ) shouldEqual PrivilegeEnum.NONE
+ userHasReadAccess(getDSLContext, nonExistentMid, ownerUser.getUid) shouldBe false
+ userHasWriteAccess(getDSLContext, nonExistentMid, ownerUser.getUid) shouldBe false
+ }
+
+ "getOwner" should "return the owning user" in {
+ getOwner(getDSLContext, privateModel.getMid).getEmail shouldEqual ownerUser.getEmail
+ }
+
+ it should "return null for a nonexistent model" in {
+ getOwner(getDSLContext, nonExistentMid) shouldBe null
+ }
+
+ // ===========================================================================
+ // grantAccess / getAccessList
+ // ===========================================================================
+
+ "grantAccess" should "add a grantee that appears in the access list with the granted privilege" in {
+ val response = accessResource.grantAccess(
+ privateModel.getMid,
+ readGranteeUser.getEmail,
+ "READ",
+ ownerSession
+ )
+ response.getStatus shouldEqual 200
+
+ val entries = accessList(privateModel.getMid)
+ entries should have size 1
+ entries.head.email shouldEqual readGranteeUser.getEmail
+ entries.head.name shouldEqual readGranteeUser.getName
+ entries.head.privilege shouldEqual PrivilegeEnum.READ
+ }
+
+ it should "reject granting to a placeholder account" in {
+ val placeholder = new User
+ placeholder.setName("model_placeholder")
+ placeholder.setEmail("model-placeholder@test.com")
+ placeholder.setRole(UserRoleEnum.INACTIVE)
+ placeholder.setIsPlaceholder(true)
+ new UserDao(getDSLContext.configuration()).insert(placeholder)
+
+ assertThrows[BadRequestException] {
+ accessResource.grantAccess(
+ privateModel.getMid,
+ "model-placeholder@test.com",
+ "READ",
+ ownerSession
+ )
+ }
+ accessList(privateModel.getMid) shouldBe empty
+ }
+
+ it should "reject granting to an unknown email" in {
+ assertThrows[BadRequestException] {
+ accessResource.grantAccess(
+ privateModel.getMid,
+ "nobody@test.com",
+ "READ",
+ ownerSession
+ )
+ }
+ accessList(privateModel.getMid) shouldBe empty
+ }
+
+ it should "update the privilege in place when re-granting with a different privilege" in {
+ accessResource.grantAccess(
+ privateModel.getMid,
+ readGranteeUser.getEmail,
+ "READ",
+ ownerSession
+ )
+ accessResource.grantAccess(
+ privateModel.getMid,
+ readGranteeUser.getEmail,
+ "WRITE",
+ ownerSession
+ )
+
+ val entries = accessList(privateModel.getMid)
+ entries should have size 1
+ entries.head.email shouldEqual readGranteeUser.getEmail
+ entries.head.privilege shouldEqual PrivilegeEnum.WRITE
+ }
+
+ it should "allow a WRITE grantee to share the model" in {
+ grantDirectly(privateModel.getMid, writeGranteeUser.getUid, PrivilegeEnum.WRITE)
+
+ val response = accessResource.grantAccess(
+ privateModel.getMid,
+ strangerUser.getEmail,
+ "READ",
+ writeGranteeSession
+ )
+ response.getStatus shouldEqual 200
+
+ userHasReadAccess(getDSLContext, privateModel.getMid, strangerUser.getUid) shouldBe true
+ }
+
+ it should "be forbidden for a user without write access" in {
+ val ex = intercept[ForbiddenException] {
+ accessResource.grantAccess(
+ privateModel.getMid,
+ readGranteeUser.getEmail,
+ "READ",
+ strangerSession
+ )
+ }
+ ex.getResponse.getStatus shouldEqual 403
+ ex.getMessage should include(
+ s"You do not have permission to modify model ${privateModel.getMid}"
+ )
+ }
+
+ it should "be forbidden for a READ grantee" in {
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+
+ assertThrows[ForbiddenException] {
+ accessResource.grantAccess(
+ privateModel.getMid,
+ strangerUser.getEmail,
+ "READ",
+ readGranteeSession
+ )
+ }
+ }
+
+ "getAccessList" should "return an empty list when no access has been granted" in {
+ accessList(privateModel.getMid) shouldBe empty
+ }
+
+ it should "not include the owner's own access row" in {
+ // even if the owner somehow has an explicit access row, the list only shows other users
+ grantDirectly(privateModel.getMid, ownerUser.getUid, PrivilegeEnum.WRITE)
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+
+ val entries = accessList(privateModel.getMid)
+ entries should have size 1
+ entries.head.email shouldEqual readGranteeUser.getEmail
+ }
+
+ it should "list multiple grantees with their respective privileges" in {
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+ grantDirectly(privateModel.getMid, writeGranteeUser.getUid, PrivilegeEnum.WRITE)
+
+ val entries = accessList(privateModel.getMid)
+ entries should have size 2
+ val privilegeByEmail = entries.map(entry => entry.email -> entry.privilege).toMap
+ privilegeByEmail(readGranteeUser.getEmail) shouldEqual PrivilegeEnum.READ
+ privilegeByEmail(writeGranteeUser.getEmail) shouldEqual PrivilegeEnum.WRITE
+ }
+
+ // ===========================================================================
+ // revokeAccess
+ // ===========================================================================
+
+ "revokeAccess" should "remove the grantee from the access list and drop their access" in {
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+
+ val response = accessResource.revokeAccess(
+ privateModel.getMid,
+ readGranteeUser.getEmail,
+ ownerSession
+ )
+ response.getStatus shouldEqual 200
+
+ accessList(privateModel.getMid) shouldBe empty
+ getModelUserAccessPrivilege(
+ getDSLContext,
+ privateModel.getMid,
+ readGranteeUser.getUid
+ ) shouldEqual PrivilegeEnum.NONE
+ userHasReadAccess(getDSLContext, privateModel.getMid, readGranteeUser.getUid) shouldBe false
+ }
+
+ it should "allow a WRITE grantee to revoke another user's access" in {
+ grantDirectly(privateModel.getMid, writeGranteeUser.getUid, PrivilegeEnum.WRITE)
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+
+ val response = accessResource.revokeAccess(
+ privateModel.getMid,
+ readGranteeUser.getEmail,
+ writeGranteeSession
+ )
+ response.getStatus shouldEqual 200
+
+ userHasReadAccess(getDSLContext, privateModel.getMid, readGranteeUser.getUid) shouldBe false
+ }
+
+ it should "succeed as a no-op when the target user has no explicit grant" in {
+ val response = accessResource.revokeAccess(
+ privateModel.getMid,
+ strangerUser.getEmail,
+ ownerSession
+ )
+ response.getStatus shouldEqual 200
+ accessList(privateModel.getMid) shouldBe empty
+ }
+
+ it should "be forbidden for a user without write access" in {
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+
+ assertThrows[ForbiddenException] {
+ accessResource.revokeAccess(
+ privateModel.getMid,
+ readGranteeUser.getEmail,
+ strangerSession
+ )
+ }
+ }
+
+ // ===========================================================================
+ // getOwnerEmailOfModel
+ // ===========================================================================
+
+ "getOwnerEmailOfModel" should "return the owner's email" in {
+ accessResource.getOwnerEmailOfModel(
+ privateModel.getMid,
+ ownerSession
+ ) shouldEqual ownerUser.getEmail
+ }
+
+ it should "be readable by a READ grantee" in {
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+
+ accessResource.getOwnerEmailOfModel(
+ privateModel.getMid,
+ readGranteeSession
+ ) shouldEqual ownerUser.getEmail
+ }
+
+ it should "be forbidden for a user with no access to a private model" in {
+ assertThrows[ForbiddenException] {
+ accessResource.getOwnerEmailOfModel(privateModel.getMid, strangerSession)
+ }
+ }
+
+ it should "be readable by anyone for a public model" in {
+ accessResource.getOwnerEmailOfModel(
+ publicModel.getMid,
+ strangerSession
+ ) shouldEqual ownerUser.getEmail
+ }
+
+ it should "be forbidden for a nonexistent model" in {
+ assertThrows[ForbiddenException] {
+ accessResource.getOwnerEmailOfModel(nonExistentMid, ownerSession)
+ }
+ }
+
+ // ===========================================================================
+ // getAccessList -- read guard
+ // ===========================================================================
+
+ "getAccessList" should "be forbidden for a user with no access to a private model" in {
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+
+ assertThrows[ForbiddenException] {
+ accessList(privateModel.getMid, strangerSession)
+ }
+ }
+
+ it should "be readable by a READ grantee" in {
+ grantDirectly(privateModel.getMid, readGranteeUser.getUid, PrivilegeEnum.READ)
+
+ accessList(privateModel.getMid, readGranteeSession).map(_.email) should contain(
+ readGranteeUser.getEmail
+ )
+ }
+}
diff --git a/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourceSpec.scala b/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourceSpec.scala
new file mode 100644
index 00000000000..218eb244707
--- /dev/null
+++ b/file-service/src/test/scala/org/apache/texera/service/resource/ModelResourceSpec.scala
@@ -0,0 +1,514 @@
+/*
+ * 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._
+import org.apache.texera.auth.SessionUser
+import org.apache.texera.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum}
+import org.apache.texera.dao.jooq.generated.tables.daos.{ModelDao, UserDao}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{Model, User}
+import org.apache.texera.service.MockLakeFS
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+class ModelResourceSpec
+ extends AnyFlatSpec
+ with Matchers
+ with MockTexeraDB
+ with MockLakeFS
+ with BeforeAndAfterAll
+ with BeforeAndAfterEach {
+
+ private val ownerUser: User = {
+ val user = new User
+ user.setName("model_user")
+ user.setEmail("model_user@test.com")
+ user.setRole(UserRoleEnum.ADMIN)
+ user
+ }
+
+ private val otherUser: User = {
+ val user = new User
+ user.setName("model_user2")
+ user.setEmail("model_user2@test.com")
+ user.setRole(UserRoleEnum.ADMIN)
+ user
+ }
+
+ private val baseModel: Model = {
+ val model = new Model
+ model.setName("test-model")
+ model.setRepositoryName("test-model")
+ model.setIsPublic(true)
+ model.setIsDownloadable(true)
+ model.setDescription("model for test")
+ model.setFramework("pytorch")
+ model
+ }
+
+ private lazy val modelDao = new ModelDao(getDSLContext.configuration())
+
+ lazy val modelResource = new ModelResource()
+
+ lazy val sessionUser = new SessionUser(ownerUser)
+ lazy val sessionUser2 = new SessionUser(otherUser)
+
+ private def assertStatus(ex: WebApplicationException, status: Int): Unit =
+ ex.getResponse.getStatus shouldEqual status
+
+ override protected def beforeAll(): Unit = {
+ super.beforeAll()
+
+ initializeDBAndReplaceDSLContext()
+
+ val userDao = new UserDao(getDSLContext.configuration())
+ userDao.insert(ownerUser)
+ userDao.insert(otherUser)
+
+ baseModel.setOwnerUid(ownerUser.getUid)
+ modelDao.insert(baseModel)
+ }
+
+ override protected def afterAll(): Unit = {
+ try shutdownDB()
+ finally super.afterAll()
+ }
+
+ // ===========================================================================
+ // createModel
+ // ===========================================================================
+ "createModel" should "create a model successfully if the user has no model with the same name" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "new-model",
+ modelDescription = "description for new model",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = "torchscript"
+ )
+
+ val created = modelResource.createModel(request, sessionUser)
+ created.model.getName shouldEqual "new-model"
+ created.model.getDescription shouldEqual "description for new model"
+ created.model.getIsPublic shouldBe false
+ created.model.getIsDownloadable shouldBe true
+ created.model.getFramework shouldEqual "pytorch"
+ created.model.getFormat shouldEqual "torchscript"
+ // the LakeFS repository is named after the created model's id
+ created.model.getRepositoryName shouldEqual s"model-${created.model.getMid}"
+ }
+
+ it should "default the framework to pytorch when none is provided" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "framework-default-model",
+ modelDescription = "no framework provided",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "",
+ format = null
+ )
+
+ val created = modelResource.createModel(request, sessionUser)
+ created.model.getFramework shouldEqual "pytorch"
+ }
+
+ it should "refuse to create a model if the user already has one with the same name" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "test-model",
+ modelDescription = "duplicate name",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ )
+
+ assertThrows[BadRequestException] {
+ modelResource.createModel(request, sessionUser)
+ }
+ }
+
+ it should "create a model successfully if another user has one with the same name" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "test-model",
+ modelDescription = "same name, different owner",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ )
+
+ val created = modelResource.createModel(request, sessionUser2)
+ created.model.getName shouldEqual "test-model"
+ }
+
+ it should "reject an invalid model name" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "bad name!",
+ modelDescription = "invalid name",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ )
+
+ assertThrows[BadRequestException] {
+ modelResource.createModel(request, sessionUser)
+ }
+ }
+
+ it should "return a DashboardModel with owner email, WRITE privilege, isOwner=true and size 0" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "dashboard-model",
+ modelDescription = "dashboard properties",
+ isModelPublic = true,
+ isModelDownloadable = false,
+ framework = "pytorch",
+ format = null
+ )
+
+ val dashboard = modelResource.createModel(request, sessionUser)
+ dashboard.ownerEmail shouldEqual ownerUser.getEmail
+ dashboard.accessPrivilege shouldEqual PrivilegeEnum.WRITE
+ dashboard.isOwner shouldBe true
+ dashboard.size shouldEqual 0
+ }
+
+ // ===========================================================================
+ // getModel / listModels
+ // ===========================================================================
+ "getModel" should "return the dashboard model including its LakeFS repository size" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "get-model",
+ modelDescription = "for get",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ )
+ val created = modelResource.createModel(request, sessionUser)
+
+ val dashboard = modelResource.getModel(created.model.getMid, sessionUser)
+ dashboard.model.getMid shouldEqual created.model.getMid
+ dashboard.size should be >= 0L
+ }
+
+ it should "forbid a stranger from getting a private model" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "private-get-model",
+ modelDescription = "private",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ )
+ val created = modelResource.createModel(request, sessionUser)
+
+ assertThrows[ForbiddenException] {
+ modelResource.getModel(created.model.getMid, sessionUser2)
+ }
+ }
+
+ "listModels" should "include models the user owns" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "listed-model",
+ modelDescription = "for list",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ )
+ val created = modelResource.createModel(request, sessionUser)
+
+ val listed = modelResource.listModels(sessionUser)
+ listed.map(_.model.getMid) should contain(created.model.getMid)
+ }
+
+ // ===========================================================================
+ // deleteModel
+ // ===========================================================================
+ "deleteModel" should "delete a model successfully if the user owns it" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "delete-model",
+ modelDescription = "for delete",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ )
+ val created = modelResource.createModel(request, sessionUser)
+
+ val response = modelResource.deleteModel(created.model.getMid, sessionUser)
+ response.getStatus shouldEqual 200
+ modelDao.fetchOneByMid(created.model.getMid) shouldBe null
+ }
+
+ it should "refuse to delete a model not owned by the user" in {
+ val request = ModelResource.CreateModelRequest(
+ modelName = "forbidden-delete-model",
+ modelDescription = "for forbidden delete",
+ isModelPublic = true,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ )
+ val created = modelResource.createModel(request, sessionUser)
+
+ assertThrows[ForbiddenException] {
+ modelResource.deleteModel(created.model.getMid, sessionUser2)
+ }
+ modelDao.fetchOneByMid(created.model.getMid) should not be null
+ }
+
+ it should "surface a LakeFS 404 as NotFoundException when deleting a model whose repo is missing" in {
+ val model = new Model
+ model.setName("delete-model-no-repo")
+ model.setRepositoryName("delete-model-no-repo")
+ model.setDescription("for lakefs 404 mapping test")
+ model.setOwnerUid(ownerUser.getUid)
+ model.setIsPublic(true)
+ model.setIsDownloadable(true)
+ model.setFramework("pytorch")
+ modelDao.insert(model)
+ // intentionally no repo created in LakeFS
+
+ val ex = intercept[NotFoundException] {
+ modelResource.deleteModel(model.getMid, sessionUser)
+ }
+ assertStatus(ex, 404)
+ }
+
+ // ===========================================================================
+ // update name / description / publicity / downloadable
+ // ===========================================================================
+ "updateModelName" should "rename a model the user can write to" in {
+ val created = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "rename-me",
+ modelDescription = "d",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ val response = modelResource.updateModelName(
+ ModelResource.ModelNameModification(created.model.getMid, "renamed"),
+ sessionUser
+ )
+ response.getStatus shouldEqual 200
+ modelDao.fetchOneByMid(created.model.getMid).getName shouldEqual "renamed"
+ }
+
+ "updateModelDescription" should "update the description of a model the user can write to" in {
+ val created = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "describe-me",
+ modelDescription = "old",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ val response = modelResource.updateModelDescription(
+ ModelResource.ModelDescriptionModification(created.model.getMid, "new description"),
+ sessionUser
+ )
+ response.getStatus shouldEqual 200
+ modelDao.fetchOneByMid(created.model.getMid).getDescription shouldEqual "new description"
+ }
+
+ "toggleModelPublicity" should "flip the public flag for a writer" in {
+ val created = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "publicity-model",
+ modelDescription = "d",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ modelResource.toggleModelPublicity(created.model.getMid, sessionUser).getStatus shouldEqual 200
+ modelDao.fetchOneByMid(created.model.getMid).getIsPublic shouldBe true
+ }
+
+ "toggleModelDownloadable" should "flip the downloadable flag for the owner" in {
+ val created = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "downloadable-model",
+ modelDescription = "d",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ modelResource
+ .toggleModelDownloadable(created.model.getMid, sessionUser)
+ .getStatus shouldEqual 200
+ modelDao.fetchOneByMid(created.model.getMid).getIsDownloadable shouldBe false
+ }
+
+ it should "forbid a non-owner from toggling downloadable" in {
+ val created = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "downloadable-forbidden-model",
+ modelDescription = "d",
+ isModelPublic = true,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ assertThrows[ForbiddenException] {
+ modelResource.toggleModelDownloadable(created.model.getMid, sessionUser2)
+ }
+ }
+
+ it should "refuse to rename a model to a name the owner already uses" in {
+ modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "dup-target",
+ modelDescription = "d",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+ val second = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "dup-source",
+ modelDescription = "d",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ assertThrows[BadRequestException] {
+ modelResource.updateModelName(
+ ModelResource.ModelNameModification(second.model.getMid, "dup-target"),
+ sessionUser
+ )
+ }
+ }
+
+ it should "forbid a user without write access from renaming or re-describing a model" in {
+ val created = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "no-write-updates",
+ modelDescription = "d",
+ isModelPublic = true,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ assertThrows[ForbiddenException] {
+ modelResource.updateModelName(
+ ModelResource.ModelNameModification(created.model.getMid, "hijacked"),
+ sessionUser2
+ )
+ }
+ assertThrows[ForbiddenException] {
+ modelResource.updateModelDescription(
+ ModelResource.ModelDescriptionModification(created.model.getMid, "hijacked"),
+ sessionUser2
+ )
+ }
+ }
+
+ // ===========================================================================
+ // getPublicModel / listModels public merge
+ // ===========================================================================
+ "getPublicModel" should "return a public model without authentication" in {
+ val created = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "public-get-model",
+ modelDescription = "d",
+ isModelPublic = true,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ val dashboard = modelResource.getPublicModel(created.model.getMid)
+ dashboard.model.getMid shouldEqual created.model.getMid
+ }
+
+ it should "forbid access to a private model" in {
+ val created = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "public-get-private-model",
+ modelDescription = "d",
+ isModelPublic = false,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser
+ )
+
+ assertThrows[ForbiddenException] {
+ modelResource.getPublicModel(created.model.getMid)
+ }
+ }
+
+ "listModels" should "include public models owned by another user" in {
+ val othersPublic = modelResource.createModel(
+ ModelResource.CreateModelRequest(
+ modelName = "others-public-model",
+ modelDescription = "d",
+ isModelPublic = true,
+ isModelDownloadable = true,
+ framework = "pytorch",
+ format = null
+ ),
+ sessionUser2
+ )
+
+ val listed = modelResource.listModels(sessionUser)
+ val entry = listed.find(_.model.getMid == othersPublic.model.getMid)
+ entry should not be empty
+ entry.get.isOwner shouldBe false
+ entry.get.accessPrivilege shouldEqual PrivilegeEnum.READ
+ }
+}
diff --git a/sql/changelog.xml b/sql/changelog.xml
index b9c96431a5e..3debbad196e 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -109,6 +109,11 @@
+
+
+
+
+