diff --git a/integration/src/test/scala/org/knora/sipi/SipiClientTestDelegator.scala b/integration/src/test/scala/org/knora/sipi/SipiClientTestDelegator.scala new file mode 100644 index 0000000000..64f302b69b --- /dev/null +++ b/integration/src/test/scala/org/knora/sipi/SipiClientTestDelegator.scala @@ -0,0 +1,123 @@ +/* + * 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.sipi + +import zio.Task +import zio.ULayer +import zio.ZLayer +import zio.nio.file.Path + +import org.knora.webapi.config.AppConfig +import org.knora.webapi.messages.store.sipimessages.DeleteTemporaryFileRequest +import org.knora.webapi.messages.store.sipimessages.IIIFServiceStatusResponse +import org.knora.webapi.messages.store.sipimessages.MoveTemporaryFileToPermanentStorageRequest +import org.knora.webapi.messages.store.sipimessages.SipiGetTextFileRequest +import org.knora.webapi.messages.store.sipimessages.SipiGetTextFileResponse +import org.knora.webapi.messages.v2.responder.SuccessResponseV2 +import org.knora.webapi.routing.JwtService +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.User +import org.knora.webapi.slice.admin.domain.service.Asset +import org.knora.webapi.slice.admin.domain.service.DspIngestClient +import org.knora.webapi.store.iiif.api.FileMetadataSipiResponse +import org.knora.webapi.store.iiif.api.SipiService +import org.knora.webapi.store.iiif.impl.SipiServiceLive +import org.knora.webapi.store.iiif.impl.SipiServiceMock + +case class WhichSipiService(useLive: Boolean) + +object WhichSipiService { + val live: ULayer[WhichSipiService] = ZLayer.succeed(WhichSipiService(useLive = true)) + val mock: ULayer[WhichSipiService] = ZLayer.succeed(WhichSipiService(useLive = false)) +} + +/** + * A delegator for the Sipi service, used in tests. + * Depending on the [[WhichSipiService]] configuration, it delegates to either the [[SipiServiceLive]] or the [[SipiServiceMock]]. + */ +case class SipiServiceTestDelegator( + private val whichSipi: WhichSipiService, + private val live: SipiServiceLive, + private val mock: SipiServiceMock, +) extends SipiService { + + private final val sipiService = + if (whichSipi.useLive) { live } + else { mock } + + /** + * Asks Sipi for metadata about a file in the tmp folder, served from the 'knora.json' route. + * + * @param filename the path to the file. + * @return a [[FileMetadataSipiResponse]] containing the requested metadata. + */ + override def getFileMetadataFromSipiTemp(filename: String): Task[FileMetadataSipiResponse] = + sipiService.getFileMetadataFromSipiTemp(filename) + + /** + * Asks DSP-Ingest for metadata about a file in permanent location, served from the 'knora.json' route. + * + * @param shortcode the shortcode of the project. + * @param assetId for the file. + * @return a [[FileMetadataSipiResponse]] containing the requested metadata. + */ + override def getFileMetadataFromDspIngest( + shortcode: KnoraProject.Shortcode, + assetId: AssetId, + ): Task[FileMetadataSipiResponse] = + sipiService.getFileMetadataFromDspIngest(shortcode, assetId) + + /** + * Asks Sipi to move a file from temporary storage to permanent storage. + * + * @param moveTemporaryFileToPermanentStorageRequestV2 the request. + * @return a [[SuccessResponseV2]]. + */ + override def moveTemporaryFileToPermanentStorage( + moveTemporaryFileToPermanentStorageRequestV2: MoveTemporaryFileToPermanentStorageRequest, + ): Task[SuccessResponseV2] = + sipiService.moveTemporaryFileToPermanentStorage(moveTemporaryFileToPermanentStorageRequestV2) + + /** + * Asks Sipi to delete a temporary file. + * + * @param deleteTemporaryFileRequestV2 the request. + * @return a [[SuccessResponseV2]]. + */ + override def deleteTemporaryFile(deleteTemporaryFileRequestV2: DeleteTemporaryFileRequest): Task[SuccessResponseV2] = + sipiService.deleteTemporaryFile(deleteTemporaryFileRequestV2) + + /** + * Asks Sipi for a text file used internally by Knora. + * + * @param textFileRequest the request message. + */ + override def getTextFileRequest(textFileRequest: SipiGetTextFileRequest): Task[SipiGetTextFileResponse] = + sipiService.getTextFileRequest(textFileRequest) + + /** + * Tries to access the IIIF Service. + */ + override def getStatus(): Task[IIIFServiceStatusResponse] = + sipiService.getStatus() + + /** + * Downloads an asset from Sipi. + * + * @param asset The asset to download. + * @param targetDir The target directory in which the asset should be stored. + * @param user The user who is downloading the asset. + * @return The path to the downloaded asset. If the asset could not be downloaded, [[None]] is returned. + */ + override def downloadAsset(asset: Asset, targetDir: Path, user: User): Task[Option[Path]] = + sipiService.downloadAsset(asset, targetDir, user) +} + +object SipiServiceTestDelegator { + val layer: ZLayer[AppConfig & DspIngestClient & JwtService & WhichSipiService, Nothing, SipiService] = + SipiServiceMock.layer >+> SipiServiceLive.layer >>> ZLayer.derive[SipiServiceTestDelegator] +} diff --git a/integration/src/test/scala/org/knora/sipi/SipiIT.scala b/integration/src/test/scala/org/knora/sipi/SipiIT.scala index 9e2fee86eb..e0151414d6 100644 --- a/integration/src/test/scala/org/knora/sipi/SipiIT.scala +++ b/integration/src/test/scala/org/knora/sipi/SipiIT.scala @@ -24,12 +24,82 @@ import scala.util.Try import org.knora.sipi.MockDspApiServer.verify._ import org.knora.webapi.config.AppConfig import org.knora.webapi.messages.admin.responder.projectsmessages.PermissionCodeAndProjectRestrictedViewSettings +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.IriIdentifier +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.ShortcodeIdentifier +import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectIdentifierADM.ShortnameIdentifier import org.knora.webapi.messages.util.KnoraSystemInstances.Users.SystemUser import org.knora.webapi.routing.JwtService import org.knora.webapi.routing.JwtServiceLive +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.service.KnoraProjectRepo +import org.knora.webapi.slice.admin.domain.service.KnoraProjectService +import org.knora.webapi.slice.common.repo.service.CrudRepository +import org.knora.webapi.store.cache.CacheService import org.knora.webapi.testcontainers.SharedVolumes import org.knora.webapi.testcontainers.SipiTestContainer +final case class KnoraProjectRepoInMemory(projects: Ref[Chunk[KnoraProject]]) + extends AbstractInMemoryCrudRepository[KnoraProject, ProjectIri](projects, _.id) + with KnoraProjectRepo { + + override def findById(id: ProjectIdentifierADM): Task[Option[KnoraProject]] = projects.get.map( + _.find(id match { + case ShortcodeIdentifier(shortcode) => _.shortcode == shortcode + case ShortnameIdentifier(shortname) => _.shortname == shortname + case IriIdentifier(iri) => _.id.value == iri.value + }), + ) +} + +abstract class AbstractInMemoryCrudRepository[Entity, Id](entities: Ref[Chunk[Entity]], getId: Entity => Id) + extends CrudRepository[Entity, Id] { + + /** + * Saves a given entity. Use the returned instance for further operations as the save operation might have changed the entity instance completely. + * + * @param entity The entity to be saved. + * @return the saved entity. + */ + override def save(entity: Entity): Task[Entity] = entities.update(_.appended(entity)).as(entity) + + /** + * Deletes a given entity. + * + * @param entity The entity to be deleted + */ + override def delete(entity: Entity): Task[Unit] = deleteById(getId(entity)) + + /** + * Deletes the entity with the given id. + * If the entity is not found in the persistence store it is silently ignored. + * + * @param id The identifier to the entity to be deleted + */ + override def deleteById(id: Id): Task[Unit] = entities.update(_.filterNot(getId(_) == id)) + + /** + * Retrieves an entity by its id. + * + * @param id The identifier of type [[Id]]. + * @return the entity with the given id or [[None]] if none found. + */ + override def findById(id: Id): Task[Option[Entity]] = entities.get.map(_.find(getId(_) == id)) + + /** + * Returns all instances of the type. + * + * @return all instances of the type. + */ + override def findAll(): Task[Chunk[Entity]] = entities.get +} + +object KnoraProjectRepoInMemory { + val layer: ULayer[KnoraProjectRepoInMemory] = + ZLayer.fromZIO(Ref.make(Chunk.empty[KnoraProject]).map(KnoraProjectRepoInMemory(_))) +} + object SipiIT extends ZIOSpecDefault { private val imageTestfile = "FGiLaT4zzuV-CqwbEDFAFeS.jp2" @@ -46,7 +116,13 @@ object SipiIT extends ZIOSpecDefault { ZIO .serviceWithZIO[JwtService](_.createJwt(SystemUser)) .map(_.jwtString) - .provide(JwtServiceLive.layer, AppConfig.layer) + .provide( + JwtServiceLive.layer, + AppConfig.layer, + KnoraProjectService.layer, + CacheService.layer, + KnoraProjectRepoInMemory.layer, + ) private val cookiesSuite = suite("Given a request is authorized using cookies")( 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 037fbd24fc..b743cec722 100644 --- a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala +++ b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala @@ -8,9 +8,12 @@ package org.knora.webapi.core import org.apache.pekko import zio._ +import org.knora.sipi.SipiServiceTestDelegator +import org.knora.sipi.WhichSipiService import org.knora.webapi.config.AppConfig.AppConfigurations import org.knora.webapi.config.AppConfig.AppConfigurationsTest import org.knora.webapi.config.AppConfigForTestContainers +import org.knora.webapi.config.JwtConfig import org.knora.webapi.messages.StringFormatter import org.knora.webapi.messages.util._ import org.knora.webapi.messages.util.search.QueryTraverser @@ -59,8 +62,6 @@ import org.knora.webapi.store.cache.CacheServiceRequestMessageHandlerLive import org.knora.webapi.store.iiif.IIIFRequestMessageHandler import org.knora.webapi.store.iiif.IIIFRequestMessageHandlerLive import org.knora.webapi.store.iiif.api.SipiService -import org.knora.webapi.store.iiif.impl.SipiServiceLive -import org.knora.webapi.store.iiif.impl.SipiServiceMock import org.knora.webapi.store.triplestore.api.TriplestoreService import org.knora.webapi.store.triplestore.impl.TriplestoreServiceLive import org.knora.webapi.store.triplestore.upgrade.RepositoryUpdater @@ -82,7 +83,7 @@ object LayersTest { DefaultTestEnvironmentWithoutSipi & SipiTestContainer & DspIngestTestContainer & SharedVolumes.Images type CommonR0 = - pekko.actor.ActorSystem & AppConfigurationsTest & JwtService & SipiService & StringFormatter + pekko.actor.ActorSystem & AppConfigurationsTest & JwtConfig & WhichSipiService type CommonR = ApiRoutes & AdminApiEndpoints & ApiV2Endpoints & AppRouter & AssetPermissionsResponder & Authenticator & @@ -94,12 +95,12 @@ object LayersTest { ProjectExportStorageService & ProjectImportService & ProjectService & ProjectRestService & QueryTraverser & RepositoryUpdater & ResourceUtilV2 & ResourcesResponderV2 & RestCardinalityService & SearchApiRoutes & SearchResponderV2 & StandoffResponderV2 & StandoffTagUtilV2 & State & TestClientService & TriplestoreService & - UserService & UsersResponder & UsersRestService & ValuesResponderV2 + UserService & UsersResponder & UsersRestService & ValuesResponderV2 & JwtService & SipiService & StringFormatter private val commonLayersForAllIntegrationTests = ZLayer.makeSome[CommonR0, CommonR]( - AdminModule.layer, AdminApiModule.layer, + AdminModule.layer, ApiRoutes.layer, ApiV2Endpoints.layer, AppRouter.layer, @@ -122,10 +123,12 @@ object LayersTest { InferenceOptimizationService.layer, IriConverter.layer, IriService.layer, + JwtServiceLive.layer, KnoraResponseRenderer.layer, KnoraUserToUserConverter.layer, ListsResponder.layer, ListsResponderV2.layer, + ManagementEndpoints.layer, ManagementRoutes.layer, MessageRelayLive.layer, OntologyCacheLive.layer, @@ -149,41 +152,36 @@ object LayersTest { SearchApiRoutes.layer, SearchEndpoints.layer, SearchResponderV2Live.layer, + SipiServiceTestDelegator.layer, StandoffResponderV2.layer, StandoffTagUtilV2Live.layer, State.layer, + StringFormatter.live, TapirToPekkoInterpreter.layer, TestClientService.layer, TriplestoreServiceLive.layer, UserService.layer, UsersResponder.layer, ValuesResponderV2Live.layer, - ManagementEndpoints.layer, ) private val fusekiAndSipiTestcontainers = ZLayer.make[ - AppConfigurations & DspIngestTestContainer & FusekiTestContainer & JwtService & SharedVolumes.Images & - SipiService & SipiTestContainer & StringFormatter, + AppConfigurations & DspIngestTestContainer & FusekiTestContainer & SharedVolumes.Images & SipiTestContainer & WhichSipiService, ]( AppConfigForTestContainers.testcontainers, - DspIngestClientLive.layer, DspIngestTestContainer.layer, FusekiTestContainer.layer, SipiTestContainer.layer, - SipiServiceLive.layer, - JwtServiceLive.layer, SharedVolumes.Images.layer, - StringFormatter.test, + WhichSipiService.live, ) private val fusekiTestcontainers = - ZLayer.make[FusekiTestContainer & AppConfigurations & JwtService & SipiService & StringFormatter]( + ZLayer.make[FusekiTestContainer & AppConfigurations & WhichSipiService]( AppConfigForTestContainers.fusekiOnlyTestcontainer, FusekiTestContainer.layer, - SipiServiceMock.layer, - JwtServiceLive.layer, - StringFormatter.test, + WhichSipiService.mock, ) /** diff --git a/integration/src/test/scala/org/knora/webapi/routing/JwtServiceSpec.scala b/integration/src/test/scala/org/knora/webapi/routing/JwtServiceSpec.scala deleted file mode 100644 index a44242e945..0000000000 --- a/integration/src/test/scala/org/knora/webapi/routing/JwtServiceSpec.scala +++ /dev/null @@ -1,103 +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 - -import org.apache.pekko -import pdi.jwt.JwtAlgorithm -import pdi.jwt.JwtZIOJson -import spray.json.JsString -import zio.ZIO - -import org.knora.webapi.CoreSpec -import org.knora.webapi.config.JwtConfig -import org.knora.webapi.sharedtestdata.SharedTestDataADM -import org.knora.webapi.slice.admin.domain.model.User - -import pekko.testkit.ImplicitSender - -class JwtServiceSpec extends CoreSpec with ImplicitSender { - - private val validToken: String = - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiIwLjAuMC4wOjMzMzMiLCJzdWIiOiJodHRwOi8vcmRmaC5jaC91c2Vycy85WEJDckRWM1NSYTdrUzFXd3luQjRRIiwiYXVkIjpbIktub3JhIiwiU2lwaSJdLCJleHAiOjQ4MDE0Njg1MTEsImlhdCI6MTY0Nzg2ODUxMSwianRpIjoiYXVVVUh1aDlUanF2SnBYUXVuOVVfZyIsImZvbyI6ImJhciJ9.6yHse3pNGdDqkC4PXdkm2ZtRqITqSwo0gvCZ__4jzHQ" - - "The JWTHelper" should { - - "create a token" in { - val runZio = for { - token <- - ZIO - .serviceWithZIO[JwtService](_.createJwt(SharedTestDataADM.anythingUser1, Map("foo" -> JsString("bar")))) - .map(_.jwtString) - useriri <- ZIO.serviceWithZIO[JwtService](_.extractUserIriFromToken(token)) - } yield useriri - UnsafeZioRun.runOrThrow(runZio) should be(Some(SharedTestDataADM.anythingUser1.id)) - } - - "create a token with dsp-ingest audience for sys admins" in { - val sysAdmin = SharedTestDataADM.rootUser - val audience = createTokenAndExtractAudience(sysAdmin) - UnsafeZioRun.runOrThrow(audience) should contain("http://localhost:3340") - } - - def createTokenAndExtractAudience(user: User) = for { - token <- ZIO.serviceWithZIO[JwtService](_.createJwt(user)) - jwtConfig <- ZIO.service[JwtConfig] - decoded = JwtZIOJson.decodeAll(token.jwtString, jwtConfig.secret, Seq(JwtAlgorithm.HS256)) - audience = decoded.toOption.flatMap { case (_, claims, _) => claims.audience }.head - } yield audience - - "create a token without dsp-ingest audience for non sys admins" in { - val normalUser = SharedTestDataADM.anythingUser2 - val audience = createTokenAndExtractAudience(normalUser) - UnsafeZioRun.runOrThrow(audience) should not contain "http://localhost:3340" - } - - "validate a token" in { - val tokenValid = ZIO.serviceWithZIO[JwtService](_.validateToken(validToken)) - UnsafeZioRun.runOrThrow(tokenValid) should be(true) - } - - "extract the user's IRI" in { - val runZio = ZIO.serviceWithZIO[JwtService](_.extractUserIriFromToken(validToken)) - UnsafeZioRun.runOrThrow(runZio) should be(Some(SharedTestDataADM.anythingUser1.id)) - } - - "not decode an invalid token" in { - val invalidToken: String = - "foobareyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJLbm9yYSIsInN1YiI6Imh0dHA6Ly9yZGZoLmNoL3VzZXJzLzlYQkNyRFYzU1JhN2tTMVd3eW5CNFEiLCJhdWQiOlsiS25vcmEiLCJTaXBpIl0sImV4cCI6NDY5NTE5MzYwNSwiaWF0IjoxNTQxNTkzNjA1LCJqdGkiOiJsZmdreWJqRlM5Q1NiV19NeVA0SGV3IiwiZm9vIjoiYmFyIn0.qPMJjv8tVOM7KKDxR4Dmdz_kB0FzTOtJBYHSp62Dilk" - val runZio = ZIO.serviceWithZIO[JwtService](_.extractUserIriFromToken(invalidToken)) - UnsafeZioRun.runOrThrow(runZio) should be(None) - } - - "not decode a token with an invalid user IRI" in { - val tokenWithInvalidSubject = - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJLbm9yYSIsInN1YiI6ImludmFsaWQiLCJhdWQiOlsiS25vcmEiLCJTaXBpIl0sImV4cCI6NDY5NTE5MzYwNSwiaWF0IjoxNTQxNTkzNjA1LCJqdGkiOiJsZmdreWJqRlM5Q1NiV19NeVA0SGV3IiwiZm9vIjoiYmFyIn0.9uPJahn_KtCCZrnr5e4OHbEh3DsSIiX_b3ZB6H3ptY4" - val runZio = ZIO.serviceWithZIO[JwtService](_.extractUserIriFromToken(tokenWithInvalidSubject)) - UnsafeZioRun.runOrThrow(runZio) should be(None) - } - - "not decode a token with missing required content" in { - val tokenWithMissingExp = - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJLbm9yYSIsInN1YiI6Imh0dHA6Ly9yZGZoLmNoL3VzZXJzLzlYQkNyRFYzU1JhN2tTMVd3eW5CNFEiLCJhdWQiOlsiS25vcmEiLCJTaXBpIl0sImlhdCI6MTU0MTU5MzYwNSwianRpIjoibGZna3liakZTOUNTYldfTXlQNEhldyIsImZvbyI6ImJhciJ9.-ugb7OCoQq1JvBSso2HlfqVRBWM97b8burJTp3J9WeQ" - val runZio = ZIO.serviceWithZIO[JwtService](_.extractUserIriFromToken(tokenWithMissingExp)) - UnsafeZioRun.runOrThrow(runZio) should be(None) - } - - "not decode an expired token" in { - val expiredToken = - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJLbm9yYSIsInN1YiI6Imh0dHA6Ly9yZGZoLmNoL3VzZXJzLzlYQkNyRFYzU1JhN2tTMVd3eW5CNFEiLCJhdWQiOlsiS25vcmEiLCJTaXBpIl0sImV4cCI6MTU0MTU5MzYwNiwiaWF0IjoxNTQxNTkzNjA1LCJqdGkiOiJsZmdreWJqRlM5Q1NiV19NeVA0SGV3IiwiZm9vIjoiYmFyIn0.gahFI5-xg_gKLAwHRkKNbF0p_PzBTWC2m36vAYJPkz4" - val runZio = ZIO.serviceWithZIO[JwtService](_.extractUserIriFromToken(expiredToken)) - UnsafeZioRun.runOrThrow(runZio) should be(None) - } - - "reject a token with a different issuer than the one who created the token" in { - val tokenWithDifferentIssuer = - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJibGFibGEiLCJzdWIiOiJodHRwOi8vcmRmaC5jaC91c2Vycy85WEJDckRWM1NSYTdrUzFXd3luQjRRIiwiYXVkIjpbIktub3JhIiwiU2lwaSJdLCJleHAiOjQ4MDE0NjkyNzgsImlhdCI6MTY0Nzg2OTI3OCwianRpIjoiYU9HRExCYnJUbi1iQUIwVXZzTDZMZyIsImZvbyI6ImJhciJ9.ewFp0uXjPkn6GSGvDcph1MZRPpip669IrpXQ8Qv3Vpw" - val runZio = ZIO.serviceWithZIO[JwtService](_.validateToken(tokenWithDifferentIssuer)) - UnsafeZioRun.runOrThrow(runZio) should be(false) - } - } -} diff --git a/integration/src/test/scala/org/knora/webapi/store/iiif/impl/SipiServiceMock.scala b/integration/src/test/scala/org/knora/webapi/store/iiif/impl/SipiServiceMock.scala index 1336fda300..2b7efd57c6 100644 --- a/integration/src/test/scala/org/knora/webapi/store/iiif/impl/SipiServiceMock.scala +++ b/integration/src/test/scala/org/knora/webapi/store/iiif/impl/SipiServiceMock.scala @@ -72,10 +72,5 @@ case class SipiServiceMock() extends SipiService { } object SipiServiceMock { - - val layer: ZLayer[Any, Nothing, SipiService] = - ZLayer - .succeed(SipiServiceMock()) - .tap(_ => ZIO.logInfo(">>> Mock Sipi IIIF Service Initialized <<<")) - + val layer: ULayer[SipiServiceMock] = ZLayer.succeed(SipiServiceMock()) } diff --git a/webapi/src/main/scala/org/knora/webapi/routing/JwtService.scala b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/JwtService.scala similarity index 70% rename from webapi/src/main/scala/org/knora/webapi/routing/JwtService.scala rename to webapi/src/main/scala/org/knora/webapi/slice/infrastructure/JwtService.scala index d5910b2084..7df9049147 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/JwtService.scala +++ b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/JwtService.scala @@ -18,7 +18,6 @@ import zio.Duration import zio.Random import zio.Task import zio.UIO -import zio.URLayer import zio.ZIO import zio.ZLayer import zio.durationInt @@ -31,8 +30,19 @@ import dsp.valueobjects.UuidUtil import org.knora.webapi.IRI import org.knora.webapi.config.DspIngestConfig import org.knora.webapi.config.JwtConfig +import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionADM import org.knora.webapi.routing.Authenticator.AUTHENTICATION_INVALIDATION_CACHE_NAME +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.admin.domain.model.Permission.Administrative +import org.knora.webapi.slice.admin.domain.model.Permission.Administrative.ProjectAdminAll +import org.knora.webapi.slice.admin.domain.model.Permission.Administrative.ProjectResourceCreateAll +import org.knora.webapi.slice.admin.domain.model.Permission.Administrative.ProjectResourceCreateRestricted import org.knora.webapi.slice.admin.domain.model.User +import org.knora.webapi.slice.admin.domain.service.KnoraProjectService +import org.knora.webapi.slice.infrastructure.Scope +import org.knora.webapi.slice.infrastructure.ScopeValue +import org.knora.webapi.slice.infrastructure.ScopeValue.Write import org.knora.webapi.util.cache.CacheUtil case class Jwt(jwtString: String, expiration: Long) @@ -50,6 +60,7 @@ trait JwtService { * @return a [[String]] containing the JWT. */ def createJwt(user: User, content: Map[String, JsValue] = Map.empty): UIO[Jwt] + def createJwtForDspIngest(): UIO[Jwt] /** @@ -74,6 +85,7 @@ trait JwtService { final case class JwtServiceLive( private val jwtConfig: JwtConfig, private val dspIngestConfig: DspIngestConfig, + private val knoraProjectService: KnoraProjectService, ) extends JwtService { private val algorithm: JwtAlgorithm = JwtAlgorithm.HS256 private val header: String = """{"typ":"JWT","alg":"HS256"}""" @@ -82,21 +94,47 @@ final case class JwtServiceLive( override def createJwt(user: User, content: Map[String, JsValue] = Map.empty): UIO[Jwt] = { val audience = if (user.isSystemAdmin) { Set("Knora", "Sipi", dspIngestConfig.audience) } else { Set("Knora", "Sipi") } - createJwtToken(jwtConfig.issuerAsString(), user.id, audience, Some(JsObject(content))) + calculateScope(user) + .flatMap(scope => createJwtToken(jwtConfig.issuerAsString(), user.id, audience, scope, Some(JsObject(content)))) } + private def calculateScope(user: User) = + if (user.isSystemAdmin || user.isSystemUser) { ZIO.succeed(Scope.admin) } + else { mapUserPermissionsToScope(user) } + + private def mapUserPermissionsToScope(user: User): UIO[Scope] = + ZIO + .foreach(user.permissions.administrativePermissionsPerProject.toSeq) { case (prjIri, permission) => + knoraProjectService + .findById(ProjectIri.unsafeFrom(prjIri)) + .orDie + .map(_.map(prj => mapPermissionToScope(permission, prj.shortcode)).getOrElse(Set.empty)) + } + .map(scopeValues => Scope.from(scopeValues.flatten)) + + private def mapPermissionToScope(permission: Set[PermissionADM], shortcode: Shortcode): Set[ScopeValue] = + permission + .map(_.name) + .flatMap(Administrative.fromToken) + .flatMap { + case ProjectResourceCreateAll | ProjectResourceCreateRestricted | ProjectAdminAll => Some(Write(shortcode)) + case _ => None + } + override def createJwtForDspIngest(): UIO[Jwt] = createJwtToken( jwtConfig.issuerAsString(), jwtConfig.issuerAsString(), Set(dspIngestConfig.audience), + Scope.admin, expiration = Some(10.minutes), ) private def createJwtToken( - issuer: String, - subject: String, - audience: Set[String], + issuer: IRI, + subject: IRI, + audience: Set[IRI], + scope: Scope, content: Option[JsObject] = None, expiration: Option[Duration] = None, ) = @@ -112,8 +150,8 @@ final case class JwtServiceLive( issuedAt = Some(now.getEpochSecond), expiration = Some(exp.getEpochSecond), jwtId = Some(UuidUtil.base64Encode(uuid)), - ).toJson - } yield Jwt(JwtZIOJson.encode(header, claim, jwtConfig.secret, algorithm), exp.getEpochSecond) + ) + ("scope", scope.toScopeString) + } yield Jwt(JwtZIOJson.encode(header, claim.toJson, jwtConfig.secret, algorithm), exp.getEpochSecond) /** * Validates a JWT, taking the invalidation cache into account. The invalidation cache holds invalidated @@ -172,7 +210,7 @@ final case class JwtServiceLive( None } } + object JwtServiceLive { - val layer: URLayer[DspIngestConfig & JwtConfig, JwtServiceLive] = - ZLayer.fromFunction(JwtServiceLive.apply _) + val layer = ZLayer.derive[JwtServiceLive] } diff --git a/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/Scope.scala b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/Scope.scala new file mode 100644 index 0000000000..5535c32476 --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/infrastructure/Scope.scala @@ -0,0 +1,49 @@ +/* + * 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.infrastructure + +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode + +final case class Scope(values: Set[ScopeValue]) { self => + def toScopeString: String = self.values.map(_.toScopeString).mkString(" ") + def +(addThis: ScopeValue): Scope = Scope(values.foldLeft(Set(addThis)) { case (s, old) => s.flatMap(_.merge(old)) }) +} + +object Scope { + val empty: Scope = Scope(Set.empty) + val admin: Scope = Scope(Set(ScopeValue.Admin)) + + def from(scopeValues: Seq[ScopeValue]): Scope = scopeValues.foldLeft(Scope.empty)(_ + _) +} + +sealed trait ScopeValue { + def toScopeString: String + final def merge(other: ScopeValue): Set[ScopeValue] = ScopeValue.merge(this, other) +} + +object ScopeValue { + final case class Read(project: Shortcode) extends ScopeValue { + override def toScopeString: String = s"read:project:${project.value}" + } + + final case class Write(project: Shortcode) extends ScopeValue { + override def toScopeString: String = s"write:project:${project.value}" + } + + final case object Admin extends ScopeValue { + override def toScopeString: String = "admin" + } + + def merge(one: ScopeValue, two: ScopeValue): Set[ScopeValue] = + (one, two) match { + case (Admin, _) | (_, Admin) => Set(Admin) + case (Write(p1), Write(p2)) if p1 == p2 => Set(Write(p1)) + case (Write(p1), Read(p2)) if p1 == p2 => Set(Write(p1)) + case (Read(p1), Write(p2)) if p1 == p2 => Set(Write(p1)) + case (Read(p1), Read(p2)) if p1 == p2 => Set(Read(p1)) + case (a, b) => Set(a, b) + } +} diff --git a/webapi/src/main/scala/org/knora/webapi/store/iiif/impl/SipiServiceLive.scala b/webapi/src/main/scala/org/knora/webapi/store/iiif/impl/SipiServiceLive.scala index d04f28f289..5fd8a45b64 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/iiif/impl/SipiServiceLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/iiif/impl/SipiServiceLive.scala @@ -412,7 +412,7 @@ object SipiServiceLive { private def release(httpClient: CloseableHttpClient): UIO[Unit] = ZIO.attemptBlocking(httpClient.close()).logError.ignore <* ZIO.logInfo(">>> Release Sipi IIIF Service <<<") - val layer: URLayer[AppConfig & DspIngestClient & JwtService, SipiService] = + val layer: URLayer[AppConfig & DspIngestClient & JwtService, SipiServiceLive] = ZLayer.scoped { for { config <- ZIO.serviceWith[AppConfig](_.sipi) diff --git a/webapi/src/test/scala/org/knora/webapi/slice/infrastructure/JwtServiceLiveSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/infrastructure/JwtServiceLiveSpec.scala new file mode 100644 index 0000000000..a44227e5e7 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/infrastructure/JwtServiceLiveSpec.scala @@ -0,0 +1,239 @@ +/* + * 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.infrastructure + +import net.sf.ehcache.CacheManager +import pdi.jwt.JwtAlgorithm +import pdi.jwt.JwtClaim +import pdi.jwt.JwtHeader +import pdi.jwt.JwtZIOJson +import spray.json.JsString +import zio.IO +import zio.NonEmptyChunk +import zio.Scope +import zio.ZIO +import zio.ZLayer +import zio.json.DecoderOps +import zio.json.DeriveJsonDecoder +import zio.json.JsonDecoder +import zio.test.Gen +import zio.test.Spec +import zio.test.TestAspect +import zio.test.TestEnvironment +import zio.test.ZIOSpecDefault +import zio.test.assertTrue +import zio.test.check + +import java.time.Duration +import java.time.Instant +import java.util.UUID + +import dsp.valueobjects.UuidUtil +import org.knora.webapi.config.DspIngestConfig +import org.knora.webapi.config.JwtConfig +import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionADM +import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionsDataADM +import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2 +import org.knora.webapi.routing.Authenticator.AUTHENTICATION_INVALIDATION_CACHE_NAME +import org.knora.webapi.routing.JwtService +import org.knora.webapi.routing.JwtServiceLive +import org.knora.webapi.slice.admin.domain.model.KnoraProject +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Description +import org.knora.webapi.slice.admin.domain.model.KnoraProject.ProjectIri +import org.knora.webapi.slice.admin.domain.model.KnoraProject.SelfJoin +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortname +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Status +import org.knora.webapi.slice.admin.domain.model.Permission.Administrative +import org.knora.webapi.slice.admin.domain.model.RestrictedView +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.repo.KnoraProjectRepoInMemory +import org.knora.webapi.slice.admin.domain.service.KnoraGroupRepo +import org.knora.webapi.slice.admin.domain.service.KnoraProjectRepo +import org.knora.webapi.slice.admin.domain.service.KnoraProjectService +import org.knora.webapi.store.cache.CacheService + +final case class ScopeJs(scope: String) +object ScopeJs { + implicit val decoder: JsonDecoder[ScopeJs] = DeriveJsonDecoder.gen[ScopeJs] +} + +object JwtServiceLiveSpec extends ZIOSpecDefault { + + private val JwtService = ZIO.serviceWithZIO[JwtService] + + private val user: User = + User( + id = UserIri.makeNew.value, + username = "testuser", + email = "test@example.com", + givenName = "given", + familyName = "family", + status = true, + lang = "en", + password = None, + groups = Seq.empty, + projects = Seq.empty, + permissions = PermissionsDataADM(), + ) + + private val systemAdminPermissions = PermissionsDataADM( + groupsPerProject = + Map(KnoraProjectRepo.builtIn.SystemProject.id.value -> Seq(KnoraGroupRepo.builtIn.SystemAdmin.id.value)), + ) + + private val shortcode = Shortcode.unsafeFrom("0001") + private val projectIri = ProjectIri.makeNew + private val project1 = KnoraProject( + projectIri, + Shortname.unsafeFrom("project1"), + shortcode, + None, + NonEmptyChunk(Description.unsafeFrom(StringLiteralV2.unsafeFrom("foo", None))), + List.empty, + None, + Status.Active, + SelfJoin.CannotJoin, + RestrictedView.default, + ) + + private val projectAdminProject1Permissions = PermissionsDataADM( + administrativePermissionsPerProject = + Map(project1.id.value -> Set(PermissionADM.from(Administrative.ProjectAdminAll))), + ) + + private val issuerStr = "https://dsp-api" + + private val dspIngestConfigLayer = + ZLayer.succeed(DspIngestConfig("https://dps-ingest", "https://dsp-ingest/audience")) + + private val jwtConfigLayer = + ZLayer.succeed(JwtConfig("n76lPIwWKNeTodFfZPPPYFn7V24R14aE63A+XgS8MMA=", Duration.ofSeconds(10), Some(issuerStr))) + + private def decodeToken(token: String): ZIO[JwtConfig, Nothing, (JwtHeader, JwtClaim, String)] = + ZIO.serviceWithZIO[JwtConfig] { jwtConfig => + ZIO.fromTry(JwtZIOJson.decodeAll(token, jwtConfig.secret, Seq(JwtAlgorithm.HS256))).orDie + } + + private def getClaim[A](token: String, extract: JwtClaim => A) = + decodeToken(token).map { case (_, claims, _) => extract(claims) } + + private def getClaimZIO[A](token: String, extract: JwtClaim => IO[String, A]) = + decodeToken(token).flatMap { case (_, claims, _) => extract(claims).mapError(new Exception(_)) } + + private def getScopeClaimValue(token: String) = + getClaimZIO(token, c => ZIO.fromEither(c.content.fromJson[ScopeJs](ScopeJs.decoder))).map(_.scope) + + private def initCache = ZIO.succeed { + val cacheManager = CacheManager.getInstance() + cacheManager.addCacheIfAbsent(AUTHENTICATION_INVALIDATION_CACHE_NAME) + cacheManager.clearAll() + } + + val spec: Spec[TestEnvironment with Scope, Any] = suite("JwtService")( + test("create a token") { + for { + token <- JwtService(_.createJwt(user, Map("foo" -> JsString("bar")))) + userIri <- getClaim(token.jwtString, _.subject) + audience <- getClaim(token.jwtString, _.audience.getOrElse(Set.empty)) + scope <- getScopeClaimValue(token.jwtString) + } yield assertTrue( + userIri.contains(user.id), + audience == Set("Knora", "Sipi"), + scope == "", + ) + }, + test("create a token with admin scope for system admins") { + for { + token <- JwtService(_.createJwt(user.copy(permissions = systemAdminPermissions))) + scope <- getScopeClaimValue(token.jwtString) + } yield assertTrue(scope == "admin") + }, + test("create a token for dspIngest") { + for { + token <- JwtService(_.createJwtForDspIngest()) + scope <- getScopeClaimValue(token.jwtString) + } yield assertTrue(scope == "admin") + }, + test("create a token with admin scope for project admins") { + for { + _ <- ZIO.serviceWithZIO[KnoraProjectRepo](_.save(project1)) + token <- JwtService(_.createJwt(user.copy(permissions = projectAdminProject1Permissions))) + scope <- getScopeClaimValue(token.jwtString) + } yield assertTrue(scope == "write:project:0001") + }, + test("create a token with dsp-ingest audience for sys admins") { + for { + token <- JwtService(_.createJwt(user.copy(permissions = systemAdminPermissions))) + userIriByService <- JwtService(_.extractUserIriFromToken(token.jwtString)) + userIri <- getClaim(token.jwtString, _.subject) + audience <- getClaim(token.jwtString, _.audience.getOrElse(Set.empty)) + } yield assertTrue( + userIriByService == userIri, + userIri.contains(user.id), + audience.contains("https://dsp-ingest/audience"), + ) + }, + test("create a token for dsp-ingest") { + for { + token <- JwtService(_.createJwtForDspIngest()) + userIri <- getClaim(token.jwtString, _.subject) + audience <- getClaim(token.jwtString, _.audience.getOrElse(Set.empty)) + scope <- getScopeClaimValue(token.jwtString) + } yield assertTrue( + userIri.contains(issuerStr), + audience.contains("https://dsp-ingest/audience"), + scope == "admin", + ) + }, + test("validate a self issued token") { + for { + token <- JwtService(_.createJwt(user)) + isValid <- JwtService(_.validateToken(token.jwtString)) + } yield assertTrue(isValid) + }, + test("fail to validate an invalid token") { + def createClaim( + issuer: Option[String] = Some(issuerStr), + subject: Option[String] = Some(UserIri.makeNew.value), + audience: Option[Set[String]] = Some(Set("Knora", "Sipi")), + issuedAt: Option[Long] = Some(Instant.now.getEpochSecond), + expiration: Option[Long] = Some(Instant.now.plusSeconds(10).getEpochSecond), + jwtId: Option[String] = Some(UuidUtil.base64Encode(UUID.randomUUID())), + ) = JwtClaim( + issuer = issuer, + subject = subject, + audience = audience, + issuedAt = issuedAt, + expiration = expiration, + jwtId = jwtId, + ) + val issuerMissing = createClaim(issuer = None) + val invalidSubject = createClaim(subject = Some("is-invalid")) + val missingAudience = createClaim(audience = None) + val missingIat = createClaim(issuedAt = None) + val expired = createClaim(expiration = Some(Instant.now.minusSeconds(10).getEpochSecond)) + val missingJwtId = createClaim(jwtId = None) + check( + Gen.fromIterable(Seq(issuerMissing, invalidSubject, missingAudience, missingIat, expired, missingJwtId)), + ) { claim => + for { + secret <- ZIO.serviceWith[JwtConfig](_.secret) + token = JwtZIOJson.encode("""{"typ":"JWT","alg":"HS256"}""", claim.toJson, secret, JwtAlgorithm.HS256) + isValid <- JwtService(_.validateToken(token)) + } yield assertTrue(!isValid) + } + }, + ).provide( + CacheService.layer, + JwtServiceLive.layer, + KnoraProjectRepoInMemory.layer, + KnoraProjectService.layer, + dspIngestConfigLayer, + jwtConfigLayer, + ) @@ TestAspect.withLiveEnvironment @@ TestAspect.beforeAll(initCache) +} diff --git a/webapi/src/test/scala/org/knora/webapi/slice/infrastructure/ScopeSpec.scala b/webapi/src/test/scala/org/knora/webapi/slice/infrastructure/ScopeSpec.scala new file mode 100644 index 0000000000..6145c215a2 --- /dev/null +++ b/webapi/src/test/scala/org/knora/webapi/slice/infrastructure/ScopeSpec.scala @@ -0,0 +1,125 @@ +/* + * 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.infrastructure + +import zio.test.Gen +import zio.test.ZIOSpecDefault +import zio.test.assertTrue +import zio.test.check + +import org.knora.webapi.slice.admin.domain.model.KnoraProject.Shortcode +import org.knora.webapi.slice.infrastructure.ScopeValue.Admin + +object ScopeSpec extends ZIOSpecDefault { + private val prj1 = Shortcode.unsafeFrom("0001") + private val readScopeValue1 = ScopeValue.Read(prj1) + private val writeScopeValue1 = ScopeValue.Write(prj1) + + private val prj2 = Shortcode.unsafeFrom("0002") + private val readScopeValue2 = ScopeValue.Read(prj2) + private val writeScopeValue2 = ScopeValue.Write(prj2) + + private val scopeValueSuite = suite("ScopeValue")( + test("merging any ScopeValue with Admin should return Admin") { + val adminScopeValue = Admin + val expected: Set[ScopeValue] = Set(Admin) + check(Gen.fromIterable(Seq(Admin, ScopeValue.Read(prj1), ScopeValue.Write(prj2)))) { (other: ScopeValue) => + assertTrue( + other.merge(adminScopeValue) == expected, + adminScopeValue.merge(other) == expected, + ) + } + }, + test("merging Read with Write for the same project should return Write") { + val expected: Set[ScopeValue] = Set(writeScopeValue1) + assertTrue( + readScopeValue1.merge(writeScopeValue1) == expected, + writeScopeValue1.merge(readScopeValue1) == expected, + ) + }, + test("merging Read with Write for different projects should return both values") { + val expected: Set[ScopeValue] = Set(readScopeValue1, writeScopeValue2) + assertTrue( + readScopeValue1.merge(writeScopeValue2) == expected, + writeScopeValue2.merge(readScopeValue1) == expected, + ) + }, + test("merging two Read values for the same project should return one Read value") { + val expected: Set[ScopeValue] = Set(readScopeValue1) + assertTrue( + readScopeValue1.merge(readScopeValue1) == expected, + readScopeValue1.merge(readScopeValue1) == expected, + ) + }, + test("merging two Write values for the same project should return one Write value") { + val expected: Set[ScopeValue] = Set(writeScopeValue1) + assertTrue( + writeScopeValue1.merge(writeScopeValue1) == expected, + writeScopeValue1.merge(writeScopeValue1) == expected, + ) + }, + test("merging two different Read values should return both values") { + val expected: Set[ScopeValue] = Set(readScopeValue1, readScopeValue2) + assertTrue( + readScopeValue1.merge(readScopeValue2) == expected, + readScopeValue2.merge(readScopeValue1) == expected, + ) + }, + test("merging two different Write values should return both values") { + val expected: Set[ScopeValue] = Set(writeScopeValue1, writeScopeValue2) + assertTrue( + writeScopeValue1.merge(writeScopeValue2) == expected, + writeScopeValue2.merge(writeScopeValue1) == expected, + ) + }, + ) + + private val scopeSuite = suite("Scope")( + test("adding a value to an empty scope") { + val scope = Scope.empty + check(Gen.fromIterable(Seq(Admin, readScopeValue1, writeScopeValue1, readScopeValue2, writeScopeValue2))) { + (value: ScopeValue) => assertTrue(scope + value == Scope(Set(value))) + } + }, + test("adding admin any scope results in admin") { + check( + Gen.fromIterable( + Seq(Scope(Set(readScopeValue1, writeScopeValue1)), Scope(Set(readScopeValue1, writeScopeValue2))), + ), + )((scope: Scope) => assertTrue(scope + Admin == Scope.admin)) + }, + test("adding an already present scope does nothing") { + val scope = Scope(Set(readScopeValue1)) + assertTrue(scope + readScopeValue1 == scope) + }, + test("adding a write scope to a read scope merges") { + val scope = Scope(Set(readScopeValue1, readScopeValue2)) + assertTrue(scope + writeScopeValue1 == Scope(Set(writeScopeValue1, readScopeValue2))) + }, + test("adding a read scope to a write scope merges") { + val scope = Scope(Set(writeScopeValue1, readScopeValue2)) + assertTrue(scope + readScopeValue1 == Scope(Set(writeScopeValue1, readScopeValue2))) + }, + test("rendering a read scope to string is successful") { + val scope = Scope(Set(readScopeValue1)) + assertTrue(scope.toScopeString == s"read:project:0001") + }, + test("rendering a write scope to string is successful") { + val scope = Scope(Set(writeScopeValue2)) + assertTrue(scope.toScopeString == s"write:project:0002") + }, + test("rendering an admin scope to string is successful") { + val scope = Scope.admin + assertTrue(scope.toScopeString == s"admin") + }, + test("rendering a combined scope to string is successful") { + val scope = Scope(Set(readScopeValue1, writeScopeValue2)) + assertTrue(scope.toScopeString == s"read:project:0001 write:project:0002") + }, + ) + + val spec = suite("ScopeSpec")(scopeValueSuite, scopeSuite) +}