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 afa6d7c53c..4601070895 100644 --- a/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala +++ b/integration/src/test/scala/org/knora/webapi/core/LayersTest.scala @@ -48,6 +48,7 @@ import org.knora.webapi.slice.ontology.api.service.RestCardinalityService import org.knora.webapi.slice.ontology.api.service.RestCardinalityServiceLive import org.knora.webapi.slice.ontology.domain.service.CardinalityService import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.ontology.domain.service.OntologyServiceLive import org.knora.webapi.slice.ontology.repo.service.OntologyCache import org.knora.webapi.slice.ontology.repo.service.OntologyCacheLive import org.knora.webapi.slice.ontology.repo.service.OntologyRepoLive @@ -168,6 +169,7 @@ object LayersTest { OntologyHelpersLive.layer, OntologyRepoLive.layer, OntologyResponderV2Live.layer, + OntologyServiceLive.layer, PermissionUtilADMLive.layer, PermissionsResponder.layer, PredicateObjectMapper.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 63f0fa2d40..f88e83e982 100644 --- a/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala +++ b/webapi/src/main/scala/org/knora/webapi/core/LayersLive.scala @@ -46,6 +46,7 @@ import org.knora.webapi.slice.ontology.api.service.RestCardinalityService import org.knora.webapi.slice.ontology.api.service.RestCardinalityServiceLive import org.knora.webapi.slice.ontology.domain.service.CardinalityService import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.ontology.domain.service.OntologyServiceLive import org.knora.webapi.slice.ontology.repo.service.OntologyCache import org.knora.webapi.slice.ontology.repo.service.OntologyCacheLive import org.knora.webapi.slice.ontology.repo.service.OntologyRepoLive @@ -154,6 +155,7 @@ object LayersLive { OntologyHelpersLive.layer, OntologyRepoLive.layer, OntologyResponderV2Live.layer, + OntologyServiceLive.layer, PermissionUtilADMLive.layer, PermissionsResponder.layer, PredicateObjectMapper.layer, diff --git a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala index 8ccc2fb646..addece5e80 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala @@ -272,6 +272,9 @@ object StringFormatter { StringFormatter.initForTest() StringFormatter.getGeneralInstance } + + def isKnoraOntologyIri(iri: SmartIri): Boolean = + iri.isKnoraApiV2DefinitionIri && OntologyConstants.InternalOntologyLabels.contains(iri.getOntologyName) } /** @@ -985,16 +988,10 @@ class StringFormatter private ( } override def getOntologyName: String = - iriInfo.ontologyName match { - case Some(name) => name - case None => throw DataConversionException(s"Expected a Knora ontology IRI: $iri") - } + iriInfo.ontologyName.getOrElse(throw DataConversionException(s"Expected a Knora ontology IRI: $iri")) override def getEntityName: String = - iriInfo.entityName match { - case Some(name) => name - case None => throw DataConversionException(s"Expected a Knora entity IRI: $iri") - } + iriInfo.entityName.getOrElse(throw DataConversionException(s"Expected a Knora entity IRI: $iri")) override def getOntologySchema: Option[OntologySchema] = iriInfo.ontologySchema @@ -1626,16 +1623,6 @@ class StringFormatter private ( s"$mappingIri/elements/$knoraMappingElementUuid" } - /** - * Creates an IRI used as a lock for the creation of mappings inside a given project. - * This method will always return the same IRI for the given project IRI. - * - * @param projectIri the IRI of the project the mapping will belong to. - * @return an IRI used as a lock for the creation of mappings inside a given project. - */ - def createMappingLockIriForProject(projectIri: IRI): IRI = - s"$projectIri/mappings" - /** * Validates a custom value IRI, throwing [[BadRequestException]] if the IRI is not valid. * @@ -1660,18 +1647,10 @@ class StringFormatter private ( customValueIri } - def isKnoraOntologyIri(iri: SmartIri): Boolean = - iri.isKnoraApiV2DefinitionIri && OntologyConstants.InternalOntologyLabels.contains(iri.getOntologyName) - def unescapeStringLiteralSeq(stringLiteralSeq: StringLiteralSequenceV2): StringLiteralSequenceV2 = StringLiteralSequenceV2( stringLiterals = stringLiteralSeq.stringLiterals.map(stringLiteral => StringLiteralV2.from(Iri.fromSparqlEncodedString(stringLiteral.value), stringLiteral.language), ), ) - def unescapeOptionalString(optionalString: Option[String]): Option[String] = - optionalString match { - case Some(s: String) => Some(Iri.fromSparqlEncodedString(s)) - case None => None - } } diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala index c06b2cddc7..6b2495d5db 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/ResourcesResponderV2.scala @@ -48,6 +48,7 @@ import org.knora.webapi.slice.admin.domain.model.User import org.knora.webapi.slice.admin.domain.service.KnoraProjectService import org.knora.webapi.slice.admin.domain.service.ProjectService import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.ontology.domain.service.OntologyService import org.knora.webapi.store.iiif.errors.SipiException import org.knora.webapi.store.triplestore.api.TriplestoreService import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Construct @@ -89,6 +90,7 @@ final case class ResourcesResponderV2( searchResponderV2: SearchResponderV2, ontologyRepo: OntologyRepo, permissionsResponder: PermissionsResponder, + ontologyService: OntologyService, )(implicit val stringFormatter: StringFormatter) extends MessageHandler with LazyLogging @@ -107,6 +109,7 @@ final case class ResourcesResponderV2( this, ontologyRepo, permissionsResponder: PermissionsResponder, + ontologyService, ) override def isResponsibleFor(message: ResponderRequest): Boolean = @@ -2018,7 +2021,8 @@ object ResourcesResponderV2 { or <- ZIO.service[OntologyRepo] sf <- ZIO.service[StringFormatter] pr <- ZIO.service[PermissionsResponder] - handler <- mr.subscribe(ResourcesResponderV2(config, iriS, mr, ts, cu, su, ru, pu, kps, sr, or, pr)(sf)) + os <- ZIO.service[OntologyService] + handler <- mr.subscribe(ResourcesResponderV2(config, iriS, mr, ts, cu, su, ru, pu, kps, sr, or, pr, os)(sf)) } yield handler } } diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/StandoffResponderV2.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/StandoffResponderV2.scala index a1a2bdb9a5..b901870fa8 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/StandoffResponderV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/StandoffResponderV2.scala @@ -508,10 +508,7 @@ final case class StandoffResponderV2( result <- IriLocker.runWithIriLock( apiRequestID, - stringFormatter - .createMappingLockIriForProject( - projectIri.toString, - ), // use a special project specific IRI to lock the creation of mappings for the given project + s"${projectIri.toString}/mappings", createMappingAndCheck( xml = xml, label = label, diff --git a/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala b/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala index 1d9c005ffd..6776692d53 100644 --- a/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala +++ b/webapi/src/main/scala/org/knora/webapi/responders/v2/resources/CreateResourceV2Handler.scala @@ -42,6 +42,8 @@ import org.knora.webapi.slice.ontology.domain.model.Cardinality.AtLeastOne import org.knora.webapi.slice.ontology.domain.model.Cardinality.ExactlyOne import org.knora.webapi.slice.ontology.domain.model.Cardinality.ZeroOrOne import org.knora.webapi.slice.ontology.domain.service.OntologyRepo +import org.knora.webapi.slice.ontology.domain.service.OntologyService +import org.knora.webapi.slice.ontology.domain.service.OntologyServiceLive import org.knora.webapi.store.triplestore.api.TriplestoreService import org.knora.webapi.store.triplestore.api.TriplestoreService.Queries.Update import org.knora.webapi.util.ZioHelper @@ -59,6 +61,7 @@ final case class CreateResourceV2Handler( getResources: GetResources, ontologyRepo: OntologyRepo, permissionsResponder: PermissionsResponder, + ontologyService: OntologyService, )(implicit val stringFormatter: StringFormatter) extends LazyLogging { @@ -94,70 +97,16 @@ final case class CreateResourceV2Handler( createResourceRequestV2: CreateResourceRequestV2, ): Task[ReadResourcesSequenceV2] = for { - // Don't allow anonymous users to create resources. - _ <- ZIO.when(createResourceRequestV2.requestingUser.isAnonymousUser) { - ZIO.fail(ForbiddenException("Anonymous users aren't allowed to create resources")) - } - - // Ensure that the project isn't the system project or the shared ontologies project. + _ <- ensureNotAnonymousUser(createResourceRequestV2.requestingUser) + _ <- ensureClassBelongsToProjectOntology(createResourceRequestV2) projectIri = createResourceRequestV2.createResource.projectADM.id - _ <- - ZIO.when( - projectIri == KnoraProjectRepo.builtIn.SystemProject.id.value || projectIri == OntologyConstants.KnoraAdmin.DefaultSharedOntologiesProject, - )(ZIO.fail(BadRequestException(s"Resources cannot be created in project <$projectIri>"))) - - // Ensure that the resource class isn't from a non-shared ontology in another project. - - resourceClassOntologyIri: SmartIri = createResourceRequestV2.createResource.resourceClassIri.getOntologyFromEntity - readOntologyMetadataV2 <- messageRelay - .ask[ReadOntologyMetadataV2]( - OntologyMetadataGetByIriRequestV2( - Set(resourceClassOntologyIri), - createResourceRequestV2.requestingUser, - ), - ) - ontologyMetadata <- ZIO - .fromOption(readOntologyMetadataV2.ontologies.headOption) - .orElseFail(BadRequestException(s"Ontology $resourceClassOntologyIri not found")) - ontologyProjectIri <- - ZIO - .fromOption(ontologyMetadata.projectIri) - .mapBoth( - _ => InconsistentRepositoryDataException(s"Ontology $resourceClassOntologyIri has no project"), - _.toString(), - ) - - _ <- - ZIO.when( - projectIri != ontologyProjectIri && !(ontologyMetadata.ontologyIri.isKnoraBuiltInDefinitionIri || ontologyMetadata.ontologyIri.isKnoraSharedDefinitionIri), - ) { - val msg = - s"Cannot create a resource in project <$projectIri> with resource class <${createResourceRequestV2.createResource.resourceClassIri}>, which is defined in a non-shared ontology in another project" - ZIO.fail(BadRequestException(msg)) - } - - // Check user's PermissionProfile (part of UserADM) to see if the user has the permission to - // create a new resource in the given project. - - internalResourceClassIri: SmartIri = createResourceRequestV2.createResource.resourceClassIri - .toOntologySchema(InternalSchema) - - _ <- ZIO.when( - !createResourceRequestV2.requestingUser.permissions - .hasPermissionFor(ResourceCreateOperation(internalResourceClassIri.toString), projectIri), - ) { - val msg = - s"User ${createResourceRequestV2.requestingUser.username} does not have permission to create a resource of class <${createResourceRequestV2.createResource.resourceClassIri}> in project <$projectIri>" - ZIO.fail(ForbiddenException(msg)) - } + _ <- ensureUserHasPermission(createResourceRequestV2, projectIri) resourceIri <- iriService.checkOrCreateEntityIri( createResourceRequestV2.createResource.resourceIri, stringFormatter.makeRandomResourceIri(createResourceRequestV2.createResource.projectADM.shortcode), ) - - // Do the remaining pre-update checks and the update while holding an update lock on the resource to be created. taskResult <- IriLocker.runWithIriLock( createResourceRequestV2.apiRequestID, resourceIri, @@ -165,6 +114,57 @@ final case class CreateResourceV2Handler( ) } yield taskResult + private def ensureNotAnonymousUser(user: User): Task[Unit] = + ZIO + .when(user.isAnonymousUser)(ZIO.fail(ForbiddenException("Anonymous users aren't allowed to create resources"))) + .ignore + + private def ensureClassBelongsToProjectOntology(createResourceRequestV2: CreateResourceRequestV2): Task[Unit] = for { + projectIri <- ZIO.succeed(createResourceRequestV2.createResource.projectADM.id) + isSystemOrSharedProject = + projectIri == KnoraProjectRepo.builtIn.SystemProject.id.value || + projectIri == OntologyConstants.KnoraAdmin.DefaultSharedOntologiesProject + _ <- ZIO.when(isSystemOrSharedProject)( + ZIO.fail(BadRequestException(s"Resources cannot be created in project <$projectIri>")), + ) + + resourceClassOntologyIri = + createResourceRequestV2.createResource.resourceClassIri.getOntologyFromEntity.toInternalIri + resourceClassProjectIri <- + ontologyService + .getProjectIriForOntologyIri(resourceClassOntologyIri) + .someOrFail(BadRequestException(s"Ontology $resourceClassOntologyIri not found")) + + _ <- + ZIO + .fail( + BadRequestException( + s"Cannot create a resource in project <$projectIri> with resource class <${createResourceRequestV2.createResource.resourceClassIri}>, which is defined in a non-shared ontology in another project", + ), + ) + .unless( + projectIri == resourceClassProjectIri || OntologyServiceLive.isBuiltInOrSharedOntology( + resourceClassOntologyIri, + ), + ) + } yield () + + private def ensureUserHasPermission(createResourceRequestV2: CreateResourceRequestV2, projectIri: String) = for { + internalResourceClassIri <- + ZIO.succeed(createResourceRequestV2.createResource.resourceClassIri.toOntologySchema(InternalSchema)) + _ <- + ZIO + .fail( + ForbiddenException( + s"User ${createResourceRequestV2.requestingUser.username} does not have permission to create a resource of class <${createResourceRequestV2.createResource.resourceClassIri}> in project <$projectIri>", + ), + ) + .unless( + createResourceRequestV2.requestingUser.permissions + .hasPermissionFor(ResourceCreateOperation(internalResourceClassIri.toString), projectIri), + ) + } yield () + private def makeTask( createResourceRequestV2: CreateResourceRequestV2, resourceIri: IRI, diff --git a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilZ.scala b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilZ.scala index e5a58d73bd..8eadfbeb4e 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilZ.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilZ.scala @@ -48,13 +48,11 @@ object RouteUtilZ { def decodeUuid(uuidStr: String): IO[BadRequestException, UUID] = ZIO.attempt(UuidUtil.decode(uuidStr)).orElseFail(BadRequestException(s"Invalid value UUID: $uuidStr")) - def ensureExternalOntologyName(iri: SmartIri): ZIO[StringFormatter, BadRequestException, SmartIri] = - ZIO.serviceWithZIO[StringFormatter] { sf => - if (sf.isKnoraOntologyIri(iri)) { - ZIO.fail(BadRequestException(s"Internal ontology <$iri> cannot be served")) - } else { - ZIO.succeed(iri) - } + def ensureExternalOntologyName(iri: SmartIri): IO[BadRequestException, SmartIri] = + if (StringFormatter.isKnoraOntologyIri(iri)) { + ZIO.fail(BadRequestException(s"Internal ontology <$iri> cannot be served")) + } else { + ZIO.succeed(iri) } def ensureIsKnoraOntologyIri(iri: SmartIri): IO[BadRequestException, SmartIri] = diff --git a/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyService.scala b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyService.scala new file mode 100644 index 0000000000..690cb8c6cd --- /dev/null +++ b/webapi/src/main/scala/org/knora/webapi/slice/ontology/domain/service/OntologyService.scala @@ -0,0 +1,38 @@ +/* + * 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.ontology.domain.service + +import zio.* + +import org.knora.webapi.messages.OntologyConstants +import org.knora.webapi.slice.ontology.repo.service.OntologyCache +import org.knora.webapi.slice.resourceinfo.domain.InternalIri + +trait OntologyService { + def getProjectIriForOntologyIri(ontologyIri: InternalIri): Task[Option[String]] +} + +final case class OntologyServiceLive(ontologyCache: OntologyCache) extends OntologyService { + def getProjectIriForOntologyIri(ontologyIri: InternalIri): Task[Option[String]] = + ontologyCache.getCacheData.map { cacheData => + cacheData.ontologies.map { case (k, v) => k.toString() -> v } + .get(ontologyIri.value) + .flatMap(_.ontologyMetadata.projectIri.map(_.toString())) + } +} + +object OntologyServiceLive { + def isBuiltInOntology(ontologyIri: InternalIri): Boolean = + OntologyConstants.BuiltInOntologyLabels.contains(ontologyIri.value.split("/").last) + + def isSharedOntology(ontologyIri: InternalIri): Boolean = + ontologyIri.value.split("/")(4) == "shared" + + def isBuiltInOrSharedOntology(ontologyIri: InternalIri): Boolean = + isBuiltInOntology(ontologyIri) || isSharedOntology(ontologyIri) + + val layer = ZLayer.derive[OntologyServiceLive] +}