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,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