Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(UUID): add IRI validation that allows only to create IRIs using UUID version 4 and 5 (DEV-402) #1990

Merged
merged 16 commits into from Feb 3, 2022
Expand Up @@ -47,6 +47,7 @@ import scala.util.{Failure, Success, Try}
* Provides instances of [[StringFormatter]], as well as string formatting constants.
*/
object StringFormatter {
val UUID_INVALID_ERROR = "Invalid UUID used to create IRI. Only versions 4 and 5 are supported."

// A non-printing delimiter character, Unicode INFORMATION SEPARATOR ONE, that should never occur in data.
val INFORMATION_SEPARATOR_ONE = '\u001F'
Expand Down Expand Up @@ -898,7 +899,7 @@ class StringFormatter private (
* about the IRI being constructed.
* @param errorFun a function that throws an exception. It will be called if the IRI is invalid.
*/
private class SmartIriImpl(iriStr: IRI, parsedIriInfo: Option[SmartIriInfo], errorFun: => Nothing) extends SmartIri {
class SmartIriImpl(iriStr: IRI, parsedIriInfo: Option[SmartIriInfo], errorFun: => Nothing) extends SmartIri {
def this(iriStr: IRI) = this(iriStr, None, throw DataConversionException(s"Couldn't parse IRI: $iriStr"))

def this(iriStr: IRI, parsedIriInfo: Option[SmartIriInfo]) =
Expand Down Expand Up @@ -2961,6 +2962,27 @@ class StringFormatter private (
errorFun
}

/**
* Checks if UUID used to create IRI has correct version (4 and 5 are allowed).
* @param s the string (IRI) to be checked.
* @return TRUE for correct versions, FALSE for incorrect.
*/
def isUUIDVersion4Or5(s: String): Boolean = {
val encodedUUID = s.split("/").last
val decodedUUIDVersion = decodeUuid(encodedUUID).version()

if (decodedUUIDVersion == 4 || decodedUUIDVersion == 5) {
true
} else {
false
}
}

def getUUIDVersion(s: IRI): Int = {
val encodedUUID = s.split("/").last
decodeUuid(encodedUUID).version()
}

/**
* Checks if a string is the right length to be a canonical or Base64-encoded UUID.
*
Expand Down
Expand Up @@ -9,7 +9,7 @@ import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import org.knora.webapi._
import org.knora.webapi.exceptions.{BadRequestException, ForbiddenException}
import org.knora.webapi.feature.FeatureFactoryConfig
import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionProfileType.Restricted
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsADMJsonProtocol
import org.knora.webapi.messages.admin.responder.usersmessages.UserADM
import org.knora.webapi.messages.admin.responder.{KnoraRequestADM, KnoraResponseADM}
Expand All @@ -23,6 +23,23 @@ import java.util.UUID
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// API requests

/**
* Validates permission IRI
* @param iri to be validated.
*/
private case class validatePermissionIRI(iri: IRI) {
mpro7 marked this conversation as resolved.
Show resolved Hide resolved
implicit protected val sf: StringFormatter = StringFormatter.getInstanceForConstantOntologies

if (sf.isKnoraPermissionIriStr(iri) && !sf.isUUIDVersion4Or5(iri)) {
throw BadRequestException(UUID_INVALID_ERROR)
} else {
sf.validatePermissionIri(
iri,
throw BadRequestException(s"Invalid permission IRI ${iri} is given.")
)
}
}

/**
* Represents a payload that asks the Knora API server to create a new
* administrative permission
Expand All @@ -37,19 +54,21 @@ case class CreateAdministrativePermissionAPIRequestADM(
forGroup: IRI,
hasPermissions: Set[PermissionADM]
) extends PermissionsADMJsonProtocol {
implicit protected val sf: StringFormatter = StringFormatter.getInstanceForConstantOntologies

id match {
case Some(iri) => validatePermissionIRI(iri)
case None => None
}

def toJsValue: JsValue = createAdministrativePermissionAPIRequestADMFormat.write(this)

implicit protected val stringFormatter: StringFormatter = StringFormatter.getInstanceForConstantOntologies
stringFormatter.validateAndEscapeProjectIri(forProject, throw BadRequestException(s"Invalid project IRI $forProject"))
stringFormatter.validateOptionalPermissionIri(
id,
throw BadRequestException(s"Invalid permission IRI ${id.get} is given.")
)
sf.validateAndEscapeProjectIri(forProject, throw BadRequestException(s"Invalid project IRI $forProject"))

if (hasPermissions.isEmpty) throw BadRequestException("Permissions needs to be supplied.")

if (!OntologyConstants.KnoraAdmin.BuiltInGroups.contains(forGroup)) {
stringFormatter.validateGroupIri(forGroup, throw BadRequestException(s"Invalid group IRI $forGroup"))
sf.validateGroupIri(forGroup, throw BadRequestException(s"Invalid group IRI $forGroup"))
}

def prepareHasPermissions: CreateAdministrativePermissionAPIRequestADM =
Expand All @@ -76,14 +95,16 @@ case class CreateDefaultObjectAccessPermissionAPIRequestADM(
forProperty: Option[IRI] = None,
hasPermissions: Set[PermissionADM]
) extends PermissionsADMJsonProtocol {
implicit protected val sf: StringFormatter = StringFormatter.getInstanceForConstantOntologies

id match {
case Some(iri) => validatePermissionIRI(iri)
case None => None
}

def toJsValue: JsValue = createDefaultObjectAccessPermissionAPIRequestADMFormat.write(this)

implicit protected val stringFormatter: StringFormatter = StringFormatter.getInstanceForConstantOntologies
stringFormatter.validateAndEscapeProjectIri(forProject, throw BadRequestException(s"Invalid project IRI $forProject"))
stringFormatter.validateOptionalPermissionIri(
id,
throw BadRequestException(s"Invalid permission IRI ${id.get} is given.")
)
sf.validateAndEscapeProjectIri(forProject, throw BadRequestException(s"Invalid project IRI $forProject"))

forGroup match {
case Some(iri: IRI) =>
Expand All @@ -93,7 +114,7 @@ case class CreateDefaultObjectAccessPermissionAPIRequestADM(
throw BadRequestException("Not allowed to supply groupIri and propertyIri together.")
else {
if (!OntologyConstants.KnoraAdmin.BuiltInGroups.contains(iri)) {
stringFormatter.validateOptionalGroupIri(
sf.validateOptionalGroupIri(
forGroup,
throw BadRequestException(s"Invalid group IRI ${forGroup.get}")
)
Expand All @@ -109,15 +130,15 @@ case class CreateDefaultObjectAccessPermissionAPIRequestADM(

forResourceClass match {
case Some(iri) =>
if (!stringFormatter.toSmartIri(iri).isKnoraEntityIri) {
if (!sf.toSmartIri(iri).isKnoraEntityIri) {
throw BadRequestException(s"Invalid resource class IRI: $iri")
}
case None => None
}

forProperty match {
case Some(iri) =>
if (!stringFormatter.toSmartIri(iri).isKnoraEntityIri) {
if (!sf.toSmartIri(iri).isKnoraEntityIri) {
throw BadRequestException(s"Invalid property IRI: $iri")
}
case None => None
Expand Down
Expand Up @@ -7,6 +7,7 @@ package org.knora.webapi.messages.admin.responder.valueObjects

import org.knora.webapi.exceptions.BadRequestException
import org.knora.webapi.messages.StringFormatter
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
import org.knora.webapi.messages.admin.responder.groupsmessages.GroupsErrorMessagesADM._
import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2
import zio.prelude.Validation
Expand All @@ -22,8 +23,12 @@ object GroupIRI { self =>
if (value.isEmpty) {
Validation.fail(BadRequestException(GROUP_IRI_MISSING_ERROR))
} else {
val isUUID: Boolean = sf.couldBeUuid(value.split("/").last)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the function name couldBeUuid() confusing. Either it is or it's not, but could seems weird in this context

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're partially right, because it only checks the length of string, so there is possibility checked string isn't the UUID, but I think to rename it.


if (!sf.isKnoraGroupIriStr(value)) {
Validation.fail(BadRequestException(GROUP_IRI_INVALID_ERROR))
} else if (isUUID && !sf.isUUIDVersion4Or5(value)) {
Validation.fail(BadRequestException(UUID_INVALID_ERROR))
} else {
val validatedValue = Validation(
sf.validateAndEscapeIri(value, throw BadRequestException(GROUP_IRI_INVALID_ERROR))
Expand Down
Expand Up @@ -7,7 +7,9 @@ package org.knora.webapi.messages.admin.responder.valueObjects

import org.knora.webapi.exceptions.BadRequestException
import org.knora.webapi.messages.StringFormatter
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
import org.knora.webapi.messages.admin.responder.listsmessages.ListsErrorMessagesADM._
import org.knora.webapi.messages.admin.responder.valueObjects.GroupIRI.sf
import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2
import zio.prelude.Validation

Expand All @@ -22,8 +24,12 @@ object ListIRI { self =>
if (value.isEmpty) {
Validation.fail(BadRequestException(LIST_NODE_IRI_MISSING_ERROR))
} else {
val isUUID: Boolean = sf.couldBeUuid(value.split("/").last)

if (!sf.isKnoraListIriStr(value)) {
Validation.fail(BadRequestException(LIST_NODE_IRI_INVALID_ERROR))
} else if (isUUID && !sf.isUUIDVersion4Or5(value)) {
Validation.fail(BadRequestException(UUID_INVALID_ERROR))
} else {
val validatedValue = Validation(
sf.validateAndEscapeIri(value, throw BadRequestException(LIST_NODE_IRI_INVALID_ERROR))
Expand Down
Expand Up @@ -7,7 +7,9 @@ package org.knora.webapi.messages.admin.responder.valueObjects

import org.knora.webapi.exceptions.BadRequestException
import org.knora.webapi.messages.StringFormatter
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsErrorMessagesADM._
import org.knora.webapi.messages.admin.responder.valueObjects.GroupIRI.sf
import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2
import zio.prelude.Validation

Expand All @@ -16,14 +18,18 @@ import zio.prelude.Validation
*/
sealed abstract case class ProjectIRI private (value: String)
object ProjectIRI { self =>
val sf: StringFormatter = StringFormatter.getGeneralInstance
private val sf: StringFormatter = StringFormatter.getGeneralInstance

def make(value: String): Validation[Throwable, ProjectIRI] =
if (value.isEmpty) {
Validation.fail(BadRequestException(PROJECT_IRI_MISSING_ERROR))
} else {
val isUUID: Boolean = sf.couldBeUuid(value.split("/").last)

if (!sf.isKnoraProjectIriStr(value)) {
Validation.fail(BadRequestException(PROJECT_IRI_INVALID_ERROR))
} else if (isUUID && !sf.isUUIDVersion4Or5(value)) {
Validation.fail(BadRequestException(UUID_INVALID_ERROR))
} else {
val validatedValue = Validation(
sf.validateAndEscapeProjectIri(value, throw BadRequestException(PROJECT_IRI_INVALID_ERROR))
Expand Down
Expand Up @@ -8,7 +8,9 @@ package org.knora.webapi.messages.admin.responder.valueObjects
import org.knora.webapi.LanguageCodes
import org.knora.webapi.exceptions.BadRequestException
import org.knora.webapi.messages.StringFormatter
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
import org.knora.webapi.messages.admin.responder.usersmessages.UsersErrorMessagesADM._
import org.knora.webapi.messages.admin.responder.valueObjects.GroupIRI.sf
import zio.prelude.Validation

import scala.util.matching.Regex
Expand All @@ -24,8 +26,12 @@ object UserIRI { self =>
if (value.isEmpty) {
Validation.fail(BadRequestException(USER_IRI_MISSING_ERROR))
} else {
val isUUID: Boolean = sf.couldBeUuid(value.split("/").last)

if (!sf.isKnoraUserIriStr(value)) {
Validation.fail(BadRequestException(USER_IRI_INVALID_ERROR))
} else if (isUUID && !sf.isUUIDVersion4Or5(value)) {
Validation.fail(BadRequestException(UUID_INVALID_ERROR))
} else {
val validatedValue = Validation(
sf.validateAndEscapeUserIri(value, throw BadRequestException(USER_IRI_INVALID_ERROR))
Expand Down
Expand Up @@ -15,6 +15,7 @@ import org.knora.webapi._
import org.knora.webapi.exceptions._
import org.knora.webapi.feature.FeatureFactoryConfig
import org.knora.webapi.messages.IriConversions._
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
import org.knora.webapi.messages.admin.responder.projectsmessages.{
ProjectADM,
ProjectGetRequestADM,
Expand All @@ -35,6 +36,14 @@ import org.knora.webapi.util._

import scala.concurrent.{ExecutionContext, Future}

private case class validateUUIDOfResourceIRI(iri: IRI) {
mpro7 marked this conversation as resolved.
Show resolved Hide resolved
implicit protected val sf: StringFormatter = StringFormatter.getInstanceForConstantOntologies

if (sf.couldBeUuid(iri.split("/").last) && !sf.isUUIDVersion4Or5(iri)) {
throw BadRequestException(UUID_INVALID_ERROR)
}
}

/**
* An abstract trait for messages that can be sent to `ResourcesResponderV2`.
*/
Expand Down Expand Up @@ -724,12 +733,14 @@ object CreateResourceRequestV2 extends KnoraJsonLDRequestReaderV2[CreateResource
requestingUser = requestingUser
)).mapTo[ProjectGetResponseADM]

_ = maybeCustomResourceIri.foreach { definedResourceIri =>
if (!definedResourceIri.isKnoraResourceIri) {
throw BadRequestException(s"<$definedResourceIri> is not a Knora resource IRI")
_ = maybeCustomResourceIri.foreach { iri =>
if (!iri.isKnoraResourceIri) {
throw BadRequestException(s"<$iri> is not a Knora resource IRI")
}

if (!definedResourceIri.getProjectCode.contains(projectInfoResponse.project.shortcode)) {
validateUUIDOfResourceIRI(iri.toString)

if (!iri.getProjectCode.contains(projectInfoResponse.project.shortcode)) {
throw BadRequestException(s"The provided resource IRI does not contain the correct project code")
}
}
Expand Down Expand Up @@ -930,6 +941,8 @@ object UpdateResourceMetadataRequestV2 extends KnoraJsonLDRequestReaderV2[Update
throw BadRequestException(s"Invalid resource IRI: <$resourceIri>")
}

validateUUIDOfResourceIRI(resourceIri.toString)

val resourceClassIri: SmartIri = jsonLDDocument.requireTypeAsKnoraTypeIri

val maybeLastModificationDate: Option[Instant] = jsonLDDocument.maybeDatatypeValueInObject(
Expand Down
Expand Up @@ -7,7 +7,6 @@ package org.knora.webapi.messages.v2.responder.valuemessages

import java.time.Instant
import java.util.UUID

import akka.actor.ActorRef
import akka.event.LoggingAdapter
import akka.http.scaladsl.util.FastFuture
Expand All @@ -17,6 +16,7 @@ import org.knora.webapi._
import org.knora.webapi.exceptions.{AssertionException, BadRequestException, NotImplementedException, SipiException}
import org.knora.webapi.feature.FeatureFactoryConfig
import org.knora.webapi.messages.IriConversions._
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM
import org.knora.webapi.messages.admin.responder.usersmessages.UserADM
import org.knora.webapi.messages.store.sipimessages.{GetFileMetadataRequest, GetFileMetadataResponse}
Expand Down Expand Up @@ -500,6 +500,13 @@ object DeleteValueRequestV2 extends KnoraJsonLDRequestReaderV2[DeleteValueReques
throw BadRequestException(s"Invalid value IRI: <$valueIri>")
}

if (
stringFormatter.couldBeUuid(valueIri.toString.split("/").last)
&& !stringFormatter.isUUIDVersion4Or5(valueIri.toString)
) {
throw BadRequestException(UUID_INVALID_ERROR)
}

val valueTypeIri: SmartIri = jsonLDObject.requireTypeAsKnoraApiV2ComplexTypeIri

val deleteComment: Option[String] = jsonLDObject.maybeStringWithValidation(
Expand Down
Expand Up @@ -88,7 +88,7 @@ class GroupsADME2ESpec extends E2ESpec(GroupsADME2ESpec.config) with GroupsADMJs
}

"given a custom Iri" should {
val customGroupIri = "http://rdfh.ch/groups/00FF/3eFYejZEduOCowwXQq5Iqg"
val customGroupIri = "http://rdfh.ch/groups/00FF/gNdJSNYrTDu2lGpPUs94nQ"
"create a group with the provided custom IRI " in {
val createGroupWithCustomIriRequest: String =
s"""{ "id": "$customGroupIri",
Expand Down
Expand Up @@ -37,9 +37,8 @@ class PermissionsADME2ESpec extends E2ESpec(PermissionsADME2ESpec.config) with T

// Collects client test data
private val clientTestDataCollector = new ClientTestDataCollector(settings)
private val customDOAPIri = "http://rdfh.ch/permissions/00FF/eIAywlYBJA3a_5yI77UsMQ"
private val customDOAPIri = "http://rdfh.ch/permissions/00FF/zTOK3HlWTLGgTO8ZWVnotg"
"The Permissions Route ('admin/permissions')" when {

"getting permissions" should {
"return a group's administrative permission" in {

Expand Down Expand Up @@ -72,7 +71,6 @@ class PermissionsADME2ESpec extends E2ESpec(PermissionsADME2ESpec.config) with T
}

"return a project's administrative permissions" in {

val projectIri = java.net.URLEncoder.encode(SharedTestDataV1.imagesProjectInfo.id, "utf-8")

val request = Get(baseApiUrl + s"/admin/permissions/ap/$projectIri") ~> addCredentials(
Expand All @@ -98,7 +96,6 @@ class PermissionsADME2ESpec extends E2ESpec(PermissionsADME2ESpec.config) with T
}

"return a project's default object access permissions" in {

val projectIri = java.net.URLEncoder.encode(SharedTestDataV1.imagesProjectInfo.id, "utf-8")

val request = Get(baseApiUrl + s"/admin/permissions/doap/$projectIri") ~> addCredentials(
Expand All @@ -124,7 +121,6 @@ class PermissionsADME2ESpec extends E2ESpec(PermissionsADME2ESpec.config) with T
}

"return a project's all permissions" in {

val projectIri = java.net.URLEncoder.encode(SharedTestDataV1.imagesProjectInfo.id, "utf-8")

val request = Get(baseApiUrl + s"/admin/permissions/$projectIri") ~> addCredentials(
Expand Down