From ecf9c0af91cb06c72df1da0a93212eae8fe9f47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Kleinb=C3=B6lting?= Date: Fri, 19 Jan 2024 10:18:42 +0100 Subject: [PATCH] fix: Fix UserIri and allow existing values (DEV-3194) (#2997) Co-authored-by: Marcin Procyk --- .../org/knora/webapi/core/LayersTest.scala | 4 +- .../org/knora/webapi/core/LayersLive.scala | 4 +- .../usersmessages/UsersMessagesADM.scala | 2 +- .../responders/admin/UsersResponderADM.scala | 32 ++++--- .../webapi/routing/admin/UsersRouteADM.scala | 64 ++++--------- .../knora/webapi/slice/admin/api/Codecs.scala | 2 + .../slice/admin/api/UsersEndpoints.scala | 15 +++- .../admin/api/UsersEndpointsHandler.scala | 26 ++++-- .../api/service/UsersADMRestService.scala | 35 -------- .../admin/api/service/UsersRestService.scala | 41 +++++++++ .../slice/admin/domain/model/User.scala | 89 ++++++++++++------- .../slice/admin/domain/model/UserSpec.scala | 70 +++++++++++---- 12 files changed, 229 insertions(+), 155 deletions(-) delete mode 100644 webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersADMRestService.scala create mode 100644 webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.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 c68632acbe..ef3b8dafab 100644 --- a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala +++ b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala @@ -33,7 +33,7 @@ import org.knora.webapi.slice.admin.api.service.MaintenanceRestService import org.knora.webapi.slice.admin.api.service.PermissionsRestService import org.knora.webapi.slice.admin.api.service.ProjectADMRestService import org.knora.webapi.slice.admin.api.service.ProjectsADMRestServiceLive -import org.knora.webapi.slice.admin.api.service.UsersADMRestServiceLive +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.common.api.* @@ -217,7 +217,7 @@ object LayersTest { TapirToPekkoInterpreter.layer, TestClientService.layer, TriplestoreServiceLive.layer, - UsersADMRestServiceLive.layer, + UsersRestService.layer, UsersEndpoints.layer, UsersEndpointsHandler.layer, UsersResponderADMLive.layer, 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 b89600e0de..52b502af0a 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -34,7 +34,7 @@ import org.knora.webapi.slice.admin.api.service.MaintenanceRestService import org.knora.webapi.slice.admin.api.service.PermissionsRestService import org.knora.webapi.slice.admin.api.service.ProjectADMRestService import org.knora.webapi.slice.admin.api.service.ProjectsADMRestServiceLive -import org.knora.webapi.slice.admin.api.service.UsersADMRestServiceLive +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.common.api.* @@ -166,7 +166,7 @@ object LayersLive { StringFormatter.live, TapirToPekkoInterpreter.layer, TriplestoreServiceLive.layer, - UsersADMRestServiceLive.layer, + UsersRestService.layer, UsersEndpoints.layer, UsersEndpointsHandler.layer, UsersResponderADMLive.layer, 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 d9c0c11c1e..65fad90950 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 @@ -474,7 +474,7 @@ case class UserGroupMembershipsGetResponseADM(groups: Seq[GroupADM]) extends Kno * * @param user the new user profile of the created/modified user. */ -case class UserOperationResponseADM(user: User) extends KnoraResponseADM { +case class UserOperationResponseADM(user: User) extends AdminKnoraResponseADM { def toJsValue: JsValue = UsersADMJsonProtocol.userOperationResponseADMFormat.write(this) } diff --git a/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponderADM.scala b/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponderADM.scala index 0add613087..617be81a19 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponderADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/admin/UsersResponderADM.scala @@ -57,6 +57,24 @@ import org.knora.webapi.util.ZioHelper @accessible trait UsersResponderADM { def getAllUserADMRequest(requestingUser: User): Task[UsersGetResponseADM] + + /** + * Change the user's status (active / inactive). + * + * @param userIri the IRI of the existing user that we want to update. + * @param status the new status. + * @param requestingUser the requesting user. + * @param apiRequestID the unique api request ID. + * @return a task containing a [[UserOperationResponseADM]]. + * fails with a [[BadRequestException]] if necessary parameters are not supplied. + * fails with a [[ForbiddenException]] if the requestingUser doesn't hold the necessary permission for the operation. + */ + def changeUserStatusADM( + userIri: IRI, + status: UserStatus, + requestingUser: User, + apiRequestID: UUID + ): Task[UserOperationResponseADM] } final case class UsersResponderADMLive( @@ -601,19 +619,7 @@ final case class UsersResponderADMLive( ) } - /** - * Change the user's status (active / inactive). - * - * @param userIri the IRI of the existing user that we want to update. - * @param status the new status. - * - * @param requestingUser the requesting user. - * @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 changeUserStatusADM( + override def changeUserStatusADM( userIri: IRI, status: UserStatus, requestingUser: User, 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 index 78662f45dd..25e54d140a 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/admin/UsersRouteADM.scala @@ -16,19 +16,13 @@ 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.messages.util.KnoraSystemInstances.Users.AnonymousUser -import org.knora.webapi.messages.util.KnoraSystemInstances.Users.SystemUser import org.knora.webapi.routing.Authenticator import org.knora.webapi.routing.RouteUtilADM import org.knora.webapi.routing.RouteUtilADM.getIriUser 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.Email -import org.knora.webapi.slice.admin.domain.model.SystemAdmin -import org.knora.webapi.slice.admin.domain.model.UserIri -import org.knora.webapi.slice.admin.domain.model.UserStatus -import org.knora.webapi.slice.admin.domain.model.Username +import org.knora.webapi.slice.admin.domain.model.* /** * Provides an pekko-http-routing function for API routes that deal with users. @@ -47,7 +41,6 @@ final case class UsersRouteADM()( changeUserBasicInformation() ~ changeUserPassword() ~ changeUserStatus() ~ - deleteUser() ~ changeUserSystemAdminMembership() ~ getUsersProjectMemberships() ~ addUserToProjectMembership() ~ @@ -122,7 +115,7 @@ final case class UsersRouteADM()( put { entity(as[ChangeUserApiRequestADM]) { apiRequest => requestContext => val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) r <- getUserUuid(requestContext) payload <- UserUpdateBasicInformationPayloadADM .make(apiRequest) @@ -143,7 +136,7 @@ final case class UsersRouteADM()( put { entity(as[ChangeUserPasswordApiRequestADM]) { apiRequest => requestContext => val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) payload <- UserUpdatePasswordPayloadADM.make(apiRequest).mapError(BadRequestException(_)).toZIO r <- getUserUuid(requestContext) } yield UserChangePasswordRequestADM(checkedUserIri, payload, r.user, r.uuid) @@ -163,7 +156,7 @@ final case class UsersRouteADM()( newStatus <- ZIO .fromOption(apiRequest.status.map(UserStatus.from)) .orElseFail(BadRequestException("The status is missing.")) - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) r <- getUserUuid(requestContext) } yield UserChangeStatusRequestADM(checkedUserIri, newStatus, r.user, r.uuid) runJsonRouteZ(task, requestContext) @@ -171,19 +164,6 @@ final case class UsersRouteADM()( } } - /** - * delete a user identified by iri (change status to false). - */ - private def deleteUser(): Route = path(usersBasePath / "iri" / Segment) { userIri => - delete { requestContext => - val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) - r <- getUserUuid(requestContext) - } yield UserChangeStatusRequestADM(checkedUserIri, UserStatus.from(false), r.user, r.uuid) - runJsonRouteZ(task, requestContext) - } - } - /** * Change user's SystemAdmin membership. */ @@ -192,7 +172,7 @@ final case class UsersRouteADM()( put { entity(as[ChangeUserApiRequestADM]) { apiRequest => requestContext => val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) r <- getUserUuid(requestContext) newSystemAdmin <- ZIO .fromOption(apiRequest.systemAdmin.map(SystemAdmin.from)) @@ -210,7 +190,7 @@ final case class UsersRouteADM()( path(usersBasePath / "iri" / Segment / "project-memberships") { userIri => get { requestContext => val requestTask = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) requestingUser <- Authenticator.getUserADM(requestContext) } yield UserProjectMembershipsGetRequestADM(checkedUserIri, requestingUser) runJsonRouteZ(requestTask, requestContext) @@ -224,24 +204,18 @@ final case class UsersRouteADM()( path(usersBasePath / "iri" / Segment / "project-memberships" / Segment) { (userIri, projectIri) => post { requestContext => val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) - r <- getIriUserUuid(projectIri, requestContext) - } yield UserProjectMembershipAddRequestADM(checkedUserIri, r.iri, r.user, r.uuid) + userIri <- validateUserIriAndEnsureRegularUser(userIri) + r <- getIriUserUuid(projectIri, requestContext) + } yield UserProjectMembershipAddRequestADM(userIri, r.iri, r.user, r.uuid) runJsonRouteZ(task, requestContext) } } - private def validateAndEscapeUserIri(userIri: String): ZIO[StringFormatter, BadRequestException, String] = - for { - nonEmptyIri <- ZIO.succeed(userIri).filterOrFail(_.nonEmpty)(BadRequestException("User IRI cannot be empty")) - checkedUserIri <- - ZIO - .fromOption(Iri.validateAndEscapeUserIri(nonEmptyIri)) - .orElseFail(BadRequestException(s"Invalid user IRI $userIri")) - .filterOrFail(isNotBuildInUser)(BadRequestException("Changes to built-in users are not allowed.")) - } yield checkedUserIri - - private def isNotBuildInUser(it: String) = !it.equals(SystemUser.id) && !it.equals(AnonymousUser.id) + 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 @@ -256,7 +230,7 @@ final case class UsersRouteADM()( path(usersBasePath / "iri" / Segment / "project-memberships" / Segment) { (userIri, projectIri) => delete { requestContext => val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) r <- getIriUserUuid(projectIri, requestContext) } yield UserProjectMembershipRemoveRequestADM(checkedUserIri, r.iri, r.user, r.uuid) runJsonRouteZ(task, requestContext) @@ -284,7 +258,7 @@ final case class UsersRouteADM()( path(usersBasePath / "iri" / Segment / "project-admin-memberships" / Segment) { (userIri, projectIri) => post { ctx => val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) r <- getIriUserUuid(projectIri, ctx) } yield UserProjectAdminMembershipAddRequestADM(checkedUserIri, r.iri, r.user, r.uuid) runJsonRouteZ(task, ctx) @@ -298,7 +272,7 @@ final case class UsersRouteADM()( path(usersBasePath / "iri" / Segment / "project-admin-memberships" / Segment) { (userIri, projectIri) => delete { requestContext => val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) r <- getIriUserUuid(projectIri, requestContext) } yield UserProjectAdminMembershipRemoveRequestADM(checkedUserIri, r.iri, r.user, r.uuid) runJsonRouteZ(task, requestContext) @@ -326,7 +300,7 @@ final case class UsersRouteADM()( path(usersBasePath / "iri" / Segment / "group-memberships" / Segment) { (userIri, groupIri) => post { requestContext => val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) checkedGroupIri <- validateAndEscapeGroupIri(groupIri) r <- getIriUserUuid(groupIri, requestContext) } yield UserGroupMembershipAddRequestADM(checkedUserIri, checkedGroupIri, r.user, r.uuid) @@ -341,7 +315,7 @@ final case class UsersRouteADM()( path(usersBasePath / "iri" / Segment / "group-memberships" / Segment) { (userIri, groupIri) => delete { requestContext => val task = for { - checkedUserIri <- validateAndEscapeUserIri(userIri) + checkedUserIri <- validateUserIriAndEnsureRegularUser(userIri) checkedGroupIri <- validateAndEscapeGroupIri(groupIri) r <- getIriUserUuid(groupIri, requestContext) } yield UserGroupMembershipRemoveRequestADM(checkedUserIri, checkedGroupIri, r.user, r.uuid) diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/Codecs.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/Codecs.scala index ac732afcb0..113ded782a 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/Codecs.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/Codecs.scala @@ -12,6 +12,7 @@ import zio.json.JsonCodec import dsp.valueobjects.V2 import org.knora.webapi.slice.admin.api.model.MaintenanceRequests.AssetId import org.knora.webapi.slice.admin.domain.model.KnoraProject.* +import org.knora.webapi.slice.admin.domain.model.UserIri import org.knora.webapi.slice.common.Value.BooleanValue import org.knora.webapi.slice.common.Value.StringValue import org.knora.webapi.slice.common.domain.SparqlEncodedString @@ -40,6 +41,7 @@ object Codecs { implicit val shortname: StringCodec[Shortname] = stringCodec(Shortname.from) implicit val sparqlEncodedString: StringCodec[SparqlEncodedString] = stringCodec(SparqlEncodedString.from) implicit val status: StringCodec[Status] = booleanCodec(Status.from) + implicit val userIri: StringCodec[UserIri] = stringCodec(UserIri.from) } object ZioJsonCodec { 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 d94d7ae0c4..1b50511216 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 @@ -10,10 +10,17 @@ import sttp.tapir.generic.auto.* import sttp.tapir.json.spray.jsonBody as sprayJsonBody import zio.* +import org.knora.webapi.messages.admin.responder.usersmessages.UserOperationResponseADM import org.knora.webapi.messages.admin.responder.usersmessages.UsersADMJsonProtocol.* import org.knora.webapi.messages.admin.responder.usersmessages.UsersGetResponseADM +import org.knora.webapi.slice.admin.api.Codecs.TapirCodec.userIri +import org.knora.webapi.slice.admin.domain.model.UserIri import org.knora.webapi.slice.common.api.BaseEndpoints +object PathVars { + val userIriPathVar: EndpointInput.PathCapture[UserIri] = + path[UserIri].description("The user IRI. Must be URL-encoded.") +} final case class UsersEndpoints(baseEndpoints: BaseEndpoints) { private val base = "admin" / "users" private val tags = List("Users", "Admin API") @@ -24,7 +31,13 @@ final case class UsersEndpoints(baseEndpoints: BaseEndpoints) { .description("Returns all users.") .tags(tags) - val endpoints: Seq[AnyEndpoint] = Seq(getUsers).map(_.endpoint) + val deleteUser = baseEndpoints.securedEndpoint.delete + .in(base / "iri" / PathVars.userIriPathVar) + .out(sprayJsonBody[UserOperationResponseADM]) + .description("Delete a user identified by IRI (change status to false).") + .tags(tags) + + val endpoints: Seq[AnyEndpoint] = Seq(getUsers, deleteUser).map(_.endpoint) } object 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 2b4460165b..3011d90636 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,27 +7,35 @@ package org.knora.webapi.slice.admin.api import zio.ZLayer +import org.knora.webapi.messages.admin.responder.usersmessages.UserOperationResponseADM import org.knora.webapi.messages.admin.responder.usersmessages.UsersGetResponseADM -import org.knora.webapi.slice.admin.api.service.UsersADMRestService +import org.knora.webapi.slice.admin.api.service.UsersRestService +import org.knora.webapi.slice.admin.domain.model.UserIri import org.knora.webapi.slice.common.api.HandlerMapper import org.knora.webapi.slice.common.api.SecuredEndpointAndZioHandler case class UsersEndpointsHandler( usersEndpoints: UsersEndpoints, - restService: UsersADMRestService, + restService: UsersRestService, mapper: HandlerMapper ) { - val getUsersHandler = + private val getUsersHandler = SecuredEndpointAndZioHandler[ Unit, UsersGetResponseADM - ](usersEndpoints.getUsers, user => { case (_: Unit) => restService.listAllUsers(user) }) - -// private val handlers = List().map(mapper.mapEndpointAndHandler) - private val securedHandlers = List(getUsersHandler).map(mapper.mapEndpointAndHandler(_)) - - val allHanders = /* handlers ++ */ securedHandlers + ]( + usersEndpoints.getUsers, + requestingUser => _ => restService.listAllUsers(requestingUser) + ) + + private val deleteUserByIriHandler = + SecuredEndpointAndZioHandler[UserIri, UserOperationResponseADM]( + usersEndpoints.deleteUser, + requestingUser => { case (userIri: UserIri) => restService.deleteUser(requestingUser, userIri) } + ) + + val allHanders = List(getUsersHandler, deleteUserByIriHandler).map(mapper.mapEndpointAndHandler(_)) } object UsersEndpointsHandler { diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersADMRestService.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersADMRestService.scala deleted file mode 100644 index 88fc4ce7b8..0000000000 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersADMRestService.scala +++ /dev/null @@ -1,35 +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.slice.admin.api.service - -import zio.* -import zio.macros.accessible - -import org.knora.webapi.messages.admin.responder.usersmessages.UsersGetResponseADM -import org.knora.webapi.responders.admin.UsersResponderADM -import org.knora.webapi.slice.admin.domain.model.User -import org.knora.webapi.slice.common.api.KnoraResponseRenderer - -@accessible -trait UsersADMRestService { - - def listAllUsers(user: User): Task[UsersGetResponseADM] -} - -final case class UsersADMRestServiceLive( - responder: UsersResponderADM, - format: KnoraResponseRenderer -) extends UsersADMRestService { - - override def listAllUsers(user: User): Task[UsersGetResponseADM] = for { - internal <- responder.getAllUserADMRequest(user) - external <- format.toExternal(internal) - } yield external -} - -object UsersADMRestServiceLive { - val layer = ZLayer.derive[UsersADMRestServiceLive] -} 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 new file mode 100644 index 0000000000..fc3e6ccb0c --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/api/service/UsersRestService.scala @@ -0,0 +1,41 @@ +/* + * 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.api.service + +import zio.* + +import dsp.errors.BadRequestException +import org.knora.webapi.messages.admin.responder.usersmessages.UserOperationResponseADM +import org.knora.webapi.messages.admin.responder.usersmessages.UsersGetResponseADM +import org.knora.webapi.responders.admin.UsersResponderADM +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 +import org.knora.webapi.slice.common.api.KnoraResponseRenderer + +final case class UsersRestService( + responder: UsersResponderADM, + format: KnoraResponseRenderer +) { + + def listAllUsers(user: User): Task[UsersGetResponseADM] = for { + internal <- responder.getAllUserADMRequest(user) + external <- format.toExternal(internal) + } yield external + + def deleteUser(requestingUser: User, deleteIri: UserIri): Task[UserOperationResponseADM] = for { + _ <- ZIO + .fail(BadRequestException("Changes to built-in users are not allowed.")) + .when(deleteIri.isBuiltInUser) + uuid <- Random.nextUUID + internal <- responder.changeUserStatusADM(deleteIri.value, UserStatus.Inactive, requestingUser, uuid) + external <- format.toExternal(internal) + } yield external +} + +object UsersRestService { + val layer = ZLayer.derive[UsersRestService] +} diff --git a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala index 244970446c..566b3dab9a 100644 --- a/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/admin/domain/model/User.scala @@ -14,9 +14,7 @@ import java.security.MessageDigest import java.security.SecureRandom import scala.util.matching.Regex -import dsp.valueobjects.Iri.isUserIri -import dsp.valueobjects.Iri.validateAndEscapeUserIri -import dsp.valueobjects.IriErrorMessages +import dsp.valueobjects.Iri import dsp.valueobjects.UuidUtil import org.knora.webapi.messages.OntologyConstants import org.knora.webapi.messages.admin.responder.groupsmessages.GroupADM @@ -24,6 +22,9 @@ import org.knora.webapi.messages.admin.responder.permissionsmessages.Permissions import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM import org.knora.webapi.messages.admin.responder.usersmessages.UserInformationTypeADM import org.knora.webapi.messages.admin.responder.usersmessages.UsersADMJsonProtocol +import org.knora.webapi.slice.common.StringValueCompanion +import org.knora.webapi.slice.common.Value.BooleanValue +import org.knora.webapi.slice.common.Value.StringValue /** * Represents a user's profile. @@ -136,24 +137,46 @@ final case class User( def isAnonymousUser: Boolean = id.equalsIgnoreCase(OntologyConstants.KnoraAdmin.AnonymousUser) } -final case class UserIri private (value: String) extends AnyVal -object UserIri { - def from(value: String): Either[String, UserIri] = - if (value.isEmpty) Left(UserErrorMessages.UserIriMissing) - else { - val isUuid: Boolean = UuidUtil.hasValidLength(value.split("/").last) - - if (!isUserIri(value)) - Left(UserErrorMessages.UserIriInvalid(value)) - else if (isUuid && !UuidUtil.hasSupportedVersion(value)) - Left(IriErrorMessages.UuidVersionInvalid) - else - validateAndEscapeUserIri(value).toRight(UserErrorMessages.UserIriInvalid(value)).map(UserIri(_)) - } +final case class UserIri private (value: String) extends AnyVal with StringValue + +object UserIri extends StringValueCompanion[UserIri] { - def unsafeFrom(value: String): UserIri = - UserIri.from(value).fold(e => throw new IllegalArgumentException(e), identity) + implicit class UserIriOps(val userIri: UserIri) { + def isBuiltInUser: Boolean = builtInIris.contains(userIri.value) + def isRegularUser: Boolean = !isBuiltInUser + } + def makeNew: UserIri = UserIri.unsafeFrom(s"http://rdfh.ch/users/${UuidUtil.makeRandomBase64EncodedUuid}") + + /** + * Explanation of the user IRI regex: + * + * `^` Asserts the start of the string. + * + * `http://rdfh\.ch/users/`: Matches the specified prefix. + * + * `[a-zA-Z0-9_-]{4,36}`: Matches any alphanumeric character, hyphen, or underscore between 4 and 36 times. + * + * `$`: Asserts the end of the string. + */ + private val userIriRegEx = """^http://rdfh\.ch/users/[a-zA-Z0-9_-]{4,36}$""".r + + private val builtInIris = Seq( + OntologyConstants.KnoraAdmin.SystemUser, + OntologyConstants.KnoraAdmin.AnonymousUser, + OntologyConstants.KnoraAdmin.SystemAdmin + ) + + private def isValid(iri: String) = + builtInIris.contains(iri) || (Iri.isIri(iri) && userIriRegEx.matches(iri)) + + private val isInvalid = "User IRI is invalid." + + def from(value: String): Either[String, UserIri] = value match { + case _ if value.isEmpty => Left("User IRI cannot be empty.") + case _ if isValid(value) => Right(UserIri(value)) + case _ => Left(isInvalid) + } def validationFrom(value: String): Validation[String, UserIri] = Validation.fromEither(from(value)) } @@ -303,10 +326,14 @@ object PasswordStrength { } -final case class UserStatus private (value: Boolean) extends AnyVal +final case class UserStatus private (value: Boolean) extends AnyVal with BooleanValue + object UserStatus { - def from(value: Boolean): UserStatus = UserStatus(value) + val Active: UserStatus = UserStatus(true) + val Inactive: UserStatus = UserStatus(false) + + def from(value: Boolean): UserStatus = if (value) Active else Inactive } final case class SystemAdmin private (value: Boolean) extends AnyVal @@ -315,15 +342,13 @@ object SystemAdmin { } object UserErrorMessages { - val UserIriMissing: String = "User IRI cannot be empty." - val UserIriInvalid: String => String = (iri: String) => s"User IRI: $iri is invalid." - val UsernameMissing = "Username cannot be empty." - val UsernameInvalid = "Username is invalid." - val EmailMissing = "Email cannot be empty." - val EmailInvalid = "Email is invalid." - val PasswordMissing = "Password cannot be empty." - val PasswordInvalid = "Password is invalid." - val PasswordStrengthInvalid = "PasswordStrength is invalid." - val GivenNameMissing = "GivenName cannot be empty." - val FamilyNameMissing = "FamilyName cannot be empty." + val UsernameMissing = "Username cannot be empty." + val UsernameInvalid = "Username is invalid." + val EmailMissing = "Email cannot be empty." + val EmailInvalid = "Email is invalid." + val PasswordMissing = "Password cannot be empty." + val PasswordInvalid = "Password is invalid." + val PasswordStrengthInvalid = "PasswordStrength is invalid." + val GivenNameMissing = "GivenName cannot be empty." + val FamilyNameMissing = "FamilyName cannot be empty." } diff --git a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UserSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UserSpec.scala index 9d38569890..0c18313c25 100644 --- a/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UserSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/slice/admin/domain/model/UserSpec.scala @@ -8,13 +8,10 @@ package org.knora.webapi.slice.admin.domain.model import zio.test.* object UserSpec extends ZIOSpecDefault { - private val validUserIri = "http://rdfh.ch/users/jDEEitJESRi3pDaDjjQ1WQ" - private val userIriWithUUIDVersion3 = "http://rdfh.ch/users/cCmdcpn2MO211YYOplR1hQ" - private val invalidIri = "Invalid IRI" - private val validPassword = "pass-word" - private val validGivenName = "John" - private val validFamilyName = "Rambo" - private val pwStrength = PasswordStrength.unsafeMake(12) + private val validPassword = "pass-word" + private val validGivenName = "John" + private val validFamilyName = "Rambo" + private val pwStrength = PasswordStrength.unsafeMake(12) private val userSuite = suite("User")() @@ -75,18 +72,61 @@ object UserSpec extends ZIOSpecDefault { test("pass an empty value and return an error") { assertTrue(UserIri.from("") == Left("User IRI cannot be empty.")) }, - test("pass an invalid value and return an error") { - assertTrue(UserIri.from(invalidIri) == Left(s"User IRI: $invalidIri is invalid.")) + test("make new should create a valid user iri") { + assertTrue(UserIri.makeNew.value.startsWith("http://rdfh.ch/users/")) }, - test("pass an invalid IRI containing unsupported UUID version and return an error") { - assertTrue( - UserIri.from(userIriWithUUIDVersion3) == Left( - "Invalid UUID used to create IRI. Only versions 4 and 5 are supported." + test("built in users should be builtIn") { + val builtInIris = Gen.fromIterable( + Seq( + "http://www.knora.org/ontology/knora-admin#AnonymousUser", + "http://www.knora.org/ontology/knora-admin#SystemUser", + "http://www.knora.org/ontology/knora-admin#AnonymousUser" ) ) + check(builtInIris) { i => + val userIri = UserIri.unsafeFrom(i) + assertTrue(!userIri.isRegularUser, userIri.isBuiltInUser) + } }, - test("pass a valid value and successfully create value object") { - assertTrue(UserIri.from(validUserIri).isRight) + test("regular user iris should not be builtIn") { + val builtInIris = Gen.fromIterable( + Seq( + "http://rdfh.ch/users/jDEEitJESRi3pDaDjjQ1WQ", + "http://rdfh.ch/users/PSGbemdjZi4kQ6GHJVkLGE" + ) + ) + check(builtInIris) { i => + val userIri = UserIri.unsafeFrom(i) + assertTrue(userIri.isRegularUser, !userIri.isBuiltInUser) + } + }, + test("valid iris should be a valid iri") { + val validIris = Gen.fromIterable( + Seq( + "http://rdfh.ch/users/jDEEitJESRi3pDaDjjQ1WQ", + "http://rdfh.ch/users/PSGbemdjZi4kQ6GHJVkLGE", + "http://www.knora.org/ontology/knora-admin#AnonymousUser", + "http://www.knora.org/ontology/knora-admin#SystemUser", + "http://www.knora.org/ontology/knora-admin#AnonymousUser", + "http://rdfh.ch/users/mls-0807-import-user", + "http://rdfh.ch/users/root", + "http://rdfh.ch/users/images-reviewer-user", + "http://rdfh.ch/users/AnythingAdminUser", + "http://rdfh.ch/users/subotic", + "http://rdfh.ch/users/_fH9FS-VRMiPPiIMRpjevA" + ) + ) + check(validIris)(i => assertTrue(UserIri.from(i).isRight)) + }, + test("pass an invalid value and return an error") { + val invalidIris = Gen.fromIterable( + Seq( + "Invalid IRI", + "http://rdfh.ch/user/AnythingAdminUser", + "http://rdfh.ch/users/AnythingAdminUser/" + ) + ) + check(invalidIris)(i => assertTrue(UserIri.from(i) == Left(s"User IRI is invalid."))) } )