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 @@ + + + + +