Skip to content

Commit 74d4344

Browse files
authored
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
1 parent 65952f9 commit 74d4344

24 files changed

Lines changed: 208 additions & 69 deletions

‎webapi/src/main/scala/org/knora/webapi/messages/StringFormatter.scala‎

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import scala.util.{Failure, Success, Try}
4747
* Provides instances of [[StringFormatter]], as well as string formatting constants.
4848
*/
4949
object StringFormatter {
50+
val UUID_INVALID_ERROR = "Invalid UUID used to create IRI. Only versions 4 and 5 are supported."
5051

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

904905
def this(iriStr: IRI, parsedIriInfo: Option[SmartIriInfo]) =
@@ -2961,14 +2962,56 @@ class StringFormatter private (
29612962
errorFun
29622963
}
29632964

2965+
/**
2966+
* Gets the last segment of IRI, decodes UUID and gets the version.
2967+
* @param s the string (IRI) to be checked.
2968+
* @return UUID version.
2969+
*/
2970+
def getUUIDVersion(s: IRI): Int = {
2971+
val encodedUUID = s.split("/").last
2972+
decodeUuid(encodedUUID).version()
2973+
}
2974+
2975+
/**
2976+
* Checks if UUID used to create IRI has correct version (4 and 5 are allowed).
2977+
* @param s the string (IRI) to be checked.
2978+
* @return TRUE for correct versions, FALSE for incorrect.
2979+
*/
2980+
def isUUIDVersion4Or5(s: IRI): Boolean =
2981+
if (getUUIDVersion(s) == 4 || getUUIDVersion(s) == 5) {
2982+
true
2983+
} else {
2984+
false
2985+
}
2986+
29642987
/**
29652988
* Checks if a string is the right length to be a canonical or Base64-encoded UUID.
29662989
*
2967-
* @param idStr the string to check.
2968-
* @return `true` if the string is the right length to be a canonical or Base64-encoded UUID.
2990+
* @param s the string to check.
2991+
* @return TRUE if the string is the right length to be a canonical or Base64-encoded UUID.
29692992
*/
2970-
def couldBeUuid(idStr: String): Boolean =
2971-
idStr.length == CanonicalUuidLength || idStr.length == Base64UuidLength
2993+
def hasUUIDLength(s: String): Boolean =
2994+
s.length == CanonicalUuidLength || s.length == Base64UuidLength
2995+
2996+
/**
2997+
* Validates resource IRI
2998+
* @param iri to be validated
2999+
*/
3000+
def validateUUIDOfResourceIRI(iri: SmartIri): Unit =
3001+
if (iri.isKnoraResourceIri && hasUUIDLength(iri.toString.split("/").last) && !isUUIDVersion4Or5(iri.toString)) {
3002+
throw BadRequestException(UUID_INVALID_ERROR)
3003+
}
3004+
3005+
/**
3006+
* Validates permission IRI
3007+
* @param iri to be validated.
3008+
*/
3009+
def validatePermissionIRI(iri: IRI): Unit =
3010+
if (isKnoraPermissionIriStr(iri) && !isUUIDVersion4Or5(iri)) {
3011+
throw BadRequestException(UUID_INVALID_ERROR)
3012+
} else {
3013+
validatePermissionIri(iri, throw BadRequestException(s"Invalid permission IRI ${iri} is given."))
3014+
}
29723015

29733016
/**
29743017
* Creates a new resource IRI based on a UUID.

‎webapi/src/main/scala/org/knora/webapi/messages/admin/responder/permissionsmessages/PermissionsMessagesADM.scala‎

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
99
import org.knora.webapi._
1010
import org.knora.webapi.exceptions.{BadRequestException, ForbiddenException}
1111
import org.knora.webapi.feature.FeatureFactoryConfig
12-
import org.knora.webapi.messages.admin.responder.permissionsmessages.PermissionProfileType.Restricted
12+
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
1313
import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsADMJsonProtocol
1414
import org.knora.webapi.messages.admin.responder.usersmessages.UserADM
1515
import org.knora.webapi.messages.admin.responder.{KnoraRequestADM, KnoraResponseADM}
@@ -37,19 +37,21 @@ case class CreateAdministrativePermissionAPIRequestADM(
3737
forGroup: IRI,
3838
hasPermissions: Set[PermissionADM]
3939
) extends PermissionsADMJsonProtocol {
40+
implicit protected val sf: StringFormatter = StringFormatter.getInstanceForConstantOntologies
41+
42+
id match {
43+
case Some(iri) => sf.validatePermissionIRI(iri)
44+
case None => None
45+
}
4046

4147
def toJsValue: JsValue = createAdministrativePermissionAPIRequestADMFormat.write(this)
4248

43-
implicit protected val stringFormatter: StringFormatter = StringFormatter.getInstanceForConstantOntologies
44-
stringFormatter.validateAndEscapeProjectIri(forProject, throw BadRequestException(s"Invalid project IRI $forProject"))
45-
stringFormatter.validateOptionalPermissionIri(
46-
id,
47-
throw BadRequestException(s"Invalid permission IRI ${id.get} is given.")
48-
)
49+
sf.validateAndEscapeProjectIri(forProject, throw BadRequestException(s"Invalid project IRI $forProject"))
50+
4951
if (hasPermissions.isEmpty) throw BadRequestException("Permissions needs to be supplied.")
5052

5153
if (!OntologyConstants.KnoraAdmin.BuiltInGroups.contains(forGroup)) {
52-
stringFormatter.validateGroupIri(forGroup, throw BadRequestException(s"Invalid group IRI $forGroup"))
54+
sf.validateGroupIri(forGroup, throw BadRequestException(s"Invalid group IRI $forGroup"))
5355
}
5456

5557
def prepareHasPermissions: CreateAdministrativePermissionAPIRequestADM =
@@ -76,14 +78,16 @@ case class CreateDefaultObjectAccessPermissionAPIRequestADM(
7678
forProperty: Option[IRI] = None,
7779
hasPermissions: Set[PermissionADM]
7880
) extends PermissionsADMJsonProtocol {
81+
implicit protected val sf: StringFormatter = StringFormatter.getInstanceForConstantOntologies
82+
83+
id match {
84+
case Some(iri) => sf.validatePermissionIRI(iri)
85+
case None => None
86+
}
87+
7988
def toJsValue: JsValue = createDefaultObjectAccessPermissionAPIRequestADMFormat.write(this)
8089

81-
implicit protected val stringFormatter: StringFormatter = StringFormatter.getInstanceForConstantOntologies
82-
stringFormatter.validateAndEscapeProjectIri(forProject, throw BadRequestException(s"Invalid project IRI $forProject"))
83-
stringFormatter.validateOptionalPermissionIri(
84-
id,
85-
throw BadRequestException(s"Invalid permission IRI ${id.get} is given.")
86-
)
90+
sf.validateAndEscapeProjectIri(forProject, throw BadRequestException(s"Invalid project IRI $forProject"))
8791

8892
forGroup match {
8993
case Some(iri: IRI) =>
@@ -93,7 +97,7 @@ case class CreateDefaultObjectAccessPermissionAPIRequestADM(
9397
throw BadRequestException("Not allowed to supply groupIri and propertyIri together.")
9498
else {
9599
if (!OntologyConstants.KnoraAdmin.BuiltInGroups.contains(iri)) {
96-
stringFormatter.validateOptionalGroupIri(
100+
sf.validateOptionalGroupIri(
97101
forGroup,
98102
throw BadRequestException(s"Invalid group IRI ${forGroup.get}")
99103
)
@@ -109,15 +113,15 @@ case class CreateDefaultObjectAccessPermissionAPIRequestADM(
109113

110114
forResourceClass match {
111115
case Some(iri) =>
112-
if (!stringFormatter.toSmartIri(iri).isKnoraEntityIri) {
116+
if (!sf.toSmartIri(iri).isKnoraEntityIri) {
113117
throw BadRequestException(s"Invalid resource class IRI: $iri")
114118
}
115119
case None => None
116120
}
117121

118122
forProperty match {
119123
case Some(iri) =>
120-
if (!stringFormatter.toSmartIri(iri).isKnoraEntityIri) {
124+
if (!sf.toSmartIri(iri).isKnoraEntityIri) {
121125
throw BadRequestException(s"Invalid property IRI: $iri")
122126
}
123127
case None => None

‎webapi/src/main/scala/org/knora/webapi/messages/admin/responder/valueObjects/GroupsValueObjectsADM.scala‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package org.knora.webapi.messages.admin.responder.valueObjects
77

88
import org.knora.webapi.exceptions.BadRequestException
99
import org.knora.webapi.messages.StringFormatter
10+
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
1011
import org.knora.webapi.messages.admin.responder.groupsmessages.GroupsErrorMessagesADM._
1112
import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2
1213
import zio.prelude.Validation
@@ -22,8 +23,12 @@ object GroupIRI { self =>
2223
if (value.isEmpty) {
2324
Validation.fail(BadRequestException(GROUP_IRI_MISSING_ERROR))
2425
} else {
26+
val isUUID: Boolean = sf.hasUUIDLength(value.split("/").last)
27+
2528
if (!sf.isKnoraGroupIriStr(value)) {
2629
Validation.fail(BadRequestException(GROUP_IRI_INVALID_ERROR))
30+
} else if (isUUID && !sf.isUUIDVersion4Or5(value)) {
31+
Validation.fail(BadRequestException(UUID_INVALID_ERROR))
2732
} else {
2833
val validatedValue = Validation(
2934
sf.validateAndEscapeIri(value, throw BadRequestException(GROUP_IRI_INVALID_ERROR))

‎webapi/src/main/scala/org/knora/webapi/messages/admin/responder/valueObjects/ListsValueObjectsADM.scala‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ package org.knora.webapi.messages.admin.responder.valueObjects
77

88
import org.knora.webapi.exceptions.BadRequestException
99
import org.knora.webapi.messages.StringFormatter
10+
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
1011
import org.knora.webapi.messages.admin.responder.listsmessages.ListsErrorMessagesADM._
12+
import org.knora.webapi.messages.admin.responder.valueObjects.GroupIRI.sf
1113
import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2
1214
import zio.prelude.Validation
1315

@@ -22,8 +24,12 @@ object ListIRI { self =>
2224
if (value.isEmpty) {
2325
Validation.fail(BadRequestException(LIST_NODE_IRI_MISSING_ERROR))
2426
} else {
27+
val isUUID: Boolean = sf.hasUUIDLength(value.split("/").last)
28+
2529
if (!sf.isKnoraListIriStr(value)) {
2630
Validation.fail(BadRequestException(LIST_NODE_IRI_INVALID_ERROR))
31+
} else if (isUUID && !sf.isUUIDVersion4Or5(value)) {
32+
Validation.fail(BadRequestException(UUID_INVALID_ERROR))
2733
} else {
2834
val validatedValue = Validation(
2935
sf.validateAndEscapeIri(value, throw BadRequestException(LIST_NODE_IRI_INVALID_ERROR))

‎webapi/src/main/scala/org/knora/webapi/messages/admin/responder/valueObjects/ProjectsValueObjectsADM.scala‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ package org.knora.webapi.messages.admin.responder.valueObjects
77

88
import org.knora.webapi.exceptions.BadRequestException
99
import org.knora.webapi.messages.StringFormatter
10+
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
1011
import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectsErrorMessagesADM._
12+
import org.knora.webapi.messages.admin.responder.valueObjects.GroupIRI.sf
1113
import org.knora.webapi.messages.store.triplestoremessages.StringLiteralV2
1214
import zio.prelude.Validation
1315

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

2123
def make(value: String): Validation[Throwable, ProjectIRI] =
2224
if (value.isEmpty) {
2325
Validation.fail(BadRequestException(PROJECT_IRI_MISSING_ERROR))
2426
} else {
27+
val isUUID: Boolean = sf.hasUUIDLength(value.split("/").last)
28+
2529
if (!sf.isKnoraProjectIriStr(value)) {
2630
Validation.fail(BadRequestException(PROJECT_IRI_INVALID_ERROR))
31+
} else if (isUUID && !sf.isUUIDVersion4Or5(value)) {
32+
Validation.fail(BadRequestException(UUID_INVALID_ERROR))
2733
} else {
2834
val validatedValue = Validation(
2935
sf.validateAndEscapeProjectIri(value, throw BadRequestException(PROJECT_IRI_INVALID_ERROR))

‎webapi/src/main/scala/org/knora/webapi/messages/admin/responder/valueObjects/UsersValueObjectsADM.scala‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ package org.knora.webapi.messages.admin.responder.valueObjects
88
import org.knora.webapi.LanguageCodes
99
import org.knora.webapi.exceptions.BadRequestException
1010
import org.knora.webapi.messages.StringFormatter
11+
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
1112
import org.knora.webapi.messages.admin.responder.usersmessages.UsersErrorMessagesADM._
13+
import org.knora.webapi.messages.admin.responder.valueObjects.GroupIRI.sf
1214
import zio.prelude.Validation
1315

1416
import scala.util.matching.Regex
@@ -24,8 +26,12 @@ object UserIRI { self =>
2426
if (value.isEmpty) {
2527
Validation.fail(BadRequestException(USER_IRI_MISSING_ERROR))
2628
} else {
29+
val isUUID: Boolean = sf.hasUUIDLength(value.split("/").last)
30+
2731
if (!sf.isKnoraUserIriStr(value)) {
2832
Validation.fail(BadRequestException(USER_IRI_INVALID_ERROR))
33+
} else if (isUUID && !sf.isUUIDVersion4Or5(value)) {
34+
Validation.fail(BadRequestException(UUID_INVALID_ERROR))
2935
} else {
3036
val validatedValue = Validation(
3137
sf.validateAndEscapeUserIri(value, throw BadRequestException(USER_IRI_INVALID_ERROR))

‎webapi/src/main/scala/org/knora/webapi/messages/util/standoff/XMLToStandoffUtil.scala‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,7 @@ class XMLToStandoffUtil(
746746
case Some(existingUuid) => existingUuid
747747
case None =>
748748
// Otherwise, try to parse the ID as a UUID.
749-
if (stringFormatter.couldBeUuid(id)) {
749+
if (stringFormatter.hasUUIDLength(id)) {
750750
stringFormatter.decodeUuid(id)
751751
} else {
752752
// If the ID doesn't seem to be a UUID, replace it with a random UUID. TODO: this should throw an exception instead.

‎webapi/src/main/scala/org/knora/webapi/messages/v2/responder/resourcemessages/ResourceMessagesV2.scala‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import org.knora.webapi._
1515
import org.knora.webapi.exceptions._
1616
import org.knora.webapi.feature.FeatureFactoryConfig
1717
import org.knora.webapi.messages.IriConversions._
18+
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
1819
import org.knora.webapi.messages.admin.responder.projectsmessages.{
1920
ProjectADM,
2021
ProjectGetRequestADM,
@@ -724,12 +725,14 @@ object CreateResourceRequestV2 extends KnoraJsonLDRequestReaderV2[CreateResource
724725
requestingUser = requestingUser
725726
)).mapTo[ProjectGetResponseADM]
726727

727-
_ = maybeCustomResourceIri.foreach { definedResourceIri =>
728-
if (!definedResourceIri.isKnoraResourceIri) {
729-
throw BadRequestException(s"<$definedResourceIri> is not a Knora resource IRI")
728+
_ = maybeCustomResourceIri.foreach { iri =>
729+
if (!iri.isKnoraResourceIri) {
730+
throw BadRequestException(s"<$iri> is not a Knora resource IRI")
730731
}
731732

732-
if (!definedResourceIri.getProjectCode.contains(projectInfoResponse.project.shortcode)) {
733+
stringFormatter.validateUUIDOfResourceIRI(iri)
734+
735+
if (!iri.getProjectCode.contains(projectInfoResponse.project.shortcode)) {
733736
throw BadRequestException(s"The provided resource IRI does not contain the correct project code")
734737
}
735738
}
@@ -930,6 +933,8 @@ object UpdateResourceMetadataRequestV2 extends KnoraJsonLDRequestReaderV2[Update
930933
throw BadRequestException(s"Invalid resource IRI: <$resourceIri>")
931934
}
932935

936+
stringFormatter.validateUUIDOfResourceIRI(resourceIri)
937+
933938
val resourceClassIri: SmartIri = jsonLDDocument.requireTypeAsKnoraTypeIri
934939

935940
val maybeLastModificationDate: Option[Instant] = jsonLDDocument.maybeDatatypeValueInObject(

‎webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ package org.knora.webapi.messages.v2.responder.valuemessages
77

88
import java.time.Instant
99
import java.util.UUID
10-
1110
import akka.actor.ActorRef
1211
import akka.event.LoggingAdapter
1312
import akka.http.scaladsl.util.FastFuture
@@ -17,6 +16,7 @@ import org.knora.webapi._
1716
import org.knora.webapi.exceptions.{AssertionException, BadRequestException, NotImplementedException, SipiException}
1817
import org.knora.webapi.feature.FeatureFactoryConfig
1918
import org.knora.webapi.messages.IriConversions._
19+
import org.knora.webapi.messages.StringFormatter.UUID_INVALID_ERROR
2020
import org.knora.webapi.messages.admin.responder.projectsmessages.ProjectADM
2121
import org.knora.webapi.messages.admin.responder.usersmessages.UserADM
2222
import org.knora.webapi.messages.store.sipimessages.{GetFileMetadataRequest, GetFileMetadataResponse}
@@ -500,6 +500,13 @@ object DeleteValueRequestV2 extends KnoraJsonLDRequestReaderV2[DeleteValueReques
500500
throw BadRequestException(s"Invalid value IRI: <$valueIri>")
501501
}
502502

503+
if (
504+
stringFormatter.hasUUIDLength(valueIri.toString.split("/").last)
505+
&& !stringFormatter.isUUIDVersion4Or5(valueIri.toString)
506+
) {
507+
throw BadRequestException(UUID_INVALID_ERROR)
508+
}
509+
503510
val valueTypeIri: SmartIri = jsonLDObject.requireTypeAsKnoraApiV2ComplexTypeIri
504511

505512
val deleteComment: Option[String] = jsonLDObject.maybeStringWithValidation(

‎webapi/src/test/scala/org/knora/webapi/e2e/admin/GroupsADME2ESpec.scala‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ class GroupsADME2ESpec extends E2ESpec(GroupsADME2ESpec.config) with GroupsADMJs
8888
}
8989

9090
"given a custom Iri" should {
91-
val customGroupIri = "http://rdfh.ch/groups/00FF/3eFYejZEduOCowwXQq5Iqg"
91+
val customGroupIri = "http://rdfh.ch/groups/00FF/gNdJSNYrTDu2lGpPUs94nQ"
9292
"create a group with the provided custom IRI " in {
9393
val createGroupWithCustomIriRequest: String =
9494
s"""{ "id": "$customGroupIri",

0 commit comments

Comments
 (0)