From 52c798d4a2760e079d0a4380baae9d6e27fa2689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Thu, 15 Feb 2024 10:00:13 +0100 Subject: [PATCH] refactor: Migrate user group endpoints to Tapir and remove UserRouteADM (#3046) --- .../org/knora/webapi/core/LayersTest.scala | 2 + .../webapi/e2e/admin/UsersADME2ESpec.scala | 10 + .../usersmessages/UsersMessagesADMSpec.scala | 59 -- .../responders/admin/UsersResponderSpec.scala | 303 +++----- .../org/knora/webapi/core/LayersLive.scala | 2 + .../groupsmessages/GroupsMessagesADM.scala | 3 + .../ProjectsMessagesADM.scala | 2 + .../usersmessages/UsersMessagesADM.scala | 152 +--- .../responders/admin/GroupsResponderADM.scala | 8 +- .../responders/admin/UsersResponder.scala | 651 +++++------------- .../org/knora/webapi/routing/ApiRoutes.scala | 1 - .../knora/webapi/routing/RouteUtilADM.scala | 13 - .../webapi/routing/admin/UsersRouteADM.scala | 160 ----- .../slice/admin/api/UsersEndpoints.scala | 54 +- .../admin/api/UsersEndpointsHandler.scala | 68 +- .../admin/api/service/UsersRestService.scala | 95 ++- .../slice/admin/domain/model/GroupIri.scala | 3 + .../domain/service/KnoraUserGroupRepo.scala | 12 + .../slice/admin/repo/rdf/Vocabulary.scala | 6 +- .../repo/service/KnoraUserGroupRepoLive.scala | 92 +++ .../common/api/AuthorizationRestService.scala | 43 +- .../AuthorizationRestServiceSpec.scala | 47 +- .../admin/domain/model/KnoraProjectSpec.scala | 2 +- .../service/KnoraUserGroupRepoLiveSpec.scala | 23 + 24 files changed, 707 insertions(+), 1104 deletions(-) delete mode 100644 webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraUserGroupRepo.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraUserGroupRepoLive.scala create mode 100644 webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/KnoraUserGroupRepoLiveSpec.scala diff --git a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala index 28ff04493c..9e954a7ae1 100644 --- a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala +++ b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala @@ -38,6 +38,7 @@ import org.knora.webapi.slice.admin.api.service.StoreRestService import org.knora.webapi.slice.admin.api.service.UsersRestService import org.knora.webapi.slice.admin.domain.service.* import org.knora.webapi.slice.admin.repo.service.KnoraProjectRepoLive +import org.knora.webapi.slice.admin.repo.service.KnoraUserGroupRepoLive import org.knora.webapi.slice.admin.repo.service.KnoraUserRepoLive import org.knora.webapi.slice.common.api.* import org.knora.webapi.slice.common.repo.service.PredicateObjectMapper @@ -179,6 +180,7 @@ object LayersTest { KnoraProjectRepoLive.layer, KnoraResponseRenderer.layer, KnoraUserRepoLive.layer, + KnoraUserGroupRepoLive.layer, ListRestService.layer, ListsEndpoints.layer, ListsEndpointsHandlers.layer, diff --git a/integration/src/test/scala/org/knora/webapi/e2e/admin/UsersADME2ESpec.scala b/integration/src/test/scala/org/knora/webapi/e2e/admin/UsersADME2ESpec.scala index b87891cf09..a2f7eecd1d 100644 --- a/integration/src/test/scala/org/knora/webapi/e2e/admin/UsersADME2ESpec.scala +++ b/integration/src/test/scala/org/knora/webapi/e2e/admin/UsersADME2ESpec.scala @@ -1108,6 +1108,16 @@ class UsersADME2ESpec } "used to modify project membership" should { + + "NOT add a user to project if the requesting user is not a SystemAdmin or ProjectAdmin" in { + val request = Post( + baseApiUrl + s"/admin/users/iri/${URLEncoder.encode(normalUser.id, "utf-8")}/project-memberships/$imagesProjectIriEnc" + ) ~> addCredentials(BasicHttpCredentials(normalUser.email, "test654321")) + val response: HttpResponse = singleAwaitingRequest(request) + + assert(response.status === StatusCodes.Forbidden) + } + "add user to project" in { val membershipsBeforeUpdate = getUserProjectMemberships(normalUser.id) membershipsBeforeUpdate should equal(Seq()) diff --git a/integration/src/test/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADMSpec.scala b/integration/src/test/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADMSpec.scala index 1119c0d8b8..9f2771d278 100644 --- a/integration/src/test/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADMSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADMSpec.scala @@ -5,7 +5,6 @@ package org.knora.webapi.messages.admin.responder.usersmessages -import dsp.errors.BadRequestException import org.knora.webapi.* import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionProfileType import org.knora.webapi.sharedtestdata.SharedTestDataADM @@ -74,62 +73,4 @@ class UsersMessagesADMSpec extends CoreSpec { ) } } - - "The ChangeUserApiRequestADM case class" should { - - "throw a BadRequestException if number of parameters is wrong" in { - - // all parameters are None - assertThrows[BadRequestException]( - ChangeUserApiRequestADM() - ) - - val errorNoParameters = the[BadRequestException] thrownBy ChangeUserApiRequestADM() - errorNoParameters.getMessage should equal("No data sent in API request.") - - // more than one parameter for status update - assertThrows[BadRequestException]( - ChangeUserApiRequestADM(status = Some(true), systemAdmin = Some(true)) - ) - - val errorTooManyParametersStatusUpdate = - the[BadRequestException] thrownBy ChangeUserApiRequestADM(status = Some(true), systemAdmin = Some(true)) - errorTooManyParametersStatusUpdate.getMessage should equal("Too many parameters sent for change request.") - - // more than one parameter for systemAdmin update - assertThrows[BadRequestException]( - ChangeUserApiRequestADM(systemAdmin = Some(true), status = Some(true)) - ) - - val errorTooManyParametersSystemAdminUpdate = - the[BadRequestException] thrownBy ChangeUserApiRequestADM(systemAdmin = Some(true), status = Some(true)) - errorTooManyParametersSystemAdminUpdate.getMessage should equal("Too many parameters sent for change request.") - - // more than 5 parameters for basic user information update - assertThrows[BadRequestException]( - ChangeUserApiRequestADM( - username = Some("newUsername"), - email = Some("newEmail@email.com"), - givenName = Some("newGivenName"), - familyName = Some("familyName"), - lang = Some("en"), - status = Some(true), - systemAdmin = Some(false) - ) - ) - - val errorTooManyParametersBasicInformationUpdate = the[BadRequestException] thrownBy ChangeUserApiRequestADM( - username = Some("newUsername"), - email = Some("newEmail@email.com"), - givenName = Some("newGivenName"), - familyName = Some("familyName"), - lang = Some("en"), - status = Some(true), - systemAdmin = Some(false) - ) - errorTooManyParametersBasicInformationUpdate.getMessage should equal( - "Too many parameters sent for change request." - ) - } - } } diff --git a/integration/src/test/scala/org/knora/webapi/responders/admin/UsersResponderSpec.scala b/integration/src/test/scala/org/knora/webapi/responders/admin/UsersResponderSpec.scala index 47f874f70b..c1ecadecb1 100644 --- a/integration/src/test/scala/org/knora/webapi/responders/admin/UsersResponderSpec.scala +++ b/integration/src/test/scala/org/knora/webapi/responders/admin/UsersResponderSpec.scala @@ -357,24 +357,22 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { } "UPDATE the user's system admin membership" in { - appActor ! UserChangeSystemAdminMembershipStatusRequestADM( - userIri = SharedTestDataADM.normalUser.id, - systemAdmin = SystemAdmin.from(true), - requestingUser = SharedTestDataADM.superUser, - UUID.randomUUID() + val response1 = UnsafeZioRun.runOrThrow( + UsersResponder.changeSystemAdmin( + SharedTestDataADM.normalUser.userIri, + SystemAdmin.from(true), + UUID.randomUUID() + ) ) - - val response1 = expectMsgType[UserOperationResponseADM](timeout) response1.user.isSystemAdmin should equal(true) - appActor ! UserChangeSystemAdminMembershipStatusRequestADM( - userIri = SharedTestDataADM.normalUser.id, - systemAdmin = SystemAdmin.from(false), - requestingUser = SharedTestDataADM.superUser, - UUID.randomUUID() + val response2 = UnsafeZioRun.runOrThrow( + UsersResponder.changeSystemAdmin( + SharedTestDataADM.normalUser.userIri, + SystemAdmin.from(false), + UUID.randomUUID() + ) ) - - val response2 = expectMsgType[UserOperationResponseADM](timeout) response2.user.permissions.isSystemAdmin should equal(false) } } @@ -388,16 +386,15 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { membershipsBeforeUpdate.projects should equal(Seq()) // add user to images project (00FF) - appActor ! UserProjectMembershipAddRequestADM( - normalUser.id, - imagesProject.id, - rootUser, - UUID.randomUUID() + UnsafeZioRun.runOrThrow( + UsersResponder.addProjectToUserIsInProject( + normalUser.userIri, + imagesProject.projectIri, + rootUser, + UUID.randomUUID() + ) ) - // wait for the response before checking the project membership - expectMsgType[UserOperationResponseADM](timeout) - val membershipsAfterUpdate = UnsafeZioRun.runOrThrow(UsersResponder.findProjectMemberShipsByIri(normalUser.userIri)) membershipsAfterUpdate.projects should equal(Seq(imagesProject)) @@ -411,42 +408,6 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { received.members.map(_.id) should contain(normalUser.id) } - "not ADD user to project as project admin of another project" in { - // get current project memberships - val membershipsBeforeUpdate = - UnsafeZioRun.runOrThrow(UsersResponder.findProjectMemberShipsByIri(normalUser.userIri)) - membershipsBeforeUpdate.projects.map(_.id).sorted should equal(Seq(imagesProject.id).sorted) - - // try to add user to incunabula project but as project admin of another project - appActor ! UserProjectMembershipAddRequestADM( - normalUser.id, - incunabulaProject.id, - anythingAdminUser, - UUID.randomUUID() - ) - - expectMsg( - timeout, - Failure( - ForbiddenException("User's project membership can only be changed by a project or system administrator") - ) - ) - - // check that the user is still only member of one project - val membershipsAfterUpdate = - UnsafeZioRun.runOrThrow(UsersResponder.findProjectMemberShipsByIri(normalUser.userIri)) - membershipsAfterUpdate.projects.map(_.id).sorted should equal(Seq(imagesProject.id).sorted) - - // check that the user was not added to the project - val received = UnsafeZioRun.runOrThrow( - ProjectsResponderADM.projectMembersGetRequestADM( - IriIdentifier.unsafeFrom(incunabulaProject.id), - KnoraSystemInstances.Users.SystemUser - ) - ) - received.members.map(_.id) should not contain normalUser.id - } - "ADD user to project as project admin" in { // get current project memberships val membershipsBeforeUpdate = @@ -454,16 +415,15 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { membershipsBeforeUpdate.projects.map(_.id).sorted should equal(Seq(imagesProject.id).sorted) // add user to images project (00FF) - appActor ! UserProjectMembershipAddRequestADM( - normalUser.id, - incunabulaProject.id, - incunabulaProjectAdminUser, - UUID.randomUUID() + UnsafeZioRun.runOrThrow( + UsersResponder.addProjectToUserIsInProject( + normalUser.userIri, + incunabulaProject.projectIri, + incunabulaProjectAdminUser, + UUID.randomUUID() + ) ) - // wait for the response before checking the project membership - expectMsgType[UserOperationResponseADM](timeout) - val membershipsAfterUpdate = UnsafeZioRun.runOrThrow(UsersResponder.findProjectMemberShipsByIri(normalUser.userIri)) membershipsAfterUpdate.projects.map(_.id).sorted should equal( @@ -488,28 +448,29 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { ) // add user as project admin to images project - appActor ! UserProjectAdminMembershipAddRequestADM( - normalUser.id, - imagesProject.id, - rootUser, - UUID.randomUUID() + UnsafeZioRun.runOrThrow( + UsersResponder.addProjectToUserIsInProjectAdminGroup( + normalUser.userIri, + imagesProject.projectIri, + rootUser, + UUID.randomUUID() + ) ) - expectMsgType[UserOperationResponseADM](timeout) - // verify that the user has been added as project admin to the images project val projectAdminMembershipsBeforeUpdate = UnsafeZioRun.runOrThrow(UsersResponder.findUserProjectAdminMemberships(normalUser.userIri)) projectAdminMembershipsBeforeUpdate.projects.map(_.id).sorted should equal(Seq(imagesProject.id).sorted) // remove the user as member of the images project - appActor ! UserProjectMembershipRemoveRequestADM( - normalUser.id, - imagesProject.id, - rootUser, - UUID.randomUUID() + UnsafeZioRun.runOrThrow( + UsersResponder.removeProjectFromUserIsInProjectAndIsInProjectAdminGroup( + normalUser.userIri, + imagesProject.projectIri, + rootUser, + UUID.randomUUID() + ) ) - expectMsgType[UserOperationResponseADM](timeout) // verify that the user has been removed as project member of the images project val membershipsAfterUpdate = @@ -527,37 +488,6 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { ) received.members should not contain normalUser.ofType(UserInformationTypeADM.Restricted) } - - "return a 'ForbiddenException' if the user requesting update is not the project or system admin" in { - /* User is added to a project by a normal user */ - appActor ! UserProjectMembershipAddRequestADM( - normalUser.id, - imagesProject.id, - normalUser, - UUID.randomUUID() - ) - expectMsg( - timeout, - Failure( - ForbiddenException("User's project membership can only be changed by a project or system administrator") - ) - ) - - /* User is removed from a project by a normal user */ - appActor ! UserProjectMembershipRemoveRequestADM( - normalUser.id, - imagesProject.id, - normalUser, - UUID.randomUUID() - ) - expectMsg( - timeout, - Failure( - ForbiddenException("User's project membership can only be changed by a project or system administrator") - ) - ) - } - } "asked to update the user's project admin group membership" should { @@ -568,20 +498,18 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { membershipsBeforeUpdate.projects should equal(Seq()) // try to add user as project admin to images project (expected to fail because he is not a member of the project) - appActor ! UserProjectAdminMembershipAddRequestADM( - normalUser.id, - imagesProject.id, - rootUser, - UUID.randomUUID() - ) - expectMsg( - timeout, - Failure( - BadRequestException( - "User http://rdfh.ch/users/normaluser is not a member of project http://rdfh.ch/projects/00FF. A user needs to be a member of the project to be added as project admin." - ) + val exit = UnsafeZioRun.run( + UsersResponder.addProjectToUserIsInProjectAdminGroup( + normalUser.userIri, + imagesProject.projectIri, + rootUser, + UUID.randomUUID() ) ) + assertFailsWithA[BadRequestException]( + exit, + "User http://rdfh.ch/users/normaluser is not a member of project http://rdfh.ch/projects/00FF. A user needs to be a member of the project to be added as project admin." + ) } "ADD user to project admin group" in { @@ -591,24 +519,25 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { membershipsBeforeUpdate.projects should equal(Seq()) // add user as project member to images project - appActor ! UserProjectMembershipAddRequestADM( - normalUser.id, - imagesProject.id, - rootUser, - UUID.randomUUID() + UnsafeZioRun.runOrThrow( + UsersResponder.addProjectToUserIsInProject( + normalUser.userIri, + imagesProject.projectIri, + rootUser, + UUID.randomUUID() + ) ) - expectMsgType[UserOperationResponseADM](timeout) // add user as project admin to images project - appActor ! UserProjectAdminMembershipAddRequestADM( - normalUser.id, - imagesProject.id, - rootUser, - UUID.randomUUID() + UnsafeZioRun.runOrThrow( + UsersResponder.addProjectToUserIsInProjectAdminGroup( + normalUser.userIri, + imagesProject.projectIri, + rootUser, + UUID.randomUUID() + ) ) - expectMsgType[UserOperationResponseADM](timeout) - // get the updated project admin memberships (should contain images project) val membershipsAfterUpdate = UnsafeZioRun.runOrThrow(UsersResponder.findUserProjectAdminMemberships(normalUser.userIri)) @@ -626,13 +555,14 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { UnsafeZioRun.runOrThrow(UsersResponder.findUserProjectAdminMemberships(normalUser.userIri)) membershipsBeforeUpdate.projects should equal(Seq(imagesProject)) - appActor ! UserProjectAdminMembershipRemoveRequestADM( - normalUser.id, - imagesProject.id, - rootUser, - UUID.randomUUID() + UnsafeZioRun.runOrThrow( + UsersResponder.removeProjectFromUserIsInProjectAdminGroup( + normalUser.userIri, + imagesProject.projectIri, + rootUser, + UUID.randomUUID() + ) ) - expectMsgType[UserOperationResponseADM](timeout) val membershipsAfterUpdate = UnsafeZioRun.runOrThrow(UsersResponder.findUserProjectAdminMemberships(normalUser.userIri)) @@ -643,41 +573,6 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { ) received.members should not contain normalUser.ofType(UserInformationTypeADM.Restricted) } - - "return a 'ForbiddenException' if the user requesting update is not the project or system admin" in { - /* User is added to a project by a normal user */ - appActor ! UserProjectAdminMembershipAddRequestADM( - normalUser.id, - imagesProject.id, - normalUser, - UUID.randomUUID() - ) - expectMsg( - timeout, - Failure( - ForbiddenException( - "User's project admin membership can only be changed by a project or system administrator" - ) - ) - ) - - /* User is removed from a project by a normal user */ - appActor ! UserProjectAdminMembershipRemoveRequestADM( - normalUser.id, - imagesProject.id, - normalUser, - UUID.randomUUID() - ) - expectMsg( - timeout, - Failure( - ForbiddenException( - "User's project admin membership can only be changed by a project or system administrator" - ) - ) - ) - } - } "asked to update the user's group membership" should { @@ -686,13 +581,14 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { UnsafeZioRun.runOrThrow(UsersResponder.findGroupMembershipsByIri(normalUser.userIri)) membershipsBeforeUpdate should equal(Seq()) - appActor ! UserGroupMembershipAddRequestADM( - normalUser.id, - imagesReviewerGroup.id, - rootUser, - UUID.randomUUID() + UnsafeZioRun.runOrThrow( + UsersResponder.addGroupToUserIsInGroup( + normalUser.userIri, + imagesReviewerGroup.groupIri, + rootUser, + UUID.randomUUID() + ) ) - expectMsgType[UserOperationResponseADM](timeout) val membershipsAfterUpdate = UnsafeZioRun.runOrThrow(UsersResponder.findGroupMembershipsByIri(normalUser.userIri)) @@ -712,13 +608,14 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { UnsafeZioRun.runOrThrow(UsersResponder.findGroupMembershipsByIri(normalUser.userIri)) membershipsBeforeUpdate.map(_.id) should equal(Seq(imagesReviewerGroup.id)) - appActor ! UserGroupMembershipRemoveRequestADM( - normalUser.id, - imagesReviewerGroup.id, - rootUser, - UUID.randomUUID() + UnsafeZioRun.runOrThrow( + UsersResponder.removeGroupFromUserIsInGroup( + normalUser.userIri, + imagesReviewerGroup.groupIri, + rootUser, + UUID.randomUUID() + ) ) - expectMsgType[UserOperationResponseADM](timeout) val membershipsAfterUpdate = UnsafeZioRun.runOrThrow(UsersResponder.findGroupMembershipsByIri(normalUser.userIri)) @@ -732,36 +629,6 @@ class UsersResponderSpec extends CoreSpec with ImplicitSender { received.members.map(_.id) should not contain normalUser.id } - - "return a 'ForbiddenException' if the user requesting update is not the project or system admin" in { - /* User is added to a project by a normal user */ - appActor ! UserGroupMembershipAddRequestADM( - normalUser.id, - imagesReviewerGroup.id, - normalUser, - UUID.randomUUID() - ) - expectMsg( - timeout, - Failure( - ForbiddenException("User's group membership can only be changed by a project or system administrator") - ) - ) - - /* User is removed from a project by a normal user */ - appActor ! UserGroupMembershipRemoveRequestADM( - normalUser.id, - imagesReviewerGroup.id, - normalUser, - UUID.randomUUID() - ) - expectMsg( - timeout, - Failure( - ForbiddenException("User's group membership can only be changed by a project or system administrator") - ) - ) - } } } } diff --git a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala index 3f6f210fd1..1f0c0c2e51 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -38,6 +38,7 @@ import org.knora.webapi.slice.admin.api.service.StoreRestService import org.knora.webapi.slice.admin.api.service.UsersRestService import org.knora.webapi.slice.admin.domain.service.* import org.knora.webapi.slice.admin.repo.service.KnoraProjectRepoLive +import org.knora.webapi.slice.admin.repo.service.KnoraUserGroupRepoLive import org.knora.webapi.slice.admin.repo.service.KnoraUserRepoLive import org.knora.webapi.slice.common.api.* import org.knora.webapi.slice.common.repo.service.PredicateObjectMapper @@ -125,6 +126,7 @@ object LayersLive { KnoraProjectRepoLive.layer, KnoraResponseRenderer.layer, KnoraUserRepoLive.layer, + KnoraUserGroupRepoLive.layer, ListRestService.layer, ListsEndpoints.layer, ListsEndpointsHandlers.layer, diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/groupsmessages/GroupsMessagesADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/groupsmessages/GroupsMessagesADM.scala index f7493b4f1f..7ba5b94b3c 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/groupsmessages/GroupsMessagesADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/groupsmessages/GroupsMessagesADM.scala @@ -21,6 +21,7 @@ import org.knora.webapi.messages.admin.responder.AdminKnoraResponseADM import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsADMJsonProtocol import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2 +import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.admin.domain.model.User ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -213,6 +214,8 @@ case class GroupADM( selfjoin: Boolean ) extends Ordered[GroupADM] { + def groupIri: GroupIri = GroupIri.unsafeFrom(id) + /** * Allows to sort collections of GroupADM. Sorting is done by the id. */ diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala index 0ad77ffc4c..10decdf39f 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/projectsmessages/ProjectsMessagesADM.scala @@ -204,6 +204,8 @@ case class ProjectADM( selfjoin: Boolean ) extends Ordered[ProjectADM] { + def projectIri: ProjectIri = ProjectIri.unsafeFrom(id) + if (description.isEmpty) { throw OntologyConstraintException("Project description is a required property.") } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADM.scala b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADM.scala index 809a2ef64b..65bdd36582 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/admin/responder/usersmessages/UsersMessagesADM.scala @@ -23,62 +23,6 @@ import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsADMJsonProtocol import org.knora.webapi.slice.admin.domain.model.* -////////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// API requests - -/** - * Represents an API request payload that asks the Knora API server to update an existing user. Information that can - * be changed are: user's username, email, given name, family name, language, user status, and system admin membership. - * - * @param username the new username. Needs to be unique on the server. - * @param email the new email address. Needs to be unique on the server. - * @param givenName the new given name. - * @param familyName the new family name. - * @param lang the new ISO 639-1 code of the new preferred language. - * @param status the new user status (active = true, inactive = false). - * @param systemAdmin the new system admin membership status. - */ -case class ChangeUserApiRequestADM( - username: Option[String] = None, - email: Option[String] = None, - givenName: Option[String] = None, - familyName: Option[String] = None, - lang: Option[String] = None, - status: Option[Boolean] = None, - systemAdmin: Option[Boolean] = None -) { - - val parametersCount: Int = List( - username, - email, - givenName, - familyName, - lang, - status, - systemAdmin - ).flatten.size - - // something needs to be sent, i.e. everything 'None' is not allowed - if (parametersCount == 0) throw BadRequestException("No data sent in API request.") - - /* check that only allowed information for the 3 cases (changing status, systemAdmin and basic information) is sent and not more. */ - - // change status case - if (status.isDefined) { - if (parametersCount > 1) throw BadRequestException("Too many parameters sent for change request.") - } - - // change system admin membership case - if (systemAdmin.isDefined) { - if (parametersCount > 1) throw BadRequestException("Too many parameters sent for change request.") - } - - // change basic user information case - if (parametersCount > 5) throw BadRequestException("Too many parameters sent for change request.") - - def toJsValue: JsValue = UsersADMJsonProtocol.changeUserApiRequestADMFormat.write(this) -} - ////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Messages @@ -108,96 +52,6 @@ case class UserGetByIriADM( requestingUser: User ) extends UsersResponderRequestADM -/** - * Request updating the users system admin status ('knora-base:isInSystemAdminGroup' property) - * - * @param userIri the IRI of the user to be updated. - * @param systemAdmin the [[SystemAdmin]] value object containing the new system admin membership status (true / false). - * @param requestingUser the user initiating the request. - * @param apiRequestID the ID of the API request. - */ -case class UserChangeSystemAdminMembershipStatusRequestADM( - userIri: IRI, - systemAdmin: SystemAdmin, - requestingUser: User, - apiRequestID: UUID -) extends UsersResponderRequestADM - -/** - * Requests adding the user to a project. - * - * @param userIri the IRI of the user to be updated. - * @param projectIri the IRI of the project. - * @param requestingUser the user initiating the request. - * @param apiRequestID the ID of the API request. - */ -case class UserProjectMembershipAddRequestADM( - userIri: IRI, - projectIri: IRI, - requestingUser: User, - apiRequestID: UUID -) extends UsersResponderRequestADM - -/** - * Requests removing the user from a project. - * - * @param userIri the IRI of the user to be updated. - * @param projectIri the IRI of the project. - * @param requestingUser the user initiating the request. - * @param apiRequestID the ID of the API request. - */ -case class UserProjectMembershipRemoveRequestADM( - userIri: IRI, - projectIri: IRI, - requestingUser: User, - apiRequestID: UUID -) extends UsersResponderRequestADM - -/** - * Requests adding the user to a project as project admin. - * - * @param userIri the IRI of the user to be updated. - * @param projectIri the IRI of the project. - * @param requestingUser the user initiating the request. - * @param apiRequestID the ID of the API request. - */ -case class UserProjectAdminMembershipAddRequestADM( - userIri: IRI, - projectIri: IRI, - requestingUser: User, - apiRequestID: UUID -) extends UsersResponderRequestADM - -/** - * Requests removing the user from a project as project admin. - * - * @param userIri the IRI of the user to be updated. - * @param projectIri the IRI of the project. - * @param requestingUser the user initiating the request. - * @param apiRequestID the ID of the API request. - */ -case class UserProjectAdminMembershipRemoveRequestADM( - userIri: IRI, - projectIri: IRI, - requestingUser: User, - apiRequestID: UUID -) extends UsersResponderRequestADM - -/** - * Requests adding the user to a group. - * - * @param userIri the IRI of the user to be updated. - * @param groupIri the IRI of the group. - * @param requestingUser the user initiating the request. - * @param apiRequestID the ID of the API request. - */ -case class UserGroupMembershipAddRequestADM( - userIri: IRI, - groupIri: IRI, - requestingUser: User, - apiRequestID: UUID -) extends UsersResponderRequestADM - /** * Requests removing the user from a group. * @@ -207,8 +61,8 @@ case class UserGroupMembershipAddRequestADM( * @param apiRequestID the ID of the API request. */ case class UserGroupMembershipRemoveRequestADM( - userIri: IRI, - groupIri: IRI, + userIri: UserIri, + groupIri: GroupIri, requestingUser: User, apiRequestID: UUID ) extends UsersResponderRequestADM @@ -397,8 +251,6 @@ object UsersADMJsonProtocol implicit val userADMFormat: JsonFormat[User] = jsonFormat11(User) implicit val groupMembersGetResponseADMFormat: RootJsonFormat[GroupMembersGetResponseADM] = jsonFormat(GroupMembersGetResponseADM, "members") - implicit val changeUserApiRequestADMFormat: RootJsonFormat[ChangeUserApiRequestADM] = - jsonFormat(ChangeUserApiRequestADM, "username", "email", "givenName", "familyName", "lang", "status", "systemAdmin") implicit val usersGetResponseADMFormat: RootJsonFormat[UsersGetResponseADM] = jsonFormat1(UsersGetResponseADM) implicit val userProfileResponseADMFormat: RootJsonFormat[UserResponseADM] = jsonFormat1(UserResponseADM) implicit val userProjectMembershipsGetResponseADMFormat: RootJsonFormat[UserProjectMembershipsGetResponseADM] = diff --git a/webapi/src/main/scala/org/knora/webapi/responders/admin/GroupsResponderADM.scala b/webapi/src/main/scala/org/knora/webapi/responders/admin/GroupsResponderADM.scala index 9f2499c4ff..b15750fb5a 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/admin/GroupsResponderADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/admin/GroupsResponderADM.scala @@ -607,10 +607,10 @@ final case class GroupsResponderADMLive( messageRelay .ask[UserOperationResponseADM]( UserGroupMembershipRemoveRequestADM( - userIri = user.id, - groupIri = changedGroup.id, - requestingUser = KnoraSystemInstances.Users.SystemUser, - apiRequestID = apiRequestID + user.userIri, + changedGroup.groupIri, + KnoraSystemInstances.Users.SystemUser, + apiRequestID ) ) } diff --git a/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponder.scala b/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponder.scala index f48ca9b94b..953f43e25c 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponder.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponder.scala @@ -25,7 +25,6 @@ import org.knora.webapi.messages.OntologyConstants import org.knora.webapi.messages.ResponderRequest import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.admin.responder.groupsmessages.GroupADM -import org.knora.webapi.messages.admin.responder.groupsmessages.GroupGetADM import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectGetADM import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.* @@ -45,7 +44,9 @@ import org.knora.webapi.slice.admin.AdminConstants import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.BasicUserInformationChangeRequest import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.PasswordChangeRequest import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.UserCreateRequest +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.domain.model.* +import org.knora.webapi.slice.admin.domain.service.KnoraUserRepo import org.knora.webapi.slice.admin.domain.service.PasswordService import org.knora.webapi.slice.admin.domain.service.UserService import org.knora.webapi.slice.common.Value.StringValue @@ -63,6 +64,7 @@ final case class UsersResponder( iriService: IriService, iriConverter: IriConverter, userService: UserService, + userRepo: KnoraUserRepo, passwordService: PasswordService, messageRelay: MessageRelay, triplestore: TriplestoreService, @@ -83,50 +85,8 @@ final case class UsersResponder( case UsersGetRequestADM(requestingUser) => getAllUserADMRequest(requestingUser) case UserGetByIriADM(identifier, userInformationTypeADM, requestingUser) => findUserByIri(identifier, userInformationTypeADM, requestingUser) - case UserChangeSystemAdminMembershipStatusRequestADM( - userIri, - changeSystemAdminMembershipStatusRequest, - requestingUser, - apiRequestID - ) => - changeUserSystemAdminMembershipStatusADM( - userIri, - changeSystemAdminMembershipStatusRequest, - requestingUser, - apiRequestID - ) - case UserProjectMembershipAddRequestADM(userIri, projectIri, requestingUser, apiRequestID) => - userProjectMembershipAddRequestADM(userIri, projectIri, requestingUser, apiRequestID) - case UserProjectMembershipRemoveRequestADM( - userIri, - projectIri, - requestingUser, - apiRequestID - ) => - userProjectMembershipRemoveRequestADM(userIri, projectIri, requestingUser, apiRequestID) - case UserProjectAdminMembershipAddRequestADM( - userIri, - projectIri, - requestingUser, - apiRequestID - ) => - userProjectAdminMembershipAddRequestADM(userIri, projectIri, requestingUser, apiRequestID) - case UserProjectAdminMembershipRemoveRequestADM( - userIri, - projectIri, - requestingUser, - apiRequestID - ) => - userProjectAdminMembershipRemoveRequestADM( - userIri, - projectIri, - requestingUser, - apiRequestID - ) - case UserGroupMembershipAddRequestADM(userIri, projectIri, requestingUser, apiRequestID) => - userGroupMembershipAddRequestADM(userIri, projectIri, requestingUser, apiRequestID) case UserGroupMembershipRemoveRequestADM(userIri, projectIri, requestingUser, apiRequestID) => - userGroupMembershipRemoveRequestADM(userIri, projectIri, requestingUser, apiRequestID) + removeGroupFromUserIsInGroup(userIri, projectIri, requestingUser, apiRequestID) case other => Responder.handleUnexpectedMessage(other, this.getClass.getName) } @@ -357,50 +317,18 @@ final case class UsersResponder( * @param userIri the IRI of the existing user that we want to update. * @param systemAdmin the new status. * - * @param requestingUser the user profile of the requesting user. - * @param apiRequestID the unique api request ID. + * @param apiRequestId the unique api request ID. * @return a future containing a [[UserOperationResponseADM]]. * fails with a [[BadRequestException]] if necessary parameters are not supplied. * fails with a [[ForbiddenException]] if the user doesn't hold the necessary permission for the operation. */ - private def changeUserSystemAdminMembershipStatusADM( - userIri: IRI, + def changeSystemAdmin( + userIri: UserIri, systemAdmin: SystemAdmin, - requestingUser: User, - apiRequestID: UUID + apiRequestId: UUID ): Task[UserOperationResponseADM] = { - - /** - * The actual change user status task run with an IRI lock. - */ - def changeUserSystemAdminMembershipStatusTask( - userIri: IRI, - systemAdmin: SystemAdmin, - requestingUser: User - ): Task[UserOperationResponseADM] = - for { - // check if the requesting user is allowed to perform updates (i.e. system admin) - _ <- - ZIO.attempt( - if (!requestingUser.permissions.isSystemAdmin) { - throw ForbiddenException("User's system admin membership can only be changed by a system administrator") - } - ) - - // create the update request - result <- updateUserADM( - userIri = UserIri.unsafeFrom(userIri), - req = UserChangeRequestADM(systemAdmin = Some(systemAdmin)), - requestingUser = Users.SystemUser - ) - - } yield result - - IriLocker.runWithIriLock( - apiRequestID, - userIri, - changeUserSystemAdminMembershipStatusTask(userIri, systemAdmin, requestingUser) - ) + val updateTask = updateUserADM(userIri, UserChangeRequestADM(systemAdmin = Some(systemAdmin)), Users.SystemUser) + IriLocker.runWithIriLock(apiRequestId, userIri.value, updateTask) } /** @@ -438,74 +366,30 @@ final case class UsersResponder( * @param apiRequestID the unique api request ID. * @return */ - private def userProjectMembershipAddRequestADM( - userIri: IRI, - projectIri: IRI, + def addProjectToUserIsInProject( + userIri: UserIri, + projectIri: ProjectIri, requestingUser: User, apiRequestID: UUID ): Task[UserOperationResponseADM] = { - - logger.debug(s"userProjectMembershipAddRequestADM: userIri: {}, projectIri: {}", userIri, projectIri) - - /** - * The actual task run with an IRI lock. - */ - def userProjectMembershipAddRequestTask( - userIri: IRI, - projectIri: IRI, - requestingUser: User - ): Task[UserOperationResponseADM] = + val updateTask = for { - // check if the requesting user is allowed to perform updates (i.e. is project or system admin) - _ <- - ZIO.attempt( - if (!requestingUser.permissions.isProjectAdmin(projectIri) && !requestingUser.permissions.isSystemAdmin) { - throw ForbiddenException( - "User's project membership can only be changed by a project or system administrator" - ) - } - ) - - // check if user exists - userExists <- userExists(userIri) - _ = if (!userExists) throw NotFoundException(s"The user $userIri does not exist.") - - // check if project exists - projectExists <- projectExists(projectIri) - _ = if (!projectExists) throw NotFoundException(s"The project $projectIri does not exist.") - - // get users current project membership list - currentProjectMembershipIris <- - findProjectMemberShipsByIri(UserIri.unsafeFrom(userIri)).map(_.projects.map(_.id)) - - // check if user is already member and if not then append to list - updatedProjectMembershipIris = - if (!currentProjectMembershipIris.contains(projectIri)) { - currentProjectMembershipIris :+ projectIri - } else { - throw BadRequestException( - s"User $userIri is already member of project $projectIri." - ) - } - - // create the update request - updateUserResult <- updateUserADM( - userIri = UserIri.unsafeFrom(userIri), - req = UserChangeRequestADM(projects = Some(updatedProjectMembershipIris)), - requestingUser = requestingUser - ) + kUser <- userRepo.findById(userIri).someOrFail(NotFoundException(s"The user $userIri does not exist.")) + currentIsInProject = kUser.isInProject + _ <- ZIO.when(currentIsInProject.contains(projectIri))( + ZIO.fail(BadRequestException(s"User ${userIri.value} is already member of project ${projectIri.value}.")) + ) + newIsInProject = (currentIsInProject :+ projectIri).map(_.value) + theChange = UserChangeRequestADM(projects = Some(newIsInProject)) + updateUserResult <- updateUserADM(userIri, theChange, requestingUser) } yield updateUserResult - - IriLocker.runWithIriLock( - apiRequestID, - userIri, - userProjectMembershipAddRequestTask(userIri, projectIri, requestingUser) - ) - + IriLocker.runWithIriLock(apiRequestID, userIri.value, updateTask) } /** - * Removes a user from a project. + * Removes a project from the user's projects. + * If the project is not in the user's projects, a BadRequestException is returned. + * If the project is in the user's admin projects, it is removed. * * @param userIri the user's IRI. * @param projectIri the project's IRI. @@ -513,81 +397,29 @@ final case class UsersResponder( * @param apiRequestID the unique api request ID. * @return */ - private def userProjectMembershipRemoveRequestADM( - userIri: IRI, - projectIri: IRI, + def removeProjectFromUserIsInProjectAndIsInProjectAdminGroup( + userIri: UserIri, + projectIri: ProjectIri, requestingUser: User, apiRequestID: UUID ): Task[UserOperationResponseADM] = { - - /** - * The actual task run with an IRI lock. - */ - def userProjectMembershipRemoveRequestTask( - userIri: IRI, - projectIri: IRI, - requestingUser: User - ): Task[UserOperationResponseADM] = + val updateTask = for { - // check if the requesting user is allowed to perform updates (i.e. is project or system admin) - _ <- - ZIO.attempt( - if (!requestingUser.permissions.isProjectAdmin(projectIri) && !requestingUser.permissions.isSystemAdmin) { - throw ForbiddenException( - "User's project membership can only be changed by a project or system administrator" - ) - } - ) - - // check if user exists - userExists <- userExists(userIri) - _ = if (!userExists) throw NotFoundException(s"The user $userIri does not exist.") - - // check if project exists - projectExists <- projectExists(projectIri) - _ = if (!projectExists) throw NotFoundException(s"The project $projectIri does not exist.") - - // get users current project membership list - currentProjectMemberships <- userProjectMembershipsGetADM(userIri = userIri) - currentProjectMembershipIris = currentProjectMemberships.map(_.id) - - // check if user is a member and if he is then remove the project from to list - updatedProjectMembershipIris = - if (currentProjectMembershipIris.contains(projectIri)) { - currentProjectMembershipIris diff Seq(projectIri) - } else { - throw BadRequestException( - s"User $userIri is not member of project $projectIri." - ) - } - - // get users current project admin membership list - currentProjectAdminMembershipIris <- userProjectAdminMembershipsGetADM(userIri).map(_.map(_.id)) - - // in case the user has an admin membership for that project, remove it as well - maybeUpdatedProjectAdminMembershipIris = - if (currentProjectAdminMembershipIris.contains(projectIri)) { - Some(currentProjectAdminMembershipIris.filterNot(p => p == projectIri)) - } else { - None - } - - // create the update request by using the SystemUser - result <- updateUserADM( - userIri = UserIri.unsafeFrom(userIri), - req = UserChangeRequestADM( - projects = Some(updatedProjectMembershipIris), - projectsAdmin = maybeUpdatedProjectAdminMembershipIris - ), - requestingUser = Users.SystemUser - ) - } yield result - - IriLocker.runWithIriLock( - apiRequestID, - userIri, - userProjectMembershipRemoveRequestTask(userIri, projectIri, requestingUser) - ) + kUser <- userRepo.findById(userIri).someOrFail(NotFoundException(s"The user $userIri does not exist.")) + currentIsInProject = kUser.isInProject + _ <- ZIO.when(!currentIsInProject.contains(projectIri))( + ZIO.fail(BadRequestException(s"User $userIri is not member of project ${projectIri.value}.")) + ) + newIsInProject = currentIsInProject.filterNot(_ == projectIri).map(_.value) + currentIsInProjectAdminGroup = kUser.isInProjectAdminGroup + newIsInProjectAdminGroup = currentIsInProjectAdminGroup.filterNot(_ == projectIri).map(_.value) + theChange = UserChangeRequestADM( + projects = Some(newIsInProject), + projectsAdmin = Some(newIsInProjectAdminGroup) + ) + updateUserResult <- updateUserADM(userIri, theChange, requestingUser) + } yield updateUserResult + IriLocker.runWithIriLock(apiRequestID, userIri.value, updateTask) } /** @@ -646,80 +478,33 @@ final case class UsersResponder( * @param apiRequestID the unique api request ID. * @return a [[UserOperationResponseADM]]. */ - private def userProjectAdminMembershipAddRequestADM( - userIri: IRI, - projectIri: IRI, + def addProjectToUserIsInProjectAdminGroup( + userIri: UserIri, + projectIri: ProjectIri, requestingUser: User, apiRequestID: UUID ): Task[UserOperationResponseADM] = { - - /** - * The actual task run with an IRI lock. - */ - def userProjectAdminMembershipAddRequestTask( - userIri: IRI, - projectIri: IRI, - requestingUser: User - ): Task[UserOperationResponseADM] = + val updateTask = for { - // check if the requesting user is allowed to perform updates (i.e. project admin or system admin) + kUser <- userRepo.findById(userIri).someOrFail(NotFoundException(s"The user $userIri does not exist.")) + currentIsInProject = kUser.isInProject _ <- - ZIO.attempt( - if (!requestingUser.permissions.isProjectAdmin(projectIri) && !requestingUser.permissions.isSystemAdmin) { - throw ForbiddenException( - "User's project admin membership can only be changed by a project or system administrator" + ZIO.when(!currentIsInProject.contains(projectIri))( + ZIO.fail( + BadRequestException( + s"User ${userIri.value} is not a member of project ${projectIri.value}. A user needs to be a member of the project to be added as project admin." ) - } - ) - - // check if user exists - userExists <- userExists(userIri) - _ = if (!userExists) throw NotFoundException(s"The user $userIri does not exist.") - - // check if project exists - projectExists <- projectExists(projectIri) - _ = if (!projectExists) throw NotFoundException(s"The project $projectIri does not exist.") - - // get users current project membership list - currentProjectMemberships <- userProjectMembershipsGetADM(userIri = userIri) - - currentProjectMembershipIris = currentProjectMemberships.map(_.id) - - // check if user is already project member and if not throw exception - - _ = if (!currentProjectMembershipIris.contains(projectIri)) { - throw BadRequestException( - s"User $userIri is not a member of project $projectIri. A user needs to be a member of the project to be added as project admin." - ) - } - - // get users current project admin membership list - currentProjectAdminMembershipIris <- userProjectAdminMembershipsGetADM(userIri).map(_.map(_.id)) - - // check if user is already project admin and if not then append to list - updatedProjectAdminMembershipIris = - if (!currentProjectAdminMembershipIris.contains(projectIri)) { - currentProjectAdminMembershipIris :+ projectIri - } else { - throw BadRequestException( - s"User $userIri is already a project admin for project $projectIri." ) - } - - // create the update request - result <- updateUserADM( - userIri = UserIri.unsafeFrom(userIri), - req = UserChangeRequestADM(projectsAdmin = Some(updatedProjectAdminMembershipIris)), - requestingUser = Users.SystemUser - ) - } yield result - - IriLocker.runWithIriLock( - apiRequestID, - userIri, - userProjectAdminMembershipAddRequestTask(userIri, projectIri, requestingUser) - ) - + ) + currentIsInProjectAdminGroup = kUser.isInProjectAdminGroup + _ <- ZIO.when(currentIsInProjectAdminGroup.contains(projectIri))( + ZIO.fail(BadRequestException(s"User $userIri is already a project admin for project $projectIri.")) + ) + newIsInProjectAdminGroup = (currentIsInProjectAdminGroup :+ projectIri).map(_.value) + theChange = UserChangeRequestADM(projectsAdmin = Some(newIsInProjectAdminGroup)) + updateUserResult <- updateUserADM(userIri, theChange, requestingUser) + } yield updateUserResult + IriLocker.runWithIriLock(apiRequestID, userIri.value, updateTask) } /** @@ -731,66 +516,24 @@ final case class UsersResponder( * @param apiRequestID the unique api request ID. * @return a [[UserOperationResponseADM]] */ - private def userProjectAdminMembershipRemoveRequestADM( - userIri: IRI, - projectIri: IRI, + def removeProjectFromUserIsInProjectAdminGroup( + userIri: UserIri, + projectIri: ProjectIri, requestingUser: User, apiRequestID: UUID ): Task[UserOperationResponseADM] = { - - /** - * The actual task run with an IRI lock. - */ - def userProjectAdminMembershipRemoveRequestTask( - userIri: IRI, - projectIri: IRI, - requestingUser: User - ): Task[UserOperationResponseADM] = + val updateTask = for { - // check if the requesting user is allowed to perform updates (i.e. requesting updates own information or is system admin) - _ <- - ZIO.attempt( - if (!requestingUser.permissions.isProjectAdmin(projectIri) && !requestingUser.permissions.isSystemAdmin) { - throw ForbiddenException( - "User's project admin membership can only be changed by a project or system administrator" - ) - } - ) - - // check if user exists - userExists <- userExists(userIri) - _ = if (!userExists) throw NotFoundException(s"The user $userIri does not exist.") - - // check if project exists - projectExists <- projectExists(projectIri) - _ = if (!projectExists) throw NotFoundException(s"The project $projectIri does not exist.") - - // get users current project membership list - currentProjectAdminMembershipIris <- userProjectAdminMembershipsGetADM(userIri).map(_.map(_.id)) - - // check if user is not already a member and if he is then remove the project from to list - updatedProjectAdminMembershipIris = - if (currentProjectAdminMembershipIris.contains(projectIri)) { - currentProjectAdminMembershipIris diff Seq(projectIri) - } else { - throw BadRequestException( - s"User $userIri is not a project admin of project $projectIri." - ) - } - - // create the update request - result <- updateUserADM( - userIri = UserIri.unsafeFrom(userIri), - req = UserChangeRequestADM(projectsAdmin = Some(updatedProjectAdminMembershipIris)), - requestingUser = Users.SystemUser - ) - } yield result - - IriLocker.runWithIriLock( - apiRequestID, - userIri, - userProjectAdminMembershipRemoveRequestTask(userIri, projectIri, requestingUser) - ) + kUser <- userRepo.findById(userIri).someOrFail(NotFoundException(s"The user $userIri does not exist.")) + currentIsInProjectAdminGroup = kUser.isInProjectAdminGroup + _ <- ZIO.when(!currentIsInProjectAdminGroup.contains(projectIri))( + ZIO.fail(BadRequestException(s"User $userIri is not a project admin of project $projectIri.")) + ) + newIsInProjectAdminGroup = currentIsInProjectAdminGroup.filterNot(_ == projectIri).map(_.value) + theChange = UserChangeRequestADM(projectsAdmin = Some(newIsInProjectAdminGroup)) + updateUserResult <- updateUserADM(userIri, theChange, requestingUser) + } yield updateUserResult + IriLocker.runWithIriLock(apiRequestID, userIri.value, updateTask) } /** @@ -812,78 +555,23 @@ final case class UsersResponder( * @param apiRequestID the unique api request ID. * @return a [[UserOperationResponseADM]]. */ - private def userGroupMembershipAddRequestADM( - userIri: IRI, - groupIri: IRI, + def addGroupToUserIsInGroup( + userIri: UserIri, + groupIri: GroupIri, requestingUser: User, apiRequestID: UUID ): Task[UserOperationResponseADM] = { - - /** - * The actual task run with an IRI lock. - */ - def userGroupMembershipAddRequestTask( - userIri: IRI, - groupIri: IRI, - requestingUser: User - ): Task[UserOperationResponseADM] = + val updateTask = for { - // check if user exists - maybeUser <- findUserByIri( - UserIri.unsafeFrom(userIri), - UserInformationTypeADM.Full, - Users.SystemUser, - skipCache = true - ) - - userToChange: User = maybeUser match { - case Some(user) => user - case None => throw NotFoundException(s"The user $userIri does not exist.") - } - - // check if group exists - groupExists <- groupExists(groupIri) - _ = if (!groupExists) throw NotFoundException(s"The group $groupIri does not exist.") - - // get group's info. we need the project IRI. - maybeGroupADM <- messageRelay.ask[Option[GroupADM]](GroupGetADM(groupIri)) - - projectIri = maybeGroupADM - .getOrElse(throw InconsistentRepositoryDataException(s"Group $groupIri does not exist")) - .project - .id - - // check if the requesting user is allowed to perform updates (i.e. project or system administrator) - _ = if (!requestingUser.permissions.isProjectAdmin(projectIri) && !requestingUser.permissions.isSystemAdmin) { - throw ForbiddenException( - "User's group membership can only be changed by a project or system administrator" - ) - } - - // get users current group membership list - currentGroupMembershipIris = userToChange.groups.map(_.id) - - // check if user is already member and if not then append to list - updatedGroupMembershipIris = - if (!currentGroupMembershipIris.contains(groupIri)) { - currentGroupMembershipIris :+ groupIri - } else { - throw BadRequestException(s"User $userIri is already member of group $groupIri.") - } - - // create the update request - result <- updateUserADM( - userIri = UserIri.unsafeFrom(userIri), - req = UserChangeRequestADM(groups = Some(updatedGroupMembershipIris)), - requestingUser = Users.SystemUser - ) + kUser <- userRepo.findById(userIri).someOrFail(NotFoundException(s"The user $userIri does not exist.")) + currentIsInGroup = kUser.isInGroup + _ <- ZIO.when(currentIsInGroup.contains(groupIri))( + ZIO.fail(BadRequestException(s"User $userIri is already member of group $groupIri.")) + ) + theChange = UserChangeRequestADM(groups = Some((currentIsInGroup :+ groupIri).map(_.value))) + result <- updateUserADM(userIri, theChange, requestingUser) } yield result - - IriLocker.runWithIriLock( - apiRequestID, - userIri, - userGroupMembershipAddRequestTask(userIri, groupIri, requestingUser) - ) + IriLocker.runWithIriLock(apiRequestID, userIri.value, updateTask) } /** @@ -895,71 +583,24 @@ final case class UsersResponder( * @param apiRequestID the unique api request ID. * @return a [[UserOperationResponseADM]]. */ - private def userGroupMembershipRemoveRequestADM( - userIri: IRI, - groupIri: IRI, + def removeGroupFromUserIsInGroup( + userIri: UserIri, + groupIri: GroupIri, requestingUser: User, apiRequestID: UUID ): Task[UserOperationResponseADM] = { - - /** - * The actual task run with an IRI lock. - */ - def userGroupMembershipRemoveRequestTask( - userIri: IRI, - groupIri: IRI, - requestingUser: User - ): Task[UserOperationResponseADM] = + val updateTask = for { - // check if user exists - userExists <- userExists(userIri) - _ = if (!userExists) throw NotFoundException(s"The user $userIri does not exist.") - - // check if group exists - projectExists <- groupExists(groupIri) - _ = if (!projectExists) throw NotFoundException(s"The group $groupIri does not exist.") - - // get group's info. we need the project IRI. - maybeGroupADM <- messageRelay.ask[Option[GroupADM]](GroupGetADM(groupIri)) - - projectIri = maybeGroupADM - .getOrElse(throw InconsistentRepositoryDataException(s"Group $groupIri does not exist")) - .project - .id - - // check if the requesting user is allowed to perform updates (i.e. is project or system admin) - _ = - if ( - !requestingUser.permissions - .isProjectAdmin(projectIri) && !requestingUser.permissions.isSystemAdmin && !requestingUser.isSystemUser - ) { - throw ForbiddenException("User's group membership can only be changed by a project or system administrator") - } - - // get users current project membership list - currentGroupMembershipIris <- findGroupMembershipsByIri(UserIri.unsafeFrom(userIri)).map(_.map(_.id)) - - // check if user is not already a member and if he is then remove the project from to list - updatedGroupMembershipIris = - if (currentGroupMembershipIris.contains(groupIri)) { - currentGroupMembershipIris diff Seq(groupIri) - } else { - throw BadRequestException(s"User $userIri is not member of group $groupIri.") - } - - // create the update request - result <- updateUserADM( - userIri = UserIri.unsafeFrom(userIri), - req = UserChangeRequestADM(groups = Some(updatedGroupMembershipIris)), - requestingUser = requestingUser - ) + kUser <- userRepo.findById(userIri).someOrFail(NotFoundException(s"The user $userIri does not exist.")) + currentIsInGroup = kUser.isInGroup + _ <- ZIO.when(!currentIsInGroup.contains(groupIri))( + ZIO.fail(BadRequestException(s"User $userIri is not member of group $groupIri.")) + ) + newIsInGroup = currentIsInGroup.filterNot(_ == groupIri).map(_.value) + theUpdate = UserChangeRequestADM(groups = Some(newIsInGroup)) + result <- updateUserADM(userIri, theUpdate, requestingUser) } yield result - - IriLocker.runWithIriLock( - apiRequestID, - userIri, - userGroupMembershipRemoveRequestTask(userIri, groupIri, requestingUser) - ) + IriLocker.runWithIriLock(apiRequestID, userIri.value, updateTask) } private def ensureNotABuiltInUser(userIri: UserIri) = @@ -1287,24 +928,6 @@ final case class UsersResponder( private def userExistsByEmail(email: Email) = triplestore.query(Ask(sparql.admin.txt.checkUserExistsByEmail(email.value))) - /** - * Helper method for checking if a project exists. - * - * @param projectIri the IRI of the project. - * @return a [[Boolean]]. - */ - private def projectExists(projectIri: IRI): Task[Boolean] = - triplestore.query(Ask(sparql.admin.txt.checkProjectExistsByIri(projectIri))) - - /** - * Helper method for checking if a group exists. - * - * @param groupIri the IRI of the group. - * @return a [[Boolean]]. - */ - private def groupExists(groupIri: IRI): Task[Boolean] = - triplestore.query(Ask(sparql.admin.txt.checkGroupExistsByIri(groupIri))) - /** * Tries to retrieve a [[User]] from the cache. * @@ -1406,6 +1029,13 @@ object UsersResponder { ): ZIO[UsersResponder, Throwable, UserOperationResponseADM] = ZIO.serviceWithZIO[UsersResponder](_.changeUserStatus(userIri, status, apiRequestID)) + def changeSystemAdmin( + userIri: UserIri, + systemAdmin: SystemAdmin, + apiRequestId: UUID + ): ZIO[UsersResponder, Throwable, UserOperationResponseADM] = + ZIO.serviceWithZIO[UsersResponder](_.changeSystemAdmin(userIri, systemAdmin, apiRequestId)) + def findUserByIri( identifier: UserIri, userInformationType: UserInformationTypeADM, @@ -1435,6 +1065,44 @@ object UsersResponder { ): ZIO[UsersResponder, Throwable, UserProjectMembershipsGetResponseADM] = ZIO.serviceWithZIO[UsersResponder](_.findProjectMemberShipsByIri(userIri)) + def addProjectToUserIsInProject( + userIri: UserIri, + projectIri: ProjectIri, + requestingUser: User, + apiRequestID: UUID + ): ZIO[UsersResponder, Throwable, UserOperationResponseADM] = + ZIO.serviceWithZIO[UsersResponder](_.addProjectToUserIsInProject(userIri, projectIri, requestingUser, apiRequestID)) + + def addProjectToUserIsInProjectAdminGroup( + userIri: UserIri, + projectIri: ProjectIri, + requestingUser: User, + apiRequestID: UUID + ): ZIO[UsersResponder, Throwable, UserOperationResponseADM] = + ZIO.serviceWithZIO[UsersResponder]( + _.addProjectToUserIsInProjectAdminGroup(userIri, projectIri, requestingUser, apiRequestID) + ) + + def removeProjectFromUserIsInProjectAndIsInProjectAdminGroup( + userIri: UserIri, + projectIri: ProjectIri, + requestingUser: User, + apiRequestID: UUID + ): ZIO[UsersResponder, Throwable, UserOperationResponseADM] = + ZIO.serviceWithZIO[UsersResponder]( + _.removeProjectFromUserIsInProjectAndIsInProjectAdminGroup(userIri, projectIri, requestingUser, apiRequestID) + ) + + def removeProjectFromUserIsInProjectAdminGroup( + userIri: UserIri, + projectIri: ProjectIri, + requestingUser: User, + apiRequestID: UUID + ): ZIO[UsersResponder, Throwable, UserOperationResponseADM] = + ZIO.serviceWithZIO[UsersResponder]( + _.removeProjectFromUserIsInProjectAdminGroup(userIri, projectIri, requestingUser, apiRequestID) + ) + def findUserProjectAdminMemberships( userIri: UserIri ): ZIO[UsersResponder, Throwable, UserProjectAdminMembershipsGetResponseADM] = @@ -1464,8 +1132,24 @@ object UsersResponder { ): RIO[UsersResponder, UserOperationResponseADM] = ZIO.serviceWithZIO[UsersResponder](_.changePassword(userIri, changeRequest, requestingUser, apiRequestID)) + def addGroupToUserIsInGroup( + userIri: UserIri, + groupIri: GroupIri, + requestingUser: User, + apiRequestID: UUID + ): ZIO[UsersResponder, Throwable, UserOperationResponseADM] = + ZIO.serviceWithZIO[UsersResponder](_.addGroupToUserIsInGroup(userIri, groupIri, requestingUser, apiRequestID)) + + def removeGroupFromUserIsInGroup( + userIri: UserIri, + groupIri: GroupIri, + requestingUser: User, + apiRequestID: UUID + ): ZIO[UsersResponder, Throwable, UserOperationResponseADM] = + ZIO.serviceWithZIO[UsersResponder](_.removeGroupFromUserIsInGroup(userIri, groupIri, requestingUser, apiRequestID)) + val layer: URLayer[ - AuthorizationRestService & AppConfig & IriConverter & IriService & PasswordService & MessageRelay & UserService & StringFormatter & TriplestoreService, + AuthorizationRestService & AppConfig & IriConverter & IriService & PasswordService & KnoraUserRepo & MessageRelay & UserService & StringFormatter & TriplestoreService, UsersResponder ] = ZLayer.fromZIO { for { @@ -1474,11 +1158,12 @@ object UsersResponder { iriS <- ZIO.service[IriService] ic <- ZIO.service[IriConverter] us <- ZIO.service[UserService] + ur <- ZIO.service[KnoraUserRepo] ps <- ZIO.service[PasswordService] mr <- ZIO.service[MessageRelay] ts <- ZIO.service[TriplestoreService] sf <- ZIO.service[StringFormatter] - handler <- mr.subscribe(UsersResponder(auth, config, iriS, ic, us, ps, mr, ts, sf)) + handler <- mr.subscribe(UsersResponder(auth, config, iriS, ic, us, ur, ps, mr, ts, sf)) } yield handler } } diff --git a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala index c4911926a9..07785b0bad 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/ApiRoutes.scala @@ -108,7 +108,6 @@ private final case class ApiRoutesImpl( RejectingRoute(appConfig, runtime).makeRoute ~ ResourcesRouteV2(appConfig).makeRoute ~ StandoffRouteV2().makeRoute ~ - UsersRouteADM().makeRoute ~ ValuesRouteV2().makeRoute ~ VersionRoute().makeRoute } diff --git a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilADM.scala b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilADM.scala index 0761bab518..3af0f62c42 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilADM.scala @@ -104,12 +104,6 @@ object RouteUtilADM { )(implicit runtime: Runtime[R & StringFormatter & MessageRelay]): Future[RouteResult] = UnsafeZioRun.runToFuture(requestTask.flatMap(doRunJsonRoute(_, requestContext))) - def runJsonRoute( - request: KnoraRequestADM, - requestContext: RequestContext - )(implicit runtime: Runtime[StringFormatter & MessageRelay]): Future[RouteResult] = - UnsafeZioRun.runToFuture(doRunJsonRoute(request, requestContext)) - private def doRunJsonRoute( request: KnoraRequestADM, ctx: RequestContext @@ -132,7 +126,6 @@ object RouteUtilADM { case class IriUserUuid(iri: IRI, user: User, uuid: UUID) case class IriUser(iri: IRI, user: User) - case class UserUuid(user: User, uuid: UUID) def getIriUserUuid( iri: String, @@ -154,10 +147,4 @@ object RouteUtilADM { def validateAndEscape(iri: String): IO[BadRequestException, IRI] = Iri.validateAndEscapeIri(iri).toZIO.orElseFail(BadRequestException(s"Invalid IRI: $iri")) - - def getUserUuid(ctx: RequestContext): ZIO[Authenticator, Throwable, UserUuid] = - for { - user <- Authenticator.getUserADM(ctx) - uuid <- RouteUtilZ.randomUuid() - } yield UserUuid(user, uuid) } diff --git a/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala b/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala deleted file mode 100644 index ec02f723f2..0000000000 --- a/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright © 2021 - 2024 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. - * SPDX-License-Identifier: Apache-2.0 - */ - -package org.knora.webapi.routing.admin - -import org.apache.pekko.http.scaladsl.server.Directives.* -import org.apache.pekko.http.scaladsl.server.PathMatcher -import org.apache.pekko.http.scaladsl.server.Route -import zio.* - -import dsp.errors.BadRequestException -import dsp.valueobjects.Iri -import org.knora.webapi.core.MessageRelay -import org.knora.webapi.messages.StringFormatter -import org.knora.webapi.messages.admin.responder.usersmessages.UsersADMJsonProtocol.* -import org.knora.webapi.messages.admin.responder.usersmessages.* -import org.knora.webapi.routing.Authenticator -import org.knora.webapi.routing.RouteUtilADM.getIriUserUuid -import org.knora.webapi.routing.RouteUtilADM.getUserUuid -import org.knora.webapi.routing.RouteUtilADM.runJsonRouteZ -import org.knora.webapi.slice.admin.domain.model.* -import org.knora.webapi.slice.common.api.AuthorizationRestService - -/** - * Provides an pekko-http-routing function for API routes that deal with users. - */ -final case class UsersRouteADM()( - private implicit val runtime: Runtime[Authenticator & AuthorizationRestService & StringFormatter & MessageRelay] -) { - - private val usersBasePath: PathMatcher[Unit] = PathMatcher("admin" / "users") - - def makeRoute: Route = - changeUserSystemAdminMembership() ~ - addUserToProjectMembership() ~ - removeUserFromProjectMembership() ~ - addUserToProjectAdminMembership() ~ - removeUserFromProjectAdminMembership() ~ - addUserToGroupMembership() ~ - removeUserFromGroupMembership() - - /** - * Change user's SystemAdmin membership. - */ - private def changeUserSystemAdminMembership(): Route = - path(usersBasePath / "iri" / Segment / "SystemAdmin") { userIri => - put { - entity(as[ChangeUserApiRequestADM]) { apiRequest => requestContext => - val task = for { - checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) - r <- getUserUuid(requestContext) - newSystemAdmin <- ZIO - .fromOption(apiRequest.systemAdmin.map(SystemAdmin.from)) - .orElseFail(BadRequestException("The systemAdmin is missing.")) - } yield UserChangeSystemAdminMembershipStatusRequestADM(checkedUserIri, newSystemAdmin, r.user, r.uuid) - runJsonRouteZ(task, requestContext) - } - } - } - - /** - * add user to project - */ - private def addUserToProjectMembership(): Route = - path(usersBasePath / "iri" / Segment / "project-memberships" / Segment) { (userIri, projectIri) => - post { requestContext => - val task = for { - userIri <- validateUserIriAndEnsureRegularUser(userIri) - r <- getIriUserUuid(projectIri, requestContext) - } yield UserProjectMembershipAddRequestADM(userIri, r.iri, r.user, r.uuid) - runJsonRouteZ(task, requestContext) - } - } - - private def validateUserIriAndEnsureRegularUser(userIri: String) = - ZIO - .fromEither(UserIri.from(userIri)) - .filterOrFail(_.isRegularUser)("Changes to built-in users are not allowed.") - .mapBoth(BadRequestException.apply, _.value) - - private def validateAndEscapeGroupIri(groupIri: String) = - Iri - .validateAndEscapeIri(groupIri) - .toZIO - .orElseFail(BadRequestException(s"Invalid group IRI $groupIri")) - - /** - * remove user from project (and all groups belonging to this project) - */ - private def removeUserFromProjectMembership(): Route = - path(usersBasePath / "iri" / Segment / "project-memberships" / Segment) { (userIri, projectIri) => - delete { requestContext => - val task = for { - checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) - r <- getIriUserUuid(projectIri, requestContext) - } yield UserProjectMembershipRemoveRequestADM(checkedUserIri, r.iri, r.user, r.uuid) - runJsonRouteZ(task, requestContext) - } - } - - /** - * add user to project admin - */ - private def addUserToProjectAdminMembership(): Route = - path(usersBasePath / "iri" / Segment / "project-admin-memberships" / Segment) { (userIri, projectIri) => - post { ctx => - val task = for { - checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) - r <- getIriUserUuid(projectIri, ctx) - } yield UserProjectAdminMembershipAddRequestADM(checkedUserIri, r.iri, r.user, r.uuid) - runJsonRouteZ(task, ctx) - } - } - - /** - * remove user from project admin membership - */ - private def removeUserFromProjectAdminMembership(): Route = - path(usersBasePath / "iri" / Segment / "project-admin-memberships" / Segment) { (userIri, projectIri) => - delete { requestContext => - val task = for { - checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) - r <- getIriUserUuid(projectIri, requestContext) - } yield UserProjectAdminMembershipRemoveRequestADM(checkedUserIri, r.iri, r.user, r.uuid) - runJsonRouteZ(task, requestContext) - } - } - - /** - * add user to group - */ - private def addUserToGroupMembership(): Route = - path(usersBasePath / "iri" / Segment / "group-memberships" / Segment) { (userIri, groupIri) => - post { requestContext => - val task = for { - checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) - checkedGroupIri <- validateAndEscapeGroupIri(groupIri) - r <- getIriUserUuid(groupIri, requestContext) - } yield UserGroupMembershipAddRequestADM(checkedUserIri, checkedGroupIri, r.user, r.uuid) - runJsonRouteZ(task, requestContext) - } - } - - /** - * remove user from group - */ - private def removeUserFromGroupMembership(): Route = - path(usersBasePath / "iri" / Segment / "group-memberships" / Segment) { (userIri, groupIri) => - delete { requestContext => - val task = for { - checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) - checkedGroupIri <- validateAndEscapeGroupIri(groupIri) - r <- getIriUserUuid(groupIri, requestContext) - } yield UserGroupMembershipRemoveRequestADM(checkedUserIri, checkedGroupIri, r.user, r.uuid) - runJsonRouteZ(task, requestContext) - } - } -} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala index 3930fd0949..9dc3574efb 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpoints.scala @@ -27,6 +27,7 @@ import org.knora.webapi.slice.admin.api.PathVars.usernamePathVar import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.BasicUserInformationChangeRequest import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.PasswordChangeRequest import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.StatusChangeRequest +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.SystemAdminChangeRequest import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.UserCreateRequest import org.knora.webapi.slice.admin.domain.model.Email import org.knora.webapi.slice.admin.domain.model.FamilyName @@ -99,13 +100,28 @@ final case class UsersEndpoints(baseEndpoints: BaseEndpoints) { .out(sprayJsonBody[UserGroupMembershipsGetResponseADM]) .description("Returns the user's group memberships for a user identified by their IRI.") } - // Create + object post { val users = baseEndpoints.securedEndpoint.post .in(base) .in(zioJsonBody[UserCreateRequest]) .out(sprayJsonBody[UserOperationResponseADM]) .description("Create a new user.") + + val usersByIriProjectMemberShips = baseEndpoints.securedEndpoint.post + .in(base / "iri" / PathVars.userIriPathVar / "project-memberships" / AdminPathVariables.projectIri) + .out(sprayJsonBody[UserOperationResponseADM]) + .description("Add a user to a project identified by IRI.") + + val usersByIriProjectAdminMemberShips = baseEndpoints.securedEndpoint.post + .in(base / "iri" / PathVars.userIriPathVar / "project-admin-memberships" / AdminPathVariables.projectIri) + .out(sprayJsonBody[UserOperationResponseADM]) + .description("Add a user as an admin to a project identified by IRI.") + + val usersByIriGroupMemberShips = baseEndpoints.securedEndpoint.post + .in(base / "iri" / PathVars.userIriPathVar / "group-memberships" / AdminPathVariables.groupIri) + .out(sprayJsonBody[UserOperationResponseADM]) + .description("Add a user to a group identified by IRI.") } object put { @@ -126,6 +142,13 @@ final case class UsersEndpoints(baseEndpoints: BaseEndpoints) { .in(zioJsonBody[StatusChangeRequest]) .out(sprayJsonBody[UserOperationResponseADM]) .description("Change a user's status identified by IRI.") + + val usersIriSystemAdmin = baseEndpoints.securedEndpoint.put + .in(base / "iri" / PathVars.userIriPathVar / "SystemAdmin") + .in(zioJsonBody[SystemAdminChangeRequest]) + .out(sprayJsonBody[UserOperationResponseADM]) + .description("Change a user's SystemAdmin status identified by IRI.") + } object delete { @@ -133,6 +156,21 @@ final case class UsersEndpoints(baseEndpoints: BaseEndpoints) { .in(base / "iri" / PathVars.userIriPathVar) .out(sprayJsonBody[UserOperationResponseADM]) .description("Delete a user identified by IRI (change status to false).") + + val usersByIriProjectMemberShips = baseEndpoints.securedEndpoint.delete + .in(base / "iri" / PathVars.userIriPathVar / "project-memberships" / AdminPathVariables.projectIri) + .out(sprayJsonBody[UserOperationResponseADM]) + .description("Remove a user from a project membership identified by IRI.") + + val usersByIriProjectAdminMemberShips = baseEndpoints.securedEndpoint.delete + .in(base / "iri" / PathVars.userIriPathVar / "project-admin-memberships" / AdminPathVariables.projectIri) + .out(sprayJsonBody[UserOperationResponseADM]) + .description("Remove a user form an admin project membership identified by IRI.") + + val usersByIriGroupMemberShips = baseEndpoints.securedEndpoint.delete + .in(base / "iri" / PathVars.userIriPathVar / "group-memberships" / AdminPathVariables.groupIri) + .out(sprayJsonBody[UserOperationResponseADM]) + .description("Remove a user form an group membership identified by IRI.") } private val public = @@ -148,10 +186,17 @@ final case class UsersEndpoints(baseEndpoints: BaseEndpoints) { get.userByEmail, get.userByUsername, post.users, + post.usersByIriProjectMemberShips, + post.usersByIriProjectAdminMemberShips, + post.usersByIriGroupMemberShips, put.usersIriBasicInformation, put.usersIriPassword, put.usersIriStatus, - delete.deleteUser + put.usersIriSystemAdmin, + delete.deleteUser, + delete.usersByIriProjectMemberShips, + delete.usersByIriProjectAdminMemberShips, + delete.usersByIriGroupMemberShips ).map(_.endpoint) val endpoints: Seq[AnyEndpoint] = (public ++ secured).map(_.tag("Admin Users")) } @@ -195,6 +240,11 @@ object UsersEndpoints { object StatusChangeRequest { implicit val jsonCodec: JsonCodec[StatusChangeRequest] = DeriveJsonCodec.gen[StatusChangeRequest] } + + final case class SystemAdminChangeRequest(systemAdmin: SystemAdmin) + object SystemAdminChangeRequest { + implicit val jsonCodec: JsonCodec[SystemAdminChangeRequest] = DeriveJsonCodec.gen[SystemAdminChangeRequest] + } } val layer = ZLayer.derive[UsersEndpoints] diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala index 8335a170dc..a16f277c6b 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/UsersEndpointsHandler.scala @@ -7,15 +7,18 @@ package org.knora.webapi.slice.admin.api import zio.ZLayer +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier import org.knora.webapi.messages.admin.responder.usersmessages.UserOperationResponseADM import org.knora.webapi.messages.admin.responder.usersmessages.UserResponseADM import org.knora.webapi.messages.admin.responder.usersmessages.UsersGetResponseADM import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.BasicUserInformationChangeRequest import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.PasswordChangeRequest import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.StatusChangeRequest +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.SystemAdminChangeRequest import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.UserCreateRequest import org.knora.webapi.slice.admin.api.service.UsersRestService import org.knora.webapi.slice.admin.domain.model.Email +import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.admin.domain.model.UserIri import org.knora.webapi.slice.admin.domain.model.Username import org.knora.webapi.slice.common.api.HandlerMapper @@ -69,6 +72,30 @@ case class UsersEndpointsHandler( requestingUser => userCreateRequest => restService.createUser(requestingUser, userCreateRequest) ) + private val postUsersByIriProjectMemberShipsHandler = + SecuredEndpointHandler[(UserIri, IriIdentifier), UserOperationResponseADM]( + usersEndpoints.post.usersByIriProjectMemberShips, + requestingUser => { case (userIri: UserIri, projectIri: IriIdentifier) => + restService.addProjectToUserIsInProject(requestingUser, userIri, projectIri) + } + ) + + private val postUsersByIriProjectAdminMemberShipsHandler = + SecuredEndpointHandler[(UserIri, IriIdentifier), UserOperationResponseADM]( + usersEndpoints.post.usersByIriProjectAdminMemberShips, + requestingUser => { case (userIri: UserIri, projectIri: IriIdentifier) => + restService.addProjectToUserIsInProjectAdminGroup(requestingUser, userIri, projectIri) + } + ) + + private val postUsersByIriGroupMemberShipsHandler = + SecuredEndpointHandler[(UserIri, GroupIri), UserOperationResponseADM]( + usersEndpoints.post.usersByIriGroupMemberShips, + requestingUser => { case (userIri: UserIri, groupIri: GroupIri) => + restService.addGroupToUserIsInGroup(requestingUser, userIri, groupIri) + } + ) + // Update private val putUsersIriBasicInformationHandler = SecuredEndpointHandler[(UserIri, BasicUserInformationChangeRequest), UserOperationResponseADM]( @@ -94,12 +121,44 @@ case class UsersEndpointsHandler( } ) + private val putUsersIriSystemAdminHandler = + SecuredEndpointHandler[(UserIri, SystemAdminChangeRequest), UserOperationResponseADM]( + usersEndpoints.put.usersIriSystemAdmin, + requestingUser => { case (userIri: UserIri, changeRequest: SystemAdminChangeRequest) => + restService.changeSystemAdmin(requestingUser, userIri, changeRequest) + } + ) + // Deletes private val deleteUserByIriHandler = SecuredEndpointHandler[UserIri, UserOperationResponseADM]( usersEndpoints.delete.deleteUser, requestingUser => userIri => restService.deleteUser(requestingUser, userIri) ) + private val deleteUsersByIriProjectMemberShipsHandler = + SecuredEndpointHandler[(UserIri, IriIdentifier), UserOperationResponseADM]( + usersEndpoints.delete.usersByIriProjectMemberShips, + requestingUser => { case (userIri: UserIri, projectIri: IriIdentifier) => + restService.removeProjectToUserIsInProject(requestingUser, userIri, projectIri) + } + ) + + private val deleteUsersByIriProjectAdminMemberShipsHandler = + SecuredEndpointHandler[(UserIri, IriIdentifier), UserOperationResponseADM]( + usersEndpoints.delete.usersByIriProjectAdminMemberShips, + requestingUser => { case (userIri: UserIri, projectIri: IriIdentifier) => + restService.removeProjectFromUserIsInProjectAdminGroup(requestingUser, userIri, projectIri) + } + ) + + private val deleteUsersByIriGroupMemberShipsHandler = + SecuredEndpointHandler[(UserIri, GroupIri), UserOperationResponseADM]( + usersEndpoints.delete.usersByIriGroupMemberShips, + requestingUser => { case (userIri: UserIri, groupIri: GroupIri) => + restService.removeGroupFromUserIsInGroup(requestingUser, userIri, groupIri) + } + ) + private val public = List( getUsersByIriProjectMemberShipsHandler, getUsersByIriProjectAdminMemberShipsHandler, @@ -112,10 +171,17 @@ case class UsersEndpointsHandler( getUserByEmailHandler, getUserByUsernameHandler, createUserHandler, + postUsersByIriProjectMemberShipsHandler, + postUsersByIriProjectAdminMemberShipsHandler, + postUsersByIriGroupMemberShipsHandler, putUsersIriBasicInformationHandler, putUsersIriPasswordHandler, putUsersIriStatusHandler, - deleteUserByIriHandler + putUsersIriSystemAdminHandler, + deleteUserByIriHandler, + deleteUsersByIriProjectMemberShipsHandler, + deleteUsersByIriProjectAdminMemberShipsHandler, + deleteUsersByIriGroupMemberShipsHandler ).map(mapper.mapSecuredEndpointHandler(_)) val allHanders = public ++ secured diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.scala index 7f537ac67a..6baab03e08 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.scala @@ -9,6 +9,7 @@ import zio.* import dsp.errors.BadRequestException import dsp.errors.NotFoundException +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM import org.knora.webapi.messages.admin.responder.usersmessages.UserGroupMembershipsGetResponseADM import org.knora.webapi.messages.admin.responder.usersmessages.UserInformationTypeADM import org.knora.webapi.messages.admin.responder.usersmessages.UserOperationResponseADM @@ -20,7 +21,10 @@ import org.knora.webapi.responders.admin.UsersResponder import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.BasicUserInformationChangeRequest import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.PasswordChangeRequest +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.StatusChangeRequest +import org.knora.webapi.slice.admin.api.UsersEndpoints.Requests.SystemAdminChangeRequest import org.knora.webapi.slice.admin.domain.model.Email +import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.admin.domain.model.User import org.knora.webapi.slice.admin.domain.model.UserIri import org.knora.webapi.slice.admin.domain.model.UserStatus @@ -120,7 +124,7 @@ final case class UsersRestService( def changeStatus( requestingUser: User, userIri: UserIri, - changeRequest: Requests.StatusChangeRequest + changeRequest: StatusChangeRequest ): Task[UserOperationResponseADM] = for { _ <- ensureNotABuiltInUser(userIri) @@ -128,6 +132,95 @@ final case class UsersRestService( uuid <- Random.nextUUID response <- responder.changeUserStatus(userIri, changeRequest.status, uuid) } yield response + + def changeSystemAdmin( + requestingUser: User, + userIri: UserIri, + changeRequest: SystemAdminChangeRequest + ): Task[UserOperationResponseADM] = + for { + _ <- ensureNotABuiltInUser(userIri) + _ <- auth.ensureSystemAdmin(requestingUser) + uuid <- Random.nextUUID + response <- responder.changeSystemAdmin(userIri, changeRequest.systemAdmin, uuid) + } yield response + + def addProjectToUserIsInProject( + requestingUser: User, + userIri: UserIri, + projectIri: ProjectIdentifierADM.IriIdentifier + ): Task[UserOperationResponseADM] = + for { + _ <- ensureNotABuiltInUser(userIri) + _ <- auth.ensureSystemAdminOrProjectAdmin(requestingUser, projectIri.value) + uuid <- Random.nextUUID + response <- responder.addProjectToUserIsInProject(userIri, projectIri.value, requestingUser, uuid) + } yield response + + def addProjectToUserIsInProjectAdminGroup( + requestingUser: User, + userIri: UserIri, + projectIri: ProjectIdentifierADM.IriIdentifier + ): Task[UserOperationResponseADM] = + for { + _ <- ensureNotABuiltInUser(userIri) + _ <- auth.ensureSystemAdminOrProjectAdmin(requestingUser, projectIri.value) + uuid <- Random.nextUUID + response <- responder.addProjectToUserIsInProjectAdminGroup(userIri, projectIri.value, requestingUser, uuid) + } yield response + + def removeProjectToUserIsInProject( + requestingUser: User, + userIri: UserIri, + projectIri: ProjectIdentifierADM.IriIdentifier + ): Task[UserOperationResponseADM] = + for { + _ <- ensureNotABuiltInUser(userIri) + _ <- auth.ensureSystemAdminOrProjectAdmin(requestingUser, projectIri.value) + uuid <- Random.nextUUID + response <- responder.removeProjectFromUserIsInProjectAndIsInProjectAdminGroup( + userIri, + projectIri.value, + requestingUser, + uuid + ) + } yield response + + def removeProjectFromUserIsInProjectAdminGroup( + requestingUser: User, + userIri: UserIri, + projectIri: ProjectIdentifierADM.IriIdentifier + ): Task[UserOperationResponseADM] = + for { + _ <- ensureNotABuiltInUser(userIri) + _ <- auth.ensureSystemAdminOrProjectAdmin(requestingUser, projectIri.value) + uuid <- Random.nextUUID + response <- responder.removeProjectFromUserIsInProjectAdminGroup(userIri, projectIri.value, requestingUser, uuid) + } yield response + + def addGroupToUserIsInGroup( + requestingUser: User, + userIri: UserIri, + groupIri: GroupIri + ): Task[UserOperationResponseADM] = + for { + _ <- ensureNotABuiltInUser(userIri) + _ <- auth.ensureSystemAdminOrProjectAdminOfGroup(requestingUser, groupIri) + uuid <- Random.nextUUID + response <- responder.addGroupToUserIsInGroup(userIri, groupIri, requestingUser, uuid) + } yield response + + def removeGroupFromUserIsInGroup( + requestingUser: User, + userIri: UserIri, + groupIri: GroupIri + ): Task[UserOperationResponseADM] = + for { + _ <- ensureNotABuiltInUser(userIri) + _ <- auth.ensureSystemAdminOrProjectAdminOfGroup(requestingUser, groupIri) + uuid <- Random.nextUUID + response <- responder.removeGroupFromUserIsInGroup(userIri, groupIri, requestingUser, uuid) + } yield response } object UsersRestService { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/GroupIri.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/GroupIri.scala index 9a36eff00a..a56f2200d0 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/GroupIri.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/GroupIri.scala @@ -12,8 +12,11 @@ import dsp.valueobjects.Iri import dsp.valueobjects.UuidUtil import org.knora.webapi.messages.OntologyConstants.KnoraAdmin.BuiltInGroups import org.knora.webapi.messages.StringFormatter.IriDomain +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +final case class KnoraUserGroup(id: GroupIri, belongsToProject: ProjectIri) + final case class GroupIri private (value: String) extends AnyVal object GroupIri { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraUserGroupRepo.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraUserGroupRepo.scala new file mode 100644 index 0000000000..96e40ea48f --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/service/KnoraUserGroupRepo.scala @@ -0,0 +1,12 @@ +/* + * Copyright © 2021 - 2024 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.admin.domain.service + +import org.knora.webapi.slice.admin.domain.model.GroupIri +import org.knora.webapi.slice.admin.domain.model.KnoraUserGroup +import org.knora.webapi.slice.common.repo.service.Repository + +trait KnoraUserGroupRepo extends Repository[KnoraUserGroup, GroupIri] {} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/rdf/Vocabulary.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/rdf/Vocabulary.scala index f388c49171..9c22c40f47 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/rdf/Vocabulary.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/rdf/Vocabulary.scala @@ -18,7 +18,8 @@ object Vocabulary { val NS: Namespace = new SimpleNamespace("knora-admin", KnoraAdminPrefixExpansion) // resource class IRIs - val User: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "User") + val User: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "User") + val UserGroup: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "UserGroup") // property IRIs val username: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "username") @@ -32,6 +33,9 @@ object Vocabulary { val isInGroup: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "isInGroup") val isInSystemAdminGroup: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "isInSystemAdminGroup") val isInProjectAdminGroup: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "isInProjectAdminGroup") + + // user group properties + val belongsToProject: Iri = Rdf.iri(KnoraAdminPrefixExpansion, "belongsToProject") } object NamedGraphs { val knoraAdminIri: Iri = Rdf.iri(adminDataNamedGraph.value) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraUserGroupRepoLive.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraUserGroupRepoLive.scala new file mode 100644 index 0000000000..6b629c9609 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/repo/service/KnoraUserGroupRepoLive.scala @@ -0,0 +1,92 @@ +/* + * Copyright © 2021 - 2024 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.admin.repo.service + +import org.eclipse.rdf4j.model.vocabulary.RDF +import org.eclipse.rdf4j.sparqlbuilder.core.SparqlBuilder.prefix +import org.eclipse.rdf4j.sparqlbuilder.core.SparqlBuilder.`var` as variable +import org.eclipse.rdf4j.sparqlbuilder.core.query.ConstructQuery +import org.eclipse.rdf4j.sparqlbuilder.core.query.Queries +import org.eclipse.rdf4j.sparqlbuilder.graphpattern.GraphPatterns.tp +import org.eclipse.rdf4j.sparqlbuilder.rdf.Rdf +import zio.Task +import zio.ZIO +import zio.ZLayer +import zio.stream.ZStream + +import org.knora.webapi.messages.OntologyConstants +import org.knora.webapi.slice.admin.domain.model.GroupIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.KnoraUserGroup +import org.knora.webapi.slice.admin.domain.service.KnoraUserGroupRepo +import org.knora.webapi.slice.admin.repo.rdf.Vocabulary +import org.knora.webapi.slice.common.repo.rdf.RdfResource +import org.knora.webapi.store.triplestore.api.TriplestoreService +import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Construct +import org.knora.webapi.store.triplestore.errors.TriplestoreResponseException + +final case class KnoraUserGroupRepoLive(triplestore: TriplestoreService) extends KnoraUserGroupRepo { + + override def findById(id: GroupIri): Task[Option[KnoraUserGroup]] = for { + model <- triplestore.queryRdfModel(UserGroupQueries.findById(id)) + resource <- model.getResource(id.value).option + user <- ZIO.foreach(resource)(toGroup) + } yield user + + override def findAll(): Task[List[KnoraUserGroup]] = for { + model <- triplestore.queryRdfModel(UserGroupQueries.findAll) + resources <- + model.getResourcesRdfType(OntologyConstants.KnoraAdmin.UserGroup).option.map(_.getOrElse(Iterator.empty)) + groups <- ZStream.fromIterator(resources).mapZIO(toGroup).runCollect + } yield groups.toList + + private def toGroup(resource: RdfResource): Task[KnoraUserGroup] = for { + id <- + resource.iri.flatMap(it => ZIO.fromEither(GroupIri.from(it.value))).mapError(TriplestoreResponseException.apply) + belongsToProject <- resource + .getObjectIriOrFail(OntologyConstants.KnoraAdmin.BelongsToProject) + .flatMap(it => ZIO.fromEither(ProjectIri.from(it.value))) + .mapError(it => TriplestoreResponseException(it.toString)) + } yield KnoraUserGroup(id, belongsToProject) +} + +object KnoraUserGroupRepoLive { + val layer = ZLayer.derive[KnoraUserGroupRepoLive] +} + +object UserGroupQueries { + + def findAll: Construct = { + val (s, p, o) = (variable("s"), variable("p"), variable("o")) + val query = Queries + .CONSTRUCT(tp(s, p, o)) + .prefix(prefix(RDF.NS), prefix(Vocabulary.KnoraAdmin.NS)) + .where( + s + .has(RDF.TYPE, Vocabulary.KnoraAdmin.UserGroup) + .and(s.has(p, o)) + .from(Vocabulary.NamedGraphs.knoraAdminIri) + ) + println(query.getQueryString) + Construct(query.getQueryString) + } + + def findById(id: GroupIri): Construct = { + val s = Rdf.iri(id.value) + val (p, o) = (variable("p"), variable("o")) + val query: ConstructQuery = Queries + .CONSTRUCT(tp(s, p, o)) + .prefix(prefix(RDF.NS), prefix(Vocabulary.KnoraAdmin.NS)) + .where( + s + .has(RDF.TYPE, Vocabulary.KnoraAdmin.UserGroup) + .and(tp(s, p, o)) + .from(Vocabulary.NamedGraphs.knoraAdminIri) + ) + println(query.getQueryString) + Construct(query) + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/common/api/AuthorizationRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/common/api/AuthorizationRestService.scala index 4344f927f7..379a70599e 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/common/api/AuthorizationRestService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/common/api/AuthorizationRestService.scala @@ -9,8 +9,12 @@ import zio.* import zio.macros.accessible import dsp.errors.ForbiddenException +import org.knora.webapi.slice.admin.domain.model.GroupIri import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri import org.knora.webapi.slice.admin.domain.model.User +import org.knora.webapi.slice.admin.domain.service.KnoraProjectRepo +import org.knora.webapi.slice.admin.domain.service.KnoraUserGroupRepo import org.knora.webapi.slice.common.api.AuthorizationRestService.isActive import org.knora.webapi.slice.common.api.AuthorizationRestService.isSystemAdmin import org.knora.webapi.slice.common.api.AuthorizationRestService.isSystemAdminOrProjectAdminInAnyProject @@ -37,8 +41,11 @@ trait AuthorizationRestService { * Fails with a [[ForbiddenException]] otherwise. */ def ensureSystemAdmin(user: User): IO[ForbiddenException, Unit] + def ensureSystemAdminSystemUserOrProjectAdminInAnyProject(user: User): IO[ForbiddenException, Unit] + def ensureSystemAdminOrProjectAdmin(user: User, project: ProjectIri): IO[ForbiddenException, KnoraProject] + /** * Checks if the user is a system or project administrator. * Checks if the user is active. @@ -56,6 +63,11 @@ trait AuthorizationRestService { ): IO[ForbiddenException, Unit] def ensureSystemAdminOrProjectAdminInAnyProject(requestingUser: User): IO[ForbiddenException, Unit] + + def ensureSystemAdminOrProjectAdminOfGroup( + user: User, + groupIri: GroupIri + ): IO[ForbiddenException, KnoraProject] } /** @@ -78,7 +90,8 @@ object AuthorizationRestService { isSystemUser(user) || isSystemAdmin(user) || user.permissions.isProjectAdminInAnyProject() } -final case class AuthorizationRestServiceLive() extends AuthorizationRestService { +final case class AuthorizationRestServiceLive(projectRepo: KnoraProjectRepo, groupsRepo: KnoraUserGroupRepo) + extends AuthorizationRestService { override def ensureSystemAdmin(user: User): IO[ForbiddenException, Unit] = { lazy val msg = s"You are logged in with username '${user.username}', but only a system administrator has permissions for this operation." @@ -89,7 +102,7 @@ final case class AuthorizationRestServiceLive() extends AuthorizationRestService user: User, condition: User => Boolean, errorMsg: String - ): ZIO[Any, ForbiddenException, Unit] = + ): IO[ForbiddenException, Unit] = ensureIsActive(user) *> ZIO.fail(ForbiddenException(errorMsg)).unless(condition(user)).unit private def ensureIsActive(user: User): IO[ForbiddenException, Unit] = { @@ -97,6 +110,30 @@ final case class AuthorizationRestServiceLive() extends AuthorizationRestService ZIO.fail(ForbiddenException(msg)).unless(isActive(user)).unit } + override def ensureSystemAdminOrProjectAdmin( + user: User, + projectIri: ProjectIri + ): IO[ForbiddenException, KnoraProject] = + for { + project <- projectRepo + .findById(projectIri) + .orDie + .someOrFail(ForbiddenException(s"Project with IRI '${projectIri.value}' not found")) + _ <- ensureSystemAdminOrProjectAdmin(user, project) + } yield project + + override def ensureSystemAdminOrProjectAdminOfGroup( + user: User, + groupIri: GroupIri + ): IO[ForbiddenException, KnoraProject] = + for { + group <- groupsRepo + .findById(groupIri) + .orDie + .someOrFail(ForbiddenException(s"Group with IRI '${groupIri.value}' not found")) + project <- ensureSystemAdminOrProjectAdmin(user, group.belongsToProject) + } yield project + override def ensureSystemAdminOrProjectAdmin(user: User, project: KnoraProject): IO[ForbiddenException, Unit] = { lazy val msg = s"You are logged in with username '${user.username}', but only a system administrator or project administrator has permissions for this operation." @@ -123,5 +160,5 @@ final case class AuthorizationRestServiceLive() extends AuthorizationRestService } object AuthorizationRestServiceLive { - val layer: ULayer[AuthorizationRestService] = ZLayer.fromFunction(AuthorizationRestServiceLive.apply _) + val layer = ZLayer.derive[AuthorizationRestServiceLive] } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/AuthorizationRestServiceSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/AuthorizationRestServiceSpec.scala index c4f133fcc9..03f74072d0 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/AuthorizationRestServiceSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/api/service/AuthorizationRestServiceSpec.scala @@ -6,17 +6,19 @@ package org.knora.webapi.slice.admin.api.service import zio.Exit -import zio.test.Spec -import zio.test.TestSuccess -import zio.test.ZIOSpecDefault -import zio.test.assertCompletes -import zio.test.assertTrue +import zio.ZIO +import zio.test.Assertion.failsWithA +import zio.test.* import dsp.errors.ForbiddenException +import org.knora.webapi.TestDataFactory +import org.knora.webapi.messages.OntologyConstants.KnoraAdmin.ProjectAdmin import org.knora.webapi.messages.OntologyConstants.KnoraAdmin.SystemAdmin import org.knora.webapi.messages.OntologyConstants.KnoraAdmin.SystemProject import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionsDataADM import org.knora.webapi.slice.admin.domain.model.User +import org.knora.webapi.slice.admin.domain.repo.KnoraProjectRepoInMemory +import org.knora.webapi.slice.admin.repo.service.KnoraUserGroupRepoInMemory import org.knora.webapi.slice.common.api.AuthorizationRestService import org.knora.webapi.slice.common.api.AuthorizationRestServiceLive @@ -32,7 +34,7 @@ object AuthorizationRestServiceSpec extends ZIOSpecDefault { private val inactiveSystemAdmin = activeSystemAdmin.copy(status = false) - val spec: Spec[Any, ForbiddenException]#ZSpec[Any, ForbiddenException, TestSuccess] = suite("RestPermissionService")( + val spec: Spec[Any, Any] = suite("RestPermissionService")( suite("given an inactive system admin")( test("isSystemAdmin should return true") { assertTrue(AuthorizationRestService.isSystemAdmin(inactiveSystemAdmin)) @@ -81,7 +83,38 @@ object AuthorizationRestServiceSpec extends ZIOSpecDefault { ) ) ) + }, + test( + "and given a project for which the user is project admin when ensureSystemAdminOrProjectAdmin then succeed" + ) { + val project = TestDataFactory.someProject + for { + _ <- ZIO.serviceWithZIO[KnoraProjectRepoInMemory](_.save(project)) + userIsAdmin = + activeNormalUser.copy(permissions = PermissionsDataADM(Map(project.id.value -> List(ProjectAdmin)))) + actualProject <- AuthorizationRestService.ensureSystemAdminOrProjectAdmin(userIsAdmin, project.id) + } yield assertTrue(project == actualProject) + }, + test( + "and given the project does not exists for which the user is project admin when ensureSystemAdminOrProjectAdmin then succeed" + ) { + val project = TestDataFactory.someProject + val userIsAdmin = + activeNormalUser.copy(permissions = PermissionsDataADM(Map(project.id.value -> List(ProjectAdmin)))) + for { + exit <- AuthorizationRestService.ensureSystemAdminOrProjectAdmin(userIsAdmin, project.id).exit + } yield assert(exit)(failsWithA[ForbiddenException]) + }, + test( + "and given a project for which the user is _not_ project admin when ensureSystemAdminOrProjectAdmin then fail" + ) { + val project = TestDataFactory.someProject + for { + _ <- ZIO.serviceWithZIO[KnoraProjectRepoInMemory](_.save(project)) + userIsNotAdmin = activeNormalUser.copy(permissions = PermissionsDataADM(Map.empty)) + exit <- AuthorizationRestService.ensureSystemAdminOrProjectAdmin(userIsNotAdmin, project.id).exit + } yield assert(exit)(failsWithA[ForbiddenException]) } ) - ).provide(AuthorizationRestServiceLive.layer) + ).provide(AuthorizationRestServiceLive.layer, KnoraProjectRepoInMemory.layer, KnoraUserGroupRepoInMemory.layer) } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/KnoraProjectSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/KnoraProjectSpec.scala index d8a8d9436c..577cadf1a6 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/KnoraProjectSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/KnoraProjectSpec.scala @@ -63,7 +63,7 @@ object KnoraProjectSpec extends ZIOSpecDefault { assertTrue(Shortcode.from("") == Left("Shortcode cannot be empty.")) }, test("pass an invalid value and return an error") { - val invalidShortcodes = Gen.fromIterable(Seq("123", "000G", "12345")) + val invalidShortcodes = Gen.fromIterable(Seq("123", "000G", "12345", "aa")) check(invalidShortcodes) { shortcode => assertTrue(Shortcode.from(shortcode) == Left(s"Shortcode is invalid: $shortcode")) } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/KnoraUserGroupRepoLiveSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/KnoraUserGroupRepoLiveSpec.scala new file mode 100644 index 0000000000..1d79c89c9c --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/repo/service/KnoraUserGroupRepoLiveSpec.scala @@ -0,0 +1,23 @@ +/* + * Copyright © 2021 - 2024 Swiss National Data and Service Center for the Humanities and/or DaSCH Service Platform contributors. + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.knora.webapi.slice.admin.repo.service + +import zio.Ref +import zio.ZLayer + +import org.knora.webapi.slice.admin.domain.model.GroupIri +import org.knora.webapi.slice.admin.domain.model.KnoraUserGroup +import org.knora.webapi.slice.admin.domain.service.KnoraUserGroupRepo +import org.knora.webapi.slice.common.repo.AbstractInMemoryCrudRepository + +final case class KnoraUserGroupRepoInMemory(groups: Ref[List[KnoraUserGroup]]) + extends AbstractInMemoryCrudRepository[KnoraUserGroup, GroupIri](groups, _.id) + with KnoraUserGroupRepo {} + +object KnoraUserGroupRepoInMemory { + val layer = ZLayer.fromZIO(Ref.make(List.empty[KnoraUserGroup])) >>> + ZLayer.derive[KnoraUserGroupRepoInMemory] +}