Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feat(UUID): add IRI validation that allows only to create IRIs using …
…UUID version 4 and 5 (DEV-402) (#1990)

* add method that checks if UUID version is correct

* improve UUID version checking implementation

* add UUID check to admin velue objects

* fix bad test IRIs

* add permission iri validation

* add resource iri validation

* fix permisson bad IRIs

* add value iri validation

* cleanup

* more cleanup

* refactor validate methods

* refactor UUID method

* fix typos

* fix failing tests
  • Loading branch information
mpro7 committed Feb 3, 2022
1 parent 65952f9 commit 74d4344
Show file tree
Hide file tree
Showing 24 changed files with 208 additions and 69 deletions.
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,14 +2962,56 @@ class StringFormatter private (
errorFun
}

/**
* Gets the last segment of IRI, decodes UUID and gets the version.
* @param s the string (IRI) to be checked.
* @return UUID version.
*/
def getUUIDVersion(s: IRI): Int = {
val encodedUUID = s.split("/").last
decodeUuid(encodedUUID).version()
}

/**
* 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: IRI): Boolean =
if (getUUIDVersion(s) == 4 || getUUIDVersion(s) == 5) {
true
} else {
false
}

/**
* Checks if a string is the right length to be a canonical or Base64-encoded UUID.
*
* @param idStr the string to check.
* @return `true` if the string is the right length to be a canonical or Base64-encoded UUID.
* @param s the string to check.
* @return TRUE if the string is the right length to be a canonical or Base64-encoded UUID.
*/
def couldBeUuid(idStr: String): Boolean =
idStr.length == CanonicalUuidLength || idStr.length == Base64UuidLength
def hasUUIDLength(s: String): Boolean =
s.length == CanonicalUuidLength || s.length == Base64UuidLength

/**
* Validates resource IRI
* @param iri to be validated
*/
def validateUUIDOfResourceIRI(iri: SmartIri): Unit =
if (iri.isKnoraResourceIri && hasUUIDLength(iri.toString.split("/").last) && !isUUIDVersion4Or5(iri.toString)) {
throw BadRequestException(UUID_INVALID_ERROR)
}

/**
* Validates permission IRI
* @param iri to be validated.
*/
def validatePermissionIRI(iri: IRI): Unit =
if (isKnoraPermissionIriStr(iri) && !isUUIDVersion4Or5(iri)) {
throw BadRequestException(UUID_INVALID_ERROR)
} else {
validatePermissionIri(iri, throw BadRequestException(s"Invalid permission IRI ${iri} is given."))
}

/**
* Creates a new resource IRI based on a 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 Down Expand Up @@ -37,19 +37,21 @@ case class CreateAdministrativePermissionAPIRequestADM(
forGroup: IRI,
hasPermissions: Set[PermissionADM]
) extends PermissionsADMJsonProtocol {
implicit protected val sf: StringFormatter = StringFormatter.getInstanceForConstantOntologies

id match {
case Some(iri) => sf.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 +78,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) => sf.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 +97,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 +113,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.hasUUIDLength(value.split("/").last)

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.hasUUIDLength(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.hasUUIDLength(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.hasUUIDLength(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 @@ -746,7 +746,7 @@ class XMLToStandoffUtil(
case Some(existingUuid) => existingUuid
case None =>
// Otherwise, try to parse the ID as a UUID.
if (stringFormatter.couldBeUuid(id)) {
if (stringFormatter.hasUUIDLength(id)) {
stringFormatter.decodeUuid(id)
} else {
// If the ID doesn't seem to be a UUID, replace it with a random UUID. TODO: this should throw an exception instead.
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 Down Expand Up @@ -724,12 +725,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)) {
stringFormatter.validateUUIDOfResourceIRI(iri)

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 +933,8 @@ object UpdateResourceMetadataRequestV2 extends KnoraJsonLDRequestReaderV2[Update
throw BadRequestException(s"Invalid resource IRI: <$resourceIri>")
}

stringFormatter.validateUUIDOfResourceIRI(resourceIri)

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.hasUUIDLength(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

0 comments on commit 74d4344

Please sign in to comment.