diff --git a/build.sbt b/build.sbt index 6aaa5d76..f9880909 100644 --- a/build.sbt +++ b/build.sbt @@ -55,6 +55,7 @@ lazy val root = project ) .aggregate( commons, + jwt, httpClient, events, redisClient, @@ -99,6 +100,18 @@ lazy val commons = project .enablePlugins(AutomateHeaderPlugin, BuildInfoPlugin) .disablePlugins(DbTestPlugin, RevolverPlugin) +lazy val jwt = project + .in(file("modules/jwt")) + .enablePlugins(AutomateHeaderPlugin) + .disablePlugins(DbTestPlugin, RevolverPlugin) + .settings(commonSettings) + .settings( + name := "jwt", + description := "jwt with borer", + libraryDependencies ++= Dependencies.borer ++ + Dependencies.jwtScala + ) + lazy val http4sBorer = project .in(file("modules/http4s-borer")) .enablePlugins(AutomateHeaderPlugin) @@ -324,7 +337,8 @@ lazy val searchApi = project http4sBorer % "compile->compile;test->test", searchSolrClient % "compile->compile;test->test", configValues % "compile->compile;test->test", - searchQueryDocs % "compile->compile;test->test" + searchQueryDocs % "compile->compile;test->test", + jwt % "compile->compile;test->test" ) .enablePlugins(AutomateHeaderPlugin, DockerImagePlugin, RevolverPlugin) diff --git a/modules/commons/src/main/scala/io/renku/search/model/projects.scala b/modules/commons/src/main/scala/io/renku/search/model/projects.scala index ff23705a..8a84e7cc 100644 --- a/modules/commons/src/main/scala/io/renku/search/model/projects.scala +++ b/modules/commons/src/main/scala/io/renku/search/model/projects.scala @@ -63,22 +63,36 @@ object projects: given Transformer[Instant, CreationDate] = apply given Codec[CreationDate] = Codec.of[Instant] - enum Visibility derives Codec: + enum Visibility: lazy val name: String = productPrefix.toLowerCase case Public, Private object Visibility: given Order[Visibility] = Order.by(_.ordinal) + given Encoder[Visibility] = Encoder.forString.contramap(_.name) + given Decoder[Visibility] = Decoder.forString.mapEither(Visibility.fromString) + + def fromString(v: String): Either[String, Visibility] = + Visibility.values + .find(_.name.equalsIgnoreCase(v)) + .toRight(s"Invalid visibility: $v") def unsafeFromString(v: String): Visibility = - valueOf(v.toLowerCase.capitalize) + fromString(v).fold(sys.error, identity) - enum MemberRole derives Codec: + enum MemberRole: lazy val name: String = productPrefix.toLowerCase case Owner, Member object MemberRole: given Order[MemberRole] = Order.by(_.ordinal) + given Encoder[MemberRole] = Encoder.forString.contramap(_.name) + given Decoder[MemberRole] = Decoder.forString.mapEither(MemberRole.fromString) + + def fromString(v: String): Either[String, MemberRole] = + MemberRole.values + .find(_.name.equalsIgnoreCase(v)) + .toRight(s"Invalid member-role: $v") def unsafeFromString(v: String): MemberRole = - valueOf(v.toLowerCase.capitalize) + fromString(v).fold(sys.error, identity) diff --git a/modules/commons/src/main/scala/io/renku/search/model/users.scala b/modules/commons/src/main/scala/io/renku/search/model/users.scala index 14b43839..788fb685 100644 --- a/modules/commons/src/main/scala/io/renku/search/model/users.scala +++ b/modules/commons/src/main/scala/io/renku/search/model/users.scala @@ -42,4 +42,11 @@ object users: def apply(v: String): Email = v extension (self: Email) def value: String = self given Transformer[String, Email] = apply - given Codec[Email] = Codec.bimap[String, Email](_.value, LastName.apply) + given Codec[Email] = Codec.bimap[String, Email](_.value, Email.apply) + + opaque type Username = String + object Username: + def apply(v: String): Username = v + extension (self: Username) def value: String = self + given Transformer[String, Username] = apply + given Codec[Username] = Codec.bimap[String, Username](_.value, Username.apply) diff --git a/modules/jwt/src/main/scala/io/renku/search/jwt/BorerCodec.scala b/modules/jwt/src/main/scala/io/renku/search/jwt/BorerCodec.scala new file mode 100644 index 00000000..3c566f88 --- /dev/null +++ b/modules/jwt/src/main/scala/io/renku/search/jwt/BorerCodec.scala @@ -0,0 +1,95 @@ +/* + * Copyright 2024 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.renku.search.jwt + +import cats.syntax.all.* + +import io.bullet.borer.{Decoder, Reader} +import pdi.jwt.{JwtAlgorithm, JwtClaim, JwtHeader} + +trait BorerCodec: + given Decoder[JwtAlgorithm] = + Decoder.forString.map(JwtAlgorithm.fromString) + + given Decoder[JwtHeader] = new Decoder[JwtHeader]: + def read(r: Reader): JwtHeader = + r.readMapStart() + r.readUntilBreak(JwtHeader(None, None, None, None).withType) { h => + r.readString() match + case "alg" => + val alg = r.read[JwtAlgorithm]() + JwtHeader(alg.some, h.typ, h.contentType, h.keyId) + case "typ" => h.withType(r.readString()) + case "cty" => + JwtHeader(h.algorithm, h.typ, r.readString().some, h.keyId) + case "kid" => h.withKeyId(r.readString()) + case _ => + r.skipElement() + h + } + + given Decoder[JwtClaim] = new Decoder[JwtClaim]: + def read(r: Reader): JwtClaim = + r.readMapStart() + r.readUntilBreak(JwtClaim()) { c => + r.readString() match + case "iss" => c.copy(issuer = r.readStringOpt()) + case "sub" => c.copy(subject = r.readStringOpt()) + case "aud" => c.copy(audience = r.readSetStr()) + case "exp" => c.copy(expiration = r.readLongOpt()) + case "nbf" => c.copy(notBefore = r.readLongOpt()) + case "iat" => c.copy(issuedAt = r.readLongOpt()) + case "jti" => c.copy(jwtId = r.readStringOpt()) + case _ => + r.skipElement() + c + } + + extension (self: Reader) + def readStringOpt(): Option[String] = + if (self.tryReadNull()) None else self.readString().some + + def readLongOpt(): Option[Long] = + if (self.tryReadNull()) None else self.readLong().some + + def readSetStr(): Option[Set[String]] = + if (self.tryReadNull()) None + else if (self.hasArrayStart) self.read[Set[String]]().some + else Set(self.readString()).some + + extension (self: JwtClaim) + def copy( + issuer: Option[String] = self.issuer, + subject: Option[String] = self.subject, + audience: Option[Set[String]] = self.audience, + expiration: Option[Long] = self.expiration, + notBefore: Option[Long] = self.notBefore, + issuedAt: Option[Long] = self.issuedAt, + jwtId: Option[String] = self.jwtId + ): JwtClaim = + JwtClaim( + self.content, + issuer, + subject, + audience, + expiration, + notBefore, + issuedAt, + jwtId + ) diff --git a/modules/jwt/src/main/scala/io/renku/search/jwt/JwtBorer.scala b/modules/jwt/src/main/scala/io/renku/search/jwt/JwtBorer.scala new file mode 100644 index 00000000..ab7d254f --- /dev/null +++ b/modules/jwt/src/main/scala/io/renku/search/jwt/JwtBorer.scala @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.renku.search.jwt + +import java.time.Clock + +import scala.util.Try + +import io.bullet.borer.Json +import pdi.jwt.* + +class JwtBorer(override val clock: Clock) + extends JwtCore[JwtHeader, JwtClaim] + with BorerCodec: + private val noSigOptions = JwtOptions.DEFAULT.copy(signature = false) + + protected def parseHeader(header: String): JwtHeader = + Json.decode(header.getBytes).to[JwtHeader].value + + protected def parseClaim(claim: String): JwtClaim = + Json.decode(claim.getBytes).to[JwtClaim].value + + protected def extractAlgorithm(header: JwtHeader): Option[JwtAlgorithm] = + header.algorithm + protected def extractExpiration(claim: JwtClaim): Option[Long] = claim.expiration + protected def extractNotBefore(claim: JwtClaim): Option[Long] = claim.notBefore + + def decodeAllNoSignatureCheck(token: String): Try[(JwtHeader, JwtClaim, String)] = + decodeAll(token, noSigOptions) + + def decodeNoSignatureCheck(token: String): Try[JwtClaim] = + decode(token, noSigOptions) + +object JwtBorer extends JwtBorer(Clock.systemUTC()): + def apply(clock: Clock): JwtBorer = new JwtBorer(clock) diff --git a/modules/jwt/src/test/scala/io/renku/search/jwt/JwtBorerSpec.scala b/modules/jwt/src/test/scala/io/renku/search/jwt/JwtBorerSpec.scala new file mode 100644 index 00000000..4e9c6248 --- /dev/null +++ b/modules/jwt/src/test/scala/io/renku/search/jwt/JwtBorerSpec.scala @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.renku.search.jwt + +import munit.FunSuite +import pdi.jwt.Jwt +import pdi.jwt.JwtAlgorithm + +class JwtBorerSpec extends FunSuite: + + val secret = new javax.crypto.spec.SecretKeySpec("abcdefg".getBytes, "HS256") + val exampleToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJteS11c2VyLWlkIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.d7F1v9sfcQzVrEGXXhJoGukbfXhm3zKn0fUyvFAMzm0" + + val regexDecode = Jwt.decodeAll(exampleToken, secret).get + + test("decode"): + val (header, claim, _) = JwtBorer.decodeAll(exampleToken, secret).get + assertEquals(header.algorithm, Some(JwtAlgorithm.HS256)) + assertEquals(header.typ, Some("JWT")) + assertEquals(header.keyId, None) + assertEquals(header.contentType, None) + + assertEquals(claim.subject, Some("my-user-id")) + assertEquals(claim.issuedAt, Some(1516239022L)) + assertEquals(header, regexDecode._1) + assertEquals(claim, regexDecode._2.withContent("{}")) + + test("decode without secret"): + val (header, claim, _) = JwtBorer.decodeAllNoSignatureCheck(exampleToken).get + val claim2 = JwtBorer.decodeNoSignatureCheck(exampleToken).get + assertEquals(claim, claim2) + assertEquals(header, regexDecode._1) + assertEquals(claim, regexDecode._2.withContent("{}")) diff --git a/modules/search-api/src/main/scala/io/renku/search/api/SearchApi.scala b/modules/search-api/src/main/scala/io/renku/search/api/SearchApi.scala index 3eb2089a..877afd47 100644 --- a/modules/search-api/src/main/scala/io/renku/search/api/SearchApi.scala +++ b/modules/search-api/src/main/scala/io/renku/search/api/SearchApi.scala @@ -25,7 +25,7 @@ import io.renku.solr.client.SolrConfig import io.renku.search.api.data.* trait SearchApi[F[_]]: - def query(query: QueryInput): F[Either[String, SearchResult]] + def query(auth: AuthContext)(query: QueryInput): F[Either[String, SearchResult]] object SearchApi: def apply[F[_]: Async: Network]( diff --git a/modules/search-api/src/main/scala/io/renku/search/api/SearchApiImpl.scala b/modules/search-api/src/main/scala/io/renku/search/api/SearchApiImpl.scala index da9abee7..3fe0a4db 100644 --- a/modules/search-api/src/main/scala/io/renku/search/api/SearchApiImpl.scala +++ b/modules/search-api/src/main/scala/io/renku/search/api/SearchApiImpl.scala @@ -37,22 +37,30 @@ private class SearchApiImpl[F[_]: Async](solrClient: SearchSolrClient[F]) extends Http4sDsl[F] with SearchApi[F]: - private given Scribe[F] = scribe.cats[F] + private val logger: Scribe[F] = scribe.cats[F] - override def query(query: QueryInput): F[Either[String, SearchResult]] = - solrClient - .queryEntity(query.query, query.page.limit + 1, query.page.offset) - .map(toApiResult(query.page)) - .map(_.asRight[String]) - .handleErrorWith(errorResponse(query.query.render)) - .widen + override def query( + auth: AuthContext + )(query: QueryInput): F[Either[String, SearchResult]] = + logger.debug(show"Running query '$query' as '$auth'") >> + solrClient + .queryEntity( + auth.searchRole, + query.query, + query.page.limit + 1, + query.page.offset + ) + .map(toApiResult(query.page)) + .map(_.asRight[String]) + .handleErrorWith(errorResponse(query.query.render)) + .widen private def errorResponse( phrase: String ): Throwable => F[Either[String, SearchResult]] = err => val message = s"Finding by '$phrase' phrase failed: ${err.getMessage}" - Scribe[F] + logger .error(message, err) .as(message) .map(_.asLeft[SearchResult]) diff --git a/modules/search-api/src/main/scala/io/renku/search/api/data/AuthContext.scala b/modules/search-api/src/main/scala/io/renku/search/api/data/AuthContext.scala new file mode 100644 index 00000000..534087b3 --- /dev/null +++ b/modules/search-api/src/main/scala/io/renku/search/api/data/AuthContext.scala @@ -0,0 +1,82 @@ +/* + * Copyright 2024 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.renku.search.api.data + +import cats.Show +import cats.syntax.all.* + +import io.renku.search.jwt.JwtBorer +import io.renku.search.model.Id +import io.renku.search.solr.SearchRole +import pdi.jwt.JwtClaim + +sealed trait AuthContext: + def isAnonymous: Boolean + def isAuthenticated: Boolean = !isAnonymous + def fold[A]( + fa: AuthContext.Authenticated => A, + fb: AuthContext.AnonymousId => A, + fc: => A + ): A + def authenticated: Option[AuthContext.Authenticated] + def searchRole: SearchRole = + fold(a => SearchRole.user(a.userId), _ => SearchRole.Anonymous, SearchRole.Anonymous) + def render: String + +object AuthContext: + val anonymous: AuthContext = Anonymous + def anonymousId(anonId: String): AuthContext = AnonymousId(Id(anonId)) + def authenticated(userId: Id): AuthContext = Authenticated(userId) + + case object Anonymous extends AuthContext { + val isAnonymous = true + val authenticated: Option[Authenticated] = None + val render = "" + def fold[A]( + fa: AuthContext.Authenticated => A, + fb: AuthContext.AnonymousId => A, + fc: => A + ): A = fc + } + + final case class AnonymousId(anonId: Id) extends AuthContext { + val isAnonymous = true + def fold[A]( + fa: AuthContext.Authenticated => A, + fb: AuthContext.AnonymousId => A, + fc: => A + ): A = fb(this) + val authenticated: Option[Authenticated] = None + val render = anonId.value + } + final case class Authenticated(userId: Id) extends AuthContext: + val isAnonymous = false + val authenticated: Option[AuthContext.Authenticated] = Some(this) + def fold[A]( + fa: AuthContext.Authenticated => A, + fb: AuthContext.AnonymousId => A, + fc: => A + ): A = fa(this) + def render = JwtBorer.encode(JwtClaim(subject = userId.value.some)) + + given Show[AuthContext] = Show.show { + case Authenticated(id) => s"Authenticated(${id.value})" + case AnonymousId(id) => s"AnonymousId(${id.value})" + case Anonymous => "Anonymous" + } diff --git a/modules/search-api/src/main/scala/io/renku/search/api/data/QueryInput.scala b/modules/search-api/src/main/scala/io/renku/search/api/data/QueryInput.scala index c7a0750c..29ca0a15 100644 --- a/modules/search-api/src/main/scala/io/renku/search/api/data/QueryInput.scala +++ b/modules/search-api/src/main/scala/io/renku/search/api/data/QueryInput.scala @@ -22,6 +22,7 @@ import io.renku.search.query.Query import io.bullet.borer.Encoder import io.bullet.borer.derivation.MapBasedCodecs.{deriveDecoder, deriveEncoder} import io.bullet.borer.Decoder +import cats.Show final case class QueryInput( query: Query, @@ -32,5 +33,7 @@ object QueryInput: given Encoder[QueryInput] = deriveEncoder given Decoder[QueryInput] = deriveDecoder + given Show[QueryInput] = Show.show(i => s"(${i.query.render}, ${i.page})") + def pageOne(query: Query): QueryInput = QueryInput(query, PageDef.default) diff --git a/modules/search-api/src/main/scala/io/renku/search/api/routes/SearchRoutes.scala b/modules/search-api/src/main/scala/io/renku/search/api/routes/SearchRoutes.scala index 211626bd..088ada58 100644 --- a/modules/search-api/src/main/scala/io/renku/search/api/routes/SearchRoutes.scala +++ b/modules/search-api/src/main/scala/io/renku/search/api/routes/SearchRoutes.scala @@ -19,51 +19,39 @@ package io.renku.search.api.routes import cats.effect.Async -import org.http4s.HttpRoutes -import sttp.tapir.* +import cats.syntax.all.* + +import io.renku.search.api.SearchApi import io.renku.search.api.data.* import io.renku.search.api.tapir.* -import io.renku.search.api.SearchApi import io.renku.search.http.borer.TapirBorerJson -import io.renku.search.query.Query import io.renku.search.query.docs.SearchQueryManual +import org.http4s.HttpRoutes +import sttp.tapir.* import sttp.tapir.server.ServerEndpoint +import sttp.tapir.server.http4s.Http4sServerInterpreter import sttp.tapir.server.http4s.Http4sServerOptions import sttp.tapir.server.interceptor.cors.CORSInterceptor -import sttp.tapir.server.http4s.Http4sServerInterpreter final class SearchRoutes[F[_]: Async](api: SearchApi[F]) extends TapirBorerJson with TapirCodecs { - private val searchEndpointGet: PublicEndpoint[QueryInput, String, SearchResult, Any] = + private val searchEndpointGet + : Endpoint[AuthContext, QueryInput, String, SearchResult, Any] = endpoint.get .in("") .in(Params.queryInput) + .securityIn(Params.renkuAuth) .errorOut(borerJsonBody[String]) .out(Params.searchResult) .description(SearchQueryManual.markdown) - private val searchEndpointPost: PublicEndpoint[QueryInput, String, SearchResult, Any] = - endpoint.post - .in("") - .errorOut(borerJsonBody[String]) - .in( - borerJsonBody[QueryInput] - .example( - QueryInput( - Query(Query.Segment.nameIs("proj-name1"), Query.Segment.text("flight sim")), - PageDef.default - ) - ) - ) - .out(Params.searchResult) - .description(SearchQueryManual.markdown) - val endpoints: List[ServerEndpoint[Any, F]] = List( - searchEndpointGet.serverLogic(api.query), - searchEndpointPost.serverLogic(api.query) + searchEndpointGet + .serverSecurityLogicSuccess(_.pure[F]) + .serverLogic(api.query) ) val routes: HttpRoutes[F] = diff --git a/modules/search-api/src/main/scala/io/renku/search/api/tapir/Params.scala b/modules/search-api/src/main/scala/io/renku/search/api/tapir/Params.scala index 632db1b9..c008bd3e 100644 --- a/modules/search-api/src/main/scala/io/renku/search/api/tapir/Params.scala +++ b/modules/search-api/src/main/scala/io/renku/search/api/tapir/Params.scala @@ -18,6 +18,7 @@ package io.renku.search.api.tapir +import cats.syntax.all.* import sttp.tapir.{query as queryParam, *} import io.renku.search.api.data.* import io.renku.search.query.Query @@ -66,4 +67,13 @@ object Params extends TapirCodecs with TapirBorerJson { val searchResult: EndpointOutput[SearchResult] = borerJsonBody[SearchResult].and(pagingInfo).map(_._1)(r => (r, r.pagingInfo)) + + private val renkuAuthIdToken: EndpointInput[Option[AuthContext.Authenticated]] = + header[Option[AuthContext.Authenticated]]("Renku-Auth-Id-Token") + private val renkuAuthAnonId: EndpointInput[Option[AuthContext.AnonymousId]] = + header[Option[AuthContext.AnonymousId]]("Renku-Auth-Anon-Id") + val renkuAuth: EndpointInput[AuthContext] = + (renkuAuthIdToken / renkuAuthAnonId).map { case (token, id) => + token.orElse(id).getOrElse(AuthContext.anonymous) + }(_.fold(a => (a.some, None), b => (None, b.some), (None, None))) } diff --git a/modules/search-api/src/main/scala/io/renku/search/api/tapir/TapirCodecs.scala b/modules/search-api/src/main/scala/io/renku/search/api/tapir/TapirCodecs.scala index f4693064..d371994a 100644 --- a/modules/search-api/src/main/scala/io/renku/search/api/tapir/TapirCodecs.scala +++ b/modules/search-api/src/main/scala/io/renku/search/api/tapir/TapirCodecs.scala @@ -18,10 +18,12 @@ package io.renku.search.api.tapir +import cats.syntax.all.* import sttp.tapir.* import io.renku.search.api.data.* import io.renku.search.query.Query -import io.renku.search.model.EntityType +import io.renku.search.model.{EntityType, Id} +import io.renku.search.jwt.JwtBorer trait TapirCodecs: given Codec[String, Query, CodecFormat.TextPlain] = @@ -35,4 +37,18 @@ trait TapirCodecs: given Schema[EntityType] = Schema.derivedEnumeration.defaultStringBased + given Codec[String, AuthContext.Authenticated, CodecFormat.TextPlain] = + Codec.string.mapEither(str => + JwtBorer + .decodeNoSignatureCheck(str) + .toEither + .leftMap(_.getMessage) + .flatMap(c => c.subject.toRight(s"Claim contains no subject")) + .map(Id.apply) + .map(AuthContext.Authenticated(_)) + )(_.render) + + given Codec[String, AuthContext.AnonymousId, CodecFormat.TextPlain] = + Codec.string.map(s => AuthContext.AnonymousId(Id(s)))(_.render) + object TapirCodecs extends TapirCodecs diff --git a/modules/search-api/src/test/scala/io/renku/search/api/SearchApiSpec.scala b/modules/search-api/src/test/scala/io/renku/search/api/SearchApiSpec.scala index 449d73f6..4b80f6dd 100644 --- a/modules/search-api/src/test/scala/io/renku/search/api/SearchApiSpec.scala +++ b/modules/search-api/src/test/scala/io/renku/search/api/SearchApiSpec.scala @@ -32,6 +32,8 @@ import io.renku.search.solr.client.SolrDocumentGenerators.* import io.renku.search.solr.documents.{EntityDocument, User as SolrUser} import munit.CatsEffectSuite import scribe.Scribe +import org.scalacheck.Gen +import io.renku.search.model.projects.Visibility class SearchApiSpec extends CatsEffectSuite with SearchSolrSpec: @@ -39,32 +41,52 @@ class SearchApiSpec extends CatsEffectSuite with SearchSolrSpec: test("do a lookup in Solr to find entities matching the given phrase"): withSearchSolrClient().use { client => - val project1 = projectDocumentGen("matching", "matching description").generateOne - val project2 = projectDocumentGen("disparate", "disparate description").generateOne + val project1 = projectDocumentGen( + "matching", + "matching description", + Gen.const(Visibility.Public) + ).generateOne + val project2 = projectDocumentGen( + "disparate", + "disparate description", + Gen.const(Visibility.Public) + ).generateOne val searchApi = new SearchApiImpl[IO](client) for { _ <- client.insert((project1 :: project2 :: Nil).map(_.widen)) results <- searchApi - .query(mkQuery("matching")) + .query(AuthContext.anonymous)(mkQuery("matching")) .map(_.fold(err => fail(s"Calling Search API failed with $err"), identity)) - } yield assert { - results.items.map(scoreToNone) contains toApiEntity(project1) - } + + expected = toApiEntities(project1).toSet + obtained = results.items.map(scoreToNone).toSet + } yield assert( + expected.diff(obtained).isEmpty, + s"Expected $expected, bot got $obtained" + ) } test("return Project and User entities"): withSearchSolrClient().use { client => - val project = projectDocumentGen("exclusive", "exclusive description").generateOne + val project = projectDocumentGen( + "exclusive", + "exclusive description", + Gen.const(Visibility.Public) + ).generateOne val user = SolrUser(project.createdBy, FirstName("exclusive").some) val searchApi = new SearchApiImpl[IO](client) for { _ <- client.insert(project :: user :: Nil) results <- searchApi - .query(mkQuery("exclusive")) + .query(AuthContext.anonymous)(mkQuery("exclusive")) .map(_.fold(err => fail(s"Calling Search API failed with $err"), identity)) - } yield assert { - toApiEntities(project, user).diff(results.items.map(scoreToNone)).isEmpty - } + + expected = toApiEntities(project, user).toSet + obtained = results.items.map(scoreToNone).toSet + } yield assert( + expected.diff(obtained).isEmpty, + s"Expected $expected, bot got $obtained" + ) } private def scoreToNone(e: SearchEntity): SearchEntity = e match diff --git a/modules/search-provision/src/main/scala/io/renku/search/provision/handler/DocumentConverter.scala b/modules/search-provision/src/main/scala/io/renku/search/provision/handler/DocumentConverter.scala index 23c873d9..e1ee3351 100644 --- a/modules/search-provision/src/main/scala/io/renku/search/provision/handler/DocumentConverter.scala +++ b/modules/search-provision/src/main/scala/io/renku/search/provision/handler/DocumentConverter.scala @@ -51,8 +51,7 @@ object DocumentConverter: fromTransformer( _.into[UserDocument].transform( Field.default(_.score), - Field.computed(_.name, u => UserDocument.nameFrom(u.firstName, u.lastName)), - Field.default(_.visibility) + Field.computed(_.name, u => UserDocument.nameFrom(u.firstName, u.lastName)) ) ) diff --git a/modules/search-provision/src/main/scala/io/renku/search/provision/handler/DocumentUpdates.scala b/modules/search-provision/src/main/scala/io/renku/search/provision/handler/DocumentUpdates.scala index 0b8fe4ad..eb95fc88 100644 --- a/modules/search-provision/src/main/scala/io/renku/search/provision/handler/DocumentUpdates.scala +++ b/modules/search-provision/src/main/scala/io/renku/search/provision/handler/DocumentUpdates.scala @@ -50,8 +50,7 @@ object DocumentUpdates: .into[UserDocument] .transform( Field.default(_.score), - Field.computed(_.name, u => UserDocument.nameFrom(u.firstName, u.lastName)), - Field.default(_.visibility) + Field.computed(_.name, u => UserDocument.nameFrom(u.firstName, u.lastName)) ) .some case _ => None diff --git a/modules/search-provision/src/main/scala/io/renku/search/provision/handler/FetchFromSolr.scala b/modules/search-provision/src/main/scala/io/renku/search/provision/handler/FetchFromSolr.scala index a603ec0a..8e513e30 100644 --- a/modules/search-provision/src/main/scala/io/renku/search/provision/handler/FetchFromSolr.scala +++ b/modules/search-provision/src/main/scala/io/renku/search/provision/handler/FetchFromSolr.scala @@ -30,6 +30,7 @@ import io.renku.search.solr.documents.EntityDocument import io.renku.search.query.Query import io.bullet.borer.derivation.MapBasedCodecs import io.bullet.borer.Decoder +import io.renku.search.solr.SearchRole import io.renku.search.solr.schema.EntityDocumentSchema.Fields import io.renku.solr.client.QueryString import io.renku.solr.client.QueryData @@ -70,8 +71,7 @@ object FetchFromSolr: val logger = scribe.cats.effect[F] private def idQuery(id: Id): Query = - // TODO this must be renamed to "idIs" since we have only one id type - Query(Query.Segment.projectIdIs(id.value)) + Query(Query.Segment.idIs(id.value)) def fetchProjectForUser(userId: Id): Stream[F, FetchFromSolr.ProjectId] = val query = QueryString( @@ -94,7 +94,7 @@ object FetchFromSolr: val loaded = ids .traverse(id => solrClient - .queryEntity(idQuery(id), 1, 0) + .queryEntity(SearchRole.Admin, idQuery(id), 1, 0) .map(_.responseBody.docs.headOption) .map(doc => id -> doc) ) diff --git a/modules/search-provision/src/test/scala/io/renku/search/provision/user/UserSyntax.scala b/modules/search-provision/src/test/scala/io/renku/search/provision/user/UserSyntax.scala index fcc388b2..ecd4986f 100644 --- a/modules/search-provision/src/test/scala/io/renku/search/provision/user/UserSyntax.scala +++ b/modules/search-provision/src/test/scala/io/renku/search/provision/user/UserSyntax.scala @@ -29,8 +29,7 @@ trait UserSyntax: .into[User] .transform( Field.default(_.score), - Field.computed(_.name, u => User.nameFrom(u.firstName, u.lastName)), - Field.default(_.visibility) + Field.computed(_.name, u => User.nameFrom(u.firstName, u.lastName)) ) def update(updated: UserUpdated): UserAdded = added.copy( diff --git a/modules/search-query-docs/docs/manual.md b/modules/search-query-docs/docs/manual.md index eb70e5c8..3d2f9f98 100644 --- a/modules/search-query-docs/docs/manual.md +++ b/modules/search-query-docs/docs/manual.md @@ -38,36 +38,6 @@ numpy flight visibility:public,private Searches for entities containing `numpy` _and_ `flight` that are _either_ `public` _or_ `private`. -### Query JSON - -The JSON format allows to specify the same query as a JSON object. A -JSON object may contain specific terms by including the corresponding -field-value pair. For unspecific terms, the special field `_text` is -used. - -Example: -```json -{ - "_text": "numpy flight", - "visibility": "public" -} -``` - -JSON objects are sequences of key-value pairs. As such, the encoding -allows to specifiy multiple same named fields in one JSON object. This -would be a valid query: - -```json -{ - "_text": "numpy", - "visibility": "public", - "_text": "flight" -} -``` - -The JSON variant follows the same rules for specifying field values. -Multiple alternative values can be given as a comma separated list. - ### Fields The following fields are available: diff --git a/modules/search-query/src/main/scala/io/renku/search/query/Field.scala b/modules/search-query/src/main/scala/io/renku/search/query/Field.scala index 9b44a5de..4999e8ed 100644 --- a/modules/search-query/src/main/scala/io/renku/search/query/Field.scala +++ b/modules/search-query/src/main/scala/io/renku/search/query/Field.scala @@ -21,7 +21,7 @@ package io.renku.search.query import io.bullet.borer.{Decoder, Encoder} enum Field: - case ProjectId + case Id case Name case Slug case Visibility diff --git a/modules/search-query/src/main/scala/io/renku/search/query/FieldTerm.scala b/modules/search-query/src/main/scala/io/renku/search/query/FieldTerm.scala index 51ac2a4c..bbc726d4 100644 --- a/modules/search-query/src/main/scala/io/renku/search/query/FieldTerm.scala +++ b/modules/search-query/src/main/scala/io/renku/search/query/FieldTerm.scala @@ -25,8 +25,7 @@ import io.renku.search.model.projects.Visibility enum FieldTerm(val field: Field, val cmp: Comparison): case TypeIs(values: NonEmptyList[EntityType]) extends FieldTerm(Field.Type, Comparison.Is) - case ProjectIdIs(values: NonEmptyList[String]) - extends FieldTerm(Field.ProjectId, Comparison.Is) + case IdIs(values: NonEmptyList[String]) extends FieldTerm(Field.Id, Comparison.Is) case NameIs(values: NonEmptyList[String]) extends FieldTerm(Field.Name, Comparison.Is) case SlugIs(values: NonEmptyList[String]) extends FieldTerm(Field.Slug, Comparison.Is) case VisibilityIs(values: NonEmptyList[Visibility]) @@ -41,9 +40,9 @@ enum FieldTerm(val field: Field, val cmp: Comparison): case TypeIs(values) => val ts = values.toList.distinct.map(_.name) ts.mkString(",") - case ProjectIdIs(values) => FieldTerm.nelToString(values) - case NameIs(values) => FieldTerm.nelToString(values) - case SlugIs(values) => FieldTerm.nelToString(values) + case IdIs(values) => FieldTerm.nelToString(values) + case NameIs(values) => FieldTerm.nelToString(values) + case SlugIs(values) => FieldTerm.nelToString(values) case VisibilityIs(values) => val vis = values.toList.distinct.map(_.name) vis.mkString(",") diff --git a/modules/search-query/src/main/scala/io/renku/search/query/Query.scala b/modules/search-query/src/main/scala/io/renku/search/query/Query.scala index 58bc4611..41ddfbaa 100644 --- a/modules/search-query/src/main/scala/io/renku/search/query/Query.scala +++ b/modules/search-query/src/main/scala/io/renku/search/query/Query.scala @@ -80,8 +80,8 @@ object Query: def typeIs(value: EntityType, more: EntityType*): Segment = Segment.Field(FieldTerm.TypeIs(NonEmptyList(value, more.toList))) - def projectIdIs(value: String, more: String*): Segment = - Segment.Field(FieldTerm.ProjectIdIs(NonEmptyList(value, more.toList))) + def idIs(value: String, more: String*): Segment = + Segment.Field(FieldTerm.IdIs(NonEmptyList(value, more.toList))) def nameIs(value: String, more: String*): Segment = Segment.Field(FieldTerm.NameIs(NonEmptyList(value, more.toList))) diff --git a/modules/search-query/src/main/scala/io/renku/search/query/json/QueryJsonCodec.scala b/modules/search-query/src/main/scala/io/renku/search/query/json/QueryJsonCodec.scala index e32d9a8a..bce857d3 100644 --- a/modules/search-query/src/main/scala/io/renku/search/query/json/QueryJsonCodec.scala +++ b/modules/search-query/src/main/scala/io/renku/search/query/json/QueryJsonCodec.scala @@ -35,8 +35,8 @@ import scala.collection.mutable.ListBuffer * {{{ * [ * { - * "projectId": ["p1", "p2"], - * "projectId": "p2", + * "id": ["p1", "p2"], + * "id": "p2", * "name": "test", * "_text": "some phrase", * "creationDate": ["<", "2024-01-29T12:00"] @@ -69,7 +69,7 @@ private[query] object QueryJsonCodec: case FieldTerm.TypeIs(values) => writeNelValue(w, values) - case FieldTerm.ProjectIdIs(values) => + case FieldTerm.IdIs(values) => writeNelValue(w, values) case FieldTerm.NameIs(values) => @@ -117,9 +117,9 @@ private[query] object QueryJsonCodec: val values = readNel[EntityType](r) Segment.Field(TypeIs(values)) - case Name.FieldName(Field.ProjectId) => + case Name.FieldName(Field.Id) => val values = readNel[String](r) - Segment.Field(ProjectIdIs(values)) + Segment.Field(IdIs(values)) case Name.FieldName(Field.Name) => val values = readNel[String](r) diff --git a/modules/search-query/src/main/scala/io/renku/search/query/parse/QueryParser.scala b/modules/search-query/src/main/scala/io/renku/search/query/parse/QueryParser.scala index 0fbe5438..19ca7856 100644 --- a/modules/search-query/src/main/scala/io/renku/search/query/parse/QueryParser.scala +++ b/modules/search-query/src/main/scala/io/renku/search/query/parse/QueryParser.scala @@ -107,7 +107,7 @@ private[query] object QueryParser { ((field <* is) ~ values).map { case (f, v) => f match case Field.Name => FieldTerm.NameIs(v) - case Field.ProjectId => FieldTerm.ProjectIdIs(v) + case Field.Id => FieldTerm.IdIs(v) case Field.Slug => FieldTerm.SlugIs(v) case Field.CreatedBy => FieldTerm.CreatedByIs(v) // other fields are excluded from the field list above diff --git a/modules/search-query/src/test/scala/io/renku/search/query/QueryGenerators.scala b/modules/search-query/src/test/scala/io/renku/search/query/QueryGenerators.scala index 854c9626..e5c433f2 100644 --- a/modules/search-query/src/test/scala/io/renku/search/query/QueryGenerators.scala +++ b/modules/search-query/src/test/scala/io/renku/search/query/QueryGenerators.scala @@ -115,7 +115,7 @@ object QueryGenerators: Gen.choose(1, 4).flatMap(n => CommonGenerators.nelOfN(n, phrase)) val projectIdTerm: Gen[FieldTerm] = - stringValues.map(FieldTerm.ProjectIdIs(_)) + stringValues.map(FieldTerm.IdIs(_)) val nameTerm: Gen[FieldTerm] = stringValues.map(FieldTerm.NameIs(_)) diff --git a/modules/search-query/src/test/scala/io/renku/search/query/parse/QueryParserSpec.scala b/modules/search-query/src/test/scala/io/renku/search/query/parse/QueryParserSpec.scala index 01911ec9..fcc90751 100644 --- a/modules/search-query/src/test/scala/io/renku/search/query/parse/QueryParserSpec.scala +++ b/modules/search-query/src/test/scala/io/renku/search/query/parse/QueryParserSpec.scala @@ -52,8 +52,8 @@ class QueryParserSpec extends ScalaCheckSuite with ParserSuite { test("field name") { val p = QueryParser.fieldNameFrom(Field.values.toSet) - List("projectId", "projectid").foreach { s => - assertEquals(p.run(s), Field.ProjectId) + List("createdBy", "createdby").foreach { s => + assertEquals(p.run(s), Field.CreatedBy) } Field.values.foreach { f => assertEquals(p.run(f.name), f) @@ -81,7 +81,7 @@ class QueryParserSpec extends ScalaCheckSuite with ParserSuite { test("field term") { val p = QueryParser.fieldTerm val data = List( - "projectId:id5" -> FieldTerm.ProjectIdIs(Nel.of("id5")), + "id:id5" -> FieldTerm.IdIs(Nel.of("id5")), "name:\"my project\"" -> FieldTerm.NameIs(Nel.of("my project")), "slug:ab1,ab2" -> FieldTerm.SlugIs(Nel.of("ab1", "ab2")), "type:project" -> FieldTerm.TypeIs(Nel.of(EntityType.Project)) @@ -98,8 +98,8 @@ class QueryParserSpec extends ScalaCheckSuite with ParserSuite { Query.Segment.Text("hello") ) assertEquals( - p.run("projectId:id5"), - Query.Segment.Field(FieldTerm.ProjectIdIs(Nel.of("id5"))) + p.run("id:id5"), + Query.Segment.Field(FieldTerm.IdIs(Nel.of("id5"))) ) assertEquals( p.run("foo:bar"), @@ -109,8 +109,8 @@ class QueryParserSpec extends ScalaCheckSuite with ParserSuite { test("invalid field terms converted as text".ignore) { assertEquals( - Query.parse("projectId:"), - Right(Query(Segment.Text("projectId:"))) + Query.parse("id:"), + Right(Query(Segment.Text("id:"))) ) assertEquals( Query.parse("projectId1"), diff --git a/modules/search-solr-client/src/main/scala/io/renku/search/solr/SearchRole.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/SearchRole.scala new file mode 100644 index 00000000..2d8cc0a0 --- /dev/null +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/SearchRole.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.renku.search.solr + +import io.renku.search.model.Id + +enum SearchRole: + case Admin + case User(id: Id) + case Anonymous + +object SearchRole: + val admin: SearchRole = Admin + val anonymous: SearchRole = Anonymous + def user(id: Id): SearchRole = User(id) diff --git a/modules/search-solr-client/src/main/scala/io/renku/search/solr/client/SearchSolrClient.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/client/SearchSolrClient.scala index 75ddc2f2..12a81387 100644 --- a/modules/search-solr-client/src/main/scala/io/renku/search/solr/client/SearchSolrClient.scala +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/client/SearchSolrClient.scala @@ -18,24 +18,31 @@ package io.renku.search.solr.client +import scala.reflect.ClassTag + import cats.data.NonEmptyList import cats.effect.{Async, Resource} -import fs2.io.net.Network import fs2.Stream +import fs2.io.net.Network + import io.bullet.borer.{Decoder, Encoder} import io.renku.search.model.Id import io.renku.search.query.Query +import io.renku.search.solr.SearchRole import io.renku.search.solr.documents.EntityDocument -import io.renku.solr.client.{QueryData, QueryResponse, SolrClient, SolrConfig} - -import scala.reflect.ClassTag +import io.renku.solr.client.* trait SearchSolrClient[F[_]]: def findById[D <: EntityDocument](id: Id)(using ct: ClassTag[D]): F[Option[D]] def insert[D: Encoder](documents: Seq[D]): F[Unit] def deleteIds(ids: NonEmptyList[Id]): F[Unit] - def queryEntity(query: Query, limit: Int, offset: Int): F[QueryResponse[EntityDocument]] def query[D: Decoder](query: QueryData): F[QueryResponse[D]] + def queryEntity( + role: SearchRole, + query: Query, + limit: Int, + offset: Int + ): F[QueryResponse[EntityDocument]] def queryAll[D: Decoder](query: QueryData): Stream[F, D] object SearchSolrClient: diff --git a/modules/search-solr-client/src/main/scala/io/renku/search/solr/client/SearchSolrClientImpl.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/client/SearchSolrClientImpl.scala index 74b789b3..6891d4ad 100644 --- a/modules/search-solr-client/src/main/scala/io/renku/search/solr/client/SearchSolrClientImpl.scala +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/client/SearchSolrClientImpl.scala @@ -18,21 +18,23 @@ package io.renku.search.solr.client +import scala.reflect.ClassTag + import cats.data.NonEmptyList import cats.effect.Async import cats.syntax.all.* import fs2.Stream + import io.bullet.borer.{Decoder, Encoder} import io.renku.search.model.Id import io.renku.search.query.Query +import io.renku.search.solr.SearchRole import io.renku.search.solr.documents.EntityDocument import io.renku.search.solr.query.LuceneQueryInterpreter import io.renku.search.solr.schema.EntityDocumentSchema +import io.renku.solr.client._ import io.renku.solr.client.facet.{Facet, Facets} import io.renku.solr.client.schema.FieldName -import io.renku.solr.client.{QueryData, QueryResponse, QueryString, SolrClient} - -import scala.reflect.ClassTag private class SearchSolrClientImpl[F[_]: Async](solrClient: SolrClient[F]) extends SearchSolrClient[F]: @@ -52,12 +54,13 @@ private class SearchSolrClientImpl[F[_]: Async](solrClient: SolrClient[F]) solrClient.deleteIds(ids.map(_.value)).void override def queryEntity( + role: SearchRole, query: Query, limit: Int, offset: Int ): F[QueryResponse[EntityDocument]] = for { - solrQuery <- interpreter.run(query) + solrQuery <- interpreter.run(role, query) _ <- logger.debug(s"Query: ${query.render} ->Solr: $solrQuery") res <- solrClient .query[EntityDocument]( diff --git a/modules/search-solr-client/src/main/scala/io/renku/search/solr/documents/EntityDocument.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/documents/EntityDocument.scala index 79f48eb8..bfa4ff94 100644 --- a/modules/search-solr-client/src/main/scala/io/renku/search/solr/documents/EntityDocument.scala +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/documents/EntityDocument.scala @@ -25,6 +25,8 @@ import io.renku.search.model.projects.MemberRole import io.renku.search.model.projects.MemberRole.{Member, Owner} import io.renku.solr.client.EncoderSupport.* import io.renku.search.model.projects.Visibility +import io.renku.search.solr.schema.EntityDocumentSchema.Fields +import io.renku.solr.client.EncoderSupport sealed trait EntityDocument: val score: Option[Double] @@ -77,12 +79,14 @@ final case class User( firstName: Option[users.FirstName] = None, lastName: Option[users.LastName] = None, name: Option[Name] = None, - score: Option[Double] = None, - visibility: Visibility = Visibility.Public + score: Option[Double] = None ) extends EntityDocument object User: val entityType: String = "User" + // auto-add a visibility:public to users + given Encoder[User] = + EncoderSupport.deriveWithAdditional(Fields.visibility.name, Visibility.Public) def nameFrom(firstName: Option[String], lastName: Option[String]): Option[Name] = Option(List(firstName, lastName).flatten.mkString(" ")) diff --git a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/LuceneQueryEncoders.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/LuceneQueryEncoders.scala index 480dc536..1e2002aa 100644 --- a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/LuceneQueryEncoders.scala +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/LuceneQueryEncoders.scala @@ -30,9 +30,9 @@ import io.renku.search.query.Comparison trait LuceneQueryEncoders: - given projectIdIs[F[_]: Applicative]: SolrTokenEncoder[F, FieldTerm.ProjectIdIs] = - SolrTokenEncoder.basic { case FieldTerm.ProjectIdIs(ids) => - SolrQuery(SolrToken.orFieldIs(Field.ProjectId, ids.map(SolrToken.fromString))) + given projectIdIs[F[_]: Applicative]: SolrTokenEncoder[F, FieldTerm.IdIs] = + SolrTokenEncoder.basic { case FieldTerm.IdIs(ids) => + SolrQuery(SolrToken.orFieldIs(Field.Id, ids.map(SolrToken.fromString))) } given nameIs[F[_]: Applicative]: SolrTokenEncoder[F, FieldTerm.NameIs] = diff --git a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/LuceneQueryInterpreter.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/LuceneQueryInterpreter.scala index a1759660..992f7a4e 100644 --- a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/LuceneQueryInterpreter.scala +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/LuceneQueryInterpreter.scala @@ -21,7 +21,9 @@ package io.renku.search.solr.query import cats.Monad import cats.effect.Sync import cats.syntax.all.* + import io.renku.search.query.Query +import io.renku.search.solr.SearchRole /** Provides conversion into solrs standard query. See * https://solr.apache.org/guide/solr/latest/query-guide/standard-query-parser.html @@ -31,9 +33,19 @@ final class LuceneQueryInterpreter[F[_]: Monad] with LuceneQueryEncoders: private val encoder = SolrTokenEncoder[F, Query] - def run(ctx: Context[F], query: Query): F[SolrQuery] = - if (query.isEmpty) SolrQuery(SolrToken.allTypes).pure[F] - else encoder.encode(ctx, query) + def run(ctx: Context[F], role: SearchRole, query: Query): F[SolrQuery] = + amendUserId(role) { + if (query.isEmpty) SolrQuery(SolrToken.allTypes).pure[F] + else encoder.encode(ctx, query) + } + + private def amendUserId(role: SearchRole)(sq: F[SolrQuery]): F[SolrQuery] = + sq.map { query => + role match + case SearchRole.Anonymous => query.asAnonymous + case SearchRole.User(id) => query.asUser(id) + case SearchRole.Admin => query + } object LuceneQueryInterpreter: def forSync[F[_]: Sync]: QueryInterpreter.WithContext[F] = diff --git a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/QueryInterpreter.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/QueryInterpreter.scala index 02ad7787..536a955b 100644 --- a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/QueryInterpreter.scala +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/QueryInterpreter.scala @@ -19,14 +19,15 @@ package io.renku.search.solr.query import io.renku.search.query.Query +import io.renku.search.solr.SearchRole trait QueryInterpreter[F[_]]: - def run(ctx: Context[F], q: Query): F[SolrQuery] + def run(ctx: Context[F], role: SearchRole, q: Query): F[SolrQuery] object QueryInterpreter: trait WithContext[F[_]]: - def run(q: Query): F[SolrQuery] + def run(role: SearchRole, q: Query): F[SolrQuery] def withContext[F[_]](qi: QueryInterpreter[F], ctx: Context[F]): WithContext[F] = new WithContext[F]: - def run(q: Query) = qi.run(ctx, q) + def run(role: SearchRole, q: Query) = qi.run(ctx, role, q) diff --git a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/SolrQuery.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/SolrQuery.scala index 58a15cbe..e63983a6 100644 --- a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/SolrQuery.scala +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/SolrQuery.scala @@ -20,6 +20,8 @@ package io.renku.search.solr.query import cats.Monoid import cats.syntax.all.* + +import io.renku.search.model.Id import io.renku.search.query.Order import io.renku.solr.client.SolrSort @@ -31,6 +33,12 @@ final case class SolrQuery( def ++(next: SolrQuery): SolrQuery = SolrQuery(query && next.query, sort ++ next.sort) + def asAnonymous: SolrQuery = + SolrQuery(query.parens && SolrToken.publicOnly, sort) + + def asUser(id: Id): SolrQuery = + SolrQuery(query.parens && SolrToken.forUser(id), sort) + object SolrQuery: val empty: SolrQuery = SolrQuery(SolrToken.empty, SolrSort.empty) diff --git a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/SolrToken.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/SolrToken.scala index f900370a..3b95417f 100644 --- a/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/SolrToken.scala +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/query/SolrToken.scala @@ -18,18 +18,19 @@ package io.renku.search.solr.query +import java.time.Instant + import cats.Monoid import cats.data.NonEmptyList import cats.syntax.all.* import io.renku.search.model.{EntityType, Id} + import io.renku.search.model.projects.Visibility import io.renku.search.query.{Comparison, Field} import io.renku.search.solr.documents.{Project as SolrProject, User as SolrUser} import io.renku.search.solr.schema.EntityDocumentSchema.Fields as SolrField import io.renku.solr.client.schema.FieldName -import java.time.Instant - opaque type SolrToken = String object SolrToken: @@ -44,7 +45,7 @@ object SolrToken: def fromField(field: Field): SolrToken = (field match - case Field.ProjectId => SolrField.id + case Field.Id => SolrField.id case Field.Name => SolrField.name case Field.Slug => SolrField.slug case Field.Visibility => SolrField.visibility @@ -76,6 +77,15 @@ object SolrToken: val allTypes: SolrToken = fieldIs(Field.Type, "*") + val publicOnly: SolrToken = + fieldIs(Field.Visibility, fromVisibility(Visibility.Public)) + + def ownerIs(id: Id): SolrToken = SolrField.owners.name === fromString(id.value) + def memberIs(id: Id): SolrToken = SolrField.members.name === fromString(id.value) + + def forUser(id: Id): SolrToken = + Seq(publicOnly, ownerIs(id), memberIs(id)).foldOr + private def fieldOp(field: Field, op: Comparison, value: SolrToken): SolrToken = val cmp = fromComparison(op) val f = fromField(field) @@ -107,6 +117,7 @@ object SolrToken: def &&(next: SolrToken): SolrToken = andMonoid.combine(self, next) def ||(next: SolrToken): SolrToken = orMonoid.combine(self, next) def ===(next: SolrToken): SolrToken = self ~ Comparison.Is.token ~ next + def parens: SolrToken = "(" ~ self ~ ")" extension (self: Comparison) def token: SolrToken = fromComparison(self) diff --git a/modules/search-solr-client/src/test/scala/io/renku/search/solr/client/SearchSolrClientSpec.scala b/modules/search-solr-client/src/test/scala/io/renku/search/solr/client/SearchSolrClientSpec.scala index 401077fa..2073be82 100644 --- a/modules/search-solr-client/src/test/scala/io/renku/search/solr/client/SearchSolrClientSpec.scala +++ b/modules/search-solr-client/src/test/scala/io/renku/search/solr/client/SearchSolrClientSpec.scala @@ -25,6 +25,7 @@ import io.bullet.borer.derivation.MapBasedCodecs.deriveDecoder import io.renku.search.GeneratorSyntax.* import io.renku.search.model.users import io.renku.search.query.Query +import io.renku.search.solr.SearchRole import io.renku.search.solr.client.SolrDocumentGenerators.* import io.renku.search.solr.documents.EntityOps.* import io.renku.search.solr.documents.{EntityDocument, Project, User} @@ -40,7 +41,12 @@ class SearchSolrClientSpec extends CatsEffectSuite with SearchSolrSpec: projectDocumentGen("solr-project", "solr project description").generateOne for { _ <- client.insert(Seq(project.widen)) - qr <- client.queryEntity(Query.parse("solr").toOption.get, 10, 0) + qr <- client.queryEntity( + SearchRole.Admin, + Query.parse("solr").toOption.get, + 10, + 0 + ) _ = assert(qr.responseBody.docs.map(_.noneScore) contains project) gr <- client.findById[Project](project.id) _ = assert(gr contains project) @@ -53,7 +59,12 @@ class SearchSolrClientSpec extends CatsEffectSuite with SearchSolrSpec: val user = userDocumentGen.generateOne.copy(firstName = firstName.some) for { _ <- client.insert(Seq(user.widen)) - qr <- client.queryEntity(Query.parse(firstName.value).toOption.get, 10, 0) + qr <- client.queryEntity( + SearchRole.Admin, + Query.parse(firstName.value).toOption.get, + 10, + 0 + ) _ = assert(qr.responseBody.docs.map(_.noneScore) contains user) gr <- client.findById[User](user.id) _ = assert(gr contains user) diff --git a/modules/search-solr-client/src/test/scala/io/renku/search/solr/client/SolrDocumentGenerators.scala b/modules/search-solr-client/src/test/scala/io/renku/search/solr/client/SolrDocumentGenerators.scala index a3b4e964..b22a870b 100644 --- a/modules/search-solr-client/src/test/scala/io/renku/search/solr/client/SolrDocumentGenerators.scala +++ b/modules/search-solr-client/src/test/scala/io/renku/search/solr/client/SolrDocumentGenerators.scala @@ -25,6 +25,7 @@ import io.renku.search.model.ModelGenerators.* import io.renku.search.solr.documents.* import org.scalacheck.Gen import org.scalacheck.cats.implicits.* +import io.renku.search.model.projects.Visibility object SolrDocumentGenerators extends SolrDocumentGenerators @@ -40,8 +41,12 @@ trait SolrDocumentGenerators: s"proj desc $differentiator" ) - def projectDocumentGen(name: String, desc: String): Gen[Project] = - (projectIdGen, idGen, projectVisibilityGen, projectCreationDateGen) + def projectDocumentGen( + name: String, + desc: String, + visibilityGen: Gen[Visibility] = projectVisibilityGen + ): Gen[Project] = + (projectIdGen, idGen, visibilityGen, projectCreationDateGen) .mapN((projectId, creatorId, visibility, creationDate) => Project( projectId, diff --git a/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/AuthTestData.scala b/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/AuthTestData.scala new file mode 100644 index 00000000..078cd726 --- /dev/null +++ b/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/AuthTestData.scala @@ -0,0 +1,123 @@ +/* + * Copyright 2024 Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.renku.search.solr.query + +import cats.effect.IO +import cats.syntax.all.* + +import io.renku.search.GeneratorSyntax.* +import io.renku.search.model.Id +import io.renku.search.model.projects.MemberRole +import io.renku.search.model.projects.Visibility +import io.renku.search.query.Query +import io.renku.search.solr.client.SolrDocumentGenerators +import io.renku.search.solr.documents.* +import org.scalacheck.Gen +import org.scalacheck.cats.implicits.* + +final case class AuthTestData( + user1: User, + user2: User, + user3: User, + projects: Map[AuthTestData.UserProjectKey, Project] +): + val users = List(user1, user2, user3) + val all: List[EntityDocument] = users ++ projects.values.toList + val user1PublicProject: Project = projects(user1.id -> Visibility.Public) + val user2PublicProject: Project = projects(user2.id -> Visibility.Public) + val user3PublicProject: Project = projects(user3.id -> Visibility.Public) + val user1PrivateProject: Project = projects(user1.id -> Visibility.Private) + val user2PrivateProject: Project = projects(user2.id -> Visibility.Private) + val user3PrivateProject: Project = projects(user3.id -> Visibility.Private) + + def modifyProject(key: AuthTestData.UserProjectKey)( + f: Project => Project + ): AuthTestData = + val p = f(projects(key)) + copy(projects = projects.updated(key, p)) + + def queryAll = + Query(Query.Segment.idIs(all.head.id.value, all.tail.map(_.id.value): _*)) + + def user1EntityIds = + users.map(_.id) ++ List( + user1PublicProject, + user1PrivateProject, + user2PublicProject, + user2PrivateProject, + user3PublicProject + ).map(_.id) + + def user2EntityIds = + users.map(_.id) ++ List( + user1PublicProject, + user2PublicProject, + user2PrivateProject, + user3PublicProject, + user3PrivateProject + ).map(_.id) + + def user3EntityIds = + users.map(_.id) ++ List( + user1PublicProject, + user2PublicProject, + user3PublicProject, + user3PrivateProject + ).map(_.id) + + def publicEntityIds = + users.map(_.id) ++ List( + user1PublicProject, + user2PublicProject, + user3PublicProject + ).map(_.id) + + private def setupRelations = + // user1 is member of user2 private project + modifyProject(user2.id -> Visibility.Private)( + _.addMember(user1.id, MemberRole.Member) + ) + // user2 is owner of user3 private project + .modifyProject(user3.id -> Visibility.Private)( + _.addMember(user2.id, MemberRole.Owner) + ) + +object AuthTestData: + private type UserProjectKey = (Id, Visibility) + private def projectGen(user: User, vis: Visibility) = + SolrDocumentGenerators + .projectDocumentGen( + s"user${user.id}-${vis.name}-proj", + "description", + Gen.const(vis) + ) + .map(p => (user.id, vis) -> p.copy(owners = List(user.id))) + + val generator: Gen[AuthTestData] = for { + u1 <- SolrDocumentGenerators.userDocumentGen + u2 <- SolrDocumentGenerators.userDocumentGen + u3 <- SolrDocumentGenerators.userDocumentGen + projects <- Visibility.values.toList + .flatMap(v => List(u1, u2, u3).map(_ -> v)) + .traverse { case (user, vis) => + projectGen(user, vis) + } + } yield AuthTestData(u1, u2, u3, projects.toMap).setupRelations + + def generate: IO[AuthTestData] = IO(generator.generateOne) diff --git a/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/LuceneQueryInterpreterSpec.scala b/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/LuceneQueryInterpreterSpec.scala index af9db3e9..579acea9 100644 --- a/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/LuceneQueryInterpreterSpec.scala +++ b/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/LuceneQueryInterpreterSpec.scala @@ -18,32 +18,35 @@ package io.renku.search.solr.query +import java.time.Instant +import java.time.ZoneId + import cats.Id import cats.effect.IO import cats.syntax.all.* + import io.bullet.borer.{Decoder, Reader} +import io.renku.search.LoggingConfigure +import io.renku.search.model +import io.renku.search.model.EntityType import io.renku.search.query.Query +import io.renku.search.query.QueryGenerators +import io.renku.search.solr.SearchRole +import io.renku.search.solr.client.SearchSolrSpec +import io.renku.search.solr.schema.EntityDocumentSchema.Fields import io.renku.search.solr.schema.Migrations -import io.renku.solr.client.{QueryData, QueryString} import io.renku.solr.client.migration.SchemaMigrator -import io.renku.solr.client.util.SolrSpec +import io.renku.solr.client.{QueryData, QueryString} import munit.CatsEffectSuite - -import java.time.Instant -import java.time.ZoneId -import io.renku.search.query.QueryGenerators import munit.ScalaCheckEffectSuite -import org.scalacheck.effect.PropF -import io.renku.search.LoggingConfigure -import io.renku.search.solr.schema.EntityDocumentSchema.Fields -import io.renku.search.model.EntityType import org.scalacheck.Test.Parameters +import org.scalacheck.effect.PropF class LuceneQueryInterpreterSpec extends CatsEffectSuite with LoggingConfigure with ScalaCheckEffectSuite - with SolrSpec: + with SearchSolrSpec: override protected lazy val coreName: String = server.testCoreName2 @@ -56,22 +59,44 @@ class LuceneQueryInterpreterSpec () } - def query(s: String | Query): QueryData = + def query(s: String | Query, role: SearchRole = SearchRole.Admin): QueryData = val userQuery: Query = s match case str: String => Query.parse(str).fold(sys.error, identity) case qq: Query => qq val ctx = Context.fixed[Id](Instant.EPOCH, ZoneId.of("UTC")) - val q = LuceneQueryInterpreter[Id].run(ctx, userQuery) + val q = LuceneQueryInterpreter[Id].run(ctx, role, userQuery) QueryData(QueryString(q.query.value, 10, 0)).withSort(q.sort) def withSolr = withSolrClient().evalTap(c => SchemaMigrator[IO](c).migrate(Migrations.all).void) + test("amend query with auth data"): + assertEquals( + query("help", SearchRole.user(model.Id("13"))).query, + "(content_all:help) AND (visibility:public OR owners:13 OR members:13)" + ) + assertEquals( + query("help", SearchRole.Anonymous).query, + "(content_all:help) AND visibility:public" + ) + assertEquals(query("help", SearchRole.Admin).query, "content_all:help") + + test("amend empty query with auth data"): + assertEquals( + query("", SearchRole.user(model.Id("13"))).query, + "(_type:*) AND (visibility:public OR owners:13 OR members:13)" + ) + assertEquals( + query("", SearchRole.Anonymous).query, + "(_type:*) AND visibility:public" + ) + assertEquals(query("", SearchRole.Admin).query, "_type:*") + test("valid content_all query"): withSolr.use { client => List("hello world", "role:test") - .map(query) + .map(query(_)) .traverse_(client.query[Unit]) } @@ -104,3 +129,33 @@ class LuceneQueryInterpreterSpec } yield () } } + + test("auth scenarios"): + withSearchSolrClient().use { solr => + for { + data <- AuthTestData.generate + _ <- solr.insert(data.all) + query = data.queryAll + + publicEntities <- solr.queryEntity(SearchRole.Anonymous, query, 50, 0) + user1Entities <- solr.queryEntity(SearchRole.User(data.user1.id), query, 50, 0) + user2Entities <- solr.queryEntity(SearchRole.User(data.user2.id), query, 50, 0) + user3Entities <- solr.queryEntity(SearchRole.User(data.user3.id), query, 50, 0) + _ = assertEquals( + publicEntities.responseBody.docs.map(_.id).toSet, + data.publicEntityIds.toSet + ) + _ = assertEquals( + user1Entities.responseBody.docs.map(_.id).toSet, + data.user1EntityIds.toSet + ) + _ = assertEquals( + user2Entities.responseBody.docs.map(_.id).toSet, + data.user2EntityIds.toSet + ) + _ = assertEquals( + user3Entities.responseBody.docs.map(_.id).toSet, + data.user3EntityIds.toSet + ) + } yield () + } diff --git a/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/SolrTokenSpec.scala b/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/SolrTokenSpec.scala index f35ce24e..f280da79 100644 --- a/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/SolrTokenSpec.scala +++ b/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/SolrTokenSpec.scala @@ -28,14 +28,14 @@ class SolrTokenSpec extends FunSuite: assertEquals( List( SolrToken.fieldIs(Field.Name, SolrToken.fromString("john")), - SolrToken.fieldIs(Field.ProjectId, SolrToken.fromString("1")) + SolrToken.fieldIs(Field.Id, SolrToken.fromString("1")) ).foldAnd, SolrToken.unsafeFromString("(name:john AND id:1)") ) assertEquals( List( SolrToken.fieldIs(Field.Name, SolrToken.fromString("john")), - SolrToken.fieldIs(Field.ProjectId, SolrToken.fromString("1")) + SolrToken.fieldIs(Field.Id, SolrToken.fromString("1")) ).foldOr, SolrToken.unsafeFromString("(name:john OR id:1)") ) diff --git a/modules/solr-client/src/main/scala/io/renku/solr/client/EncoderSupport.scala b/modules/solr-client/src/main/scala/io/renku/solr/client/EncoderSupport.scala index df7765cc..9f8953f7 100644 --- a/modules/solr-client/src/main/scala/io/renku/solr/client/EncoderSupport.scala +++ b/modules/solr-client/src/main/scala/io/renku/solr/client/EncoderSupport.scala @@ -31,11 +31,19 @@ object EncoderSupport { inline def deriveWithDiscriminator[A <: Product](using Mirror.ProductOf[A] ): Encoder[A] = - Macros.createEncoder[String, A](discriminatorField) + Macros.createEncoder[String, String, A](Map.empty, Some(discriminatorField)) + + inline def deriveWithAdditional[V: Encoder, A <: Product](key: String, value: V)(using + Mirror.ProductOf[A] + ): Encoder[A] = + Macros.createEncoder[String, V, A](Map(key -> value), None) private object Macros { - final inline def createEncoder[K: Encoder, T <: Product](discriminatorName: K)(using + final inline def createEncoder[K: Encoder, V: Encoder, T <: Product]( + additionalFields: Map[String, V], + discriminatorField: Option[K] + )(using m: Mirror.ProductOf[T] ): Encoder[T] = val names = summonLabels[m.MirroredElemLabels] @@ -45,8 +53,9 @@ object EncoderSupport { def write(w: Writer, value: T): Writer = val kind = value.asInstanceOf[Product].productPrefix val values = value.asInstanceOf[Product].productIterator.toList - w.writeMapOpen(names.size + 1) - w.writeMapMember(discriminatorName, kind) + w.writeMapOpen(names.size + additionalFields.size + discriminatorField.size) + additionalFields.foreach { case (k, v) => w.writeMapMember(k, v) } + discriminatorField.foreach(k => w.writeMapMember(k, kind)) names.zip(values).zip(encoders).foreach { case ((k, v), e) => w.writeMapMember(k, v)(Encoder[String], e.asInstanceOf[Encoder[Any]]) } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 1e9c8f4c..0d49a622 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -23,8 +23,13 @@ object Dependencies { val scribe = "3.13.0" val sttpApiSpec = "0.7.4" val tapir = "1.9.11" + val jwtScala = "10.0.0"; } + val jwtScala = Seq( + "com.github.jwt-scala" %% "jwt-core" % V.jwtScala + ) + val catsScalaCheck = Seq( "io.chrisdavenport" %% "cats-scalacheck" % V.catsScalaCheck )