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 index efd8cfd2..acb153c7 100644 --- a/modules/jwt/src/main/scala/io/renku/search/jwt/JwtBorer.scala +++ b/modules/jwt/src/main/scala/io/renku/search/jwt/JwtBorer.scala @@ -26,25 +26,27 @@ import io.bullet.borer.Json import pdi.jwt.* class JwtBorer(override val clock: Clock) - extends JwtCore[JwtHeader, JwtClaim] + extends JwtCore[JwtHeader, RenkuToken] 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 parseClaim(claim: String): RenkuToken = + Json.decode(claim.getBytes).to[RenkuToken].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 + protected def extractExpiration(claim: RenkuToken): Option[Long] = + claim.expirationTime.map(_.getEpochSecond) + protected def extractNotBefore(claim: RenkuToken): Option[Long] = + claim.notBefore.map(_.getEpochSecond()) - def decodeAllNoSignatureCheck(token: String): Try[(JwtHeader, JwtClaim, String)] = + def decodeAllNoSignatureCheck(token: String): Try[(JwtHeader, RenkuToken, String)] = decodeAll(token, noSigOptions) - def decodeNoSignatureCheck(token: String): Try[JwtClaim] = + def decodeNoSignatureCheck(token: String): Try[RenkuToken] = decode(token, noSigOptions) object JwtBorer extends JwtBorer(Clock.systemUTC()): diff --git a/modules/jwt/src/main/scala/io/renku/search/jwt/RenkuToken.scala b/modules/jwt/src/main/scala/io/renku/search/jwt/RenkuToken.scala new file mode 100644 index 00000000..8ca34c67 --- /dev/null +++ b/modules/jwt/src/main/scala/io/renku/search/jwt/RenkuToken.scala @@ -0,0 +1,64 @@ +/* + * 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.Instant + +import io.bullet.borer.NullOptions.given +import io.bullet.borer.derivation.MapBasedCodecs +import io.bullet.borer.derivation.key +import io.bullet.borer.{Decoder, Encoder} +import io.renku.search.jwt.RenkuToken.{Access, AccountRoles} + +final case class RenkuToken( + @key("exp") expirationTime: Option[Instant] = None, + @key("iat") issuedAt: Option[Instant] = None, + @key("nbf") notBefore: Option[Instant] = None, + @key("auth_time") authTime: Option[Instant] = None, + @key("jti") jwtId: Option[String] = None, + @key("iss") issuer: Option[String] = None, + @key("sub") subject: Option[String] = None, + @key("typ") tokenType: Option[String] = None, + @key("realm_access") realmAccess: Option[Access] = None, + @key("resource_access") resourceAccess: Option[AccountRoles] = None, + @key("scope") scopeStr: Option[String] = None, + name: Option[String] = None, + email: Option[String] = None, + @key("email_verified") emailVerified: Boolean = false, + groups: Set[String] = Set.empty, + @key("preferred_username") preferredUsername: Option[String] = None +): + + lazy val isAdmin = + realmAccess.exists(_.roles.contains("renku-admin")) + +object RenkuToken: + final case class Access(roles: Set[String] = Set.empty) + final case class AccountRoles(account: Access = Access()) + + private given Decoder[Instant] = Decoder.forLong.map(Instant.ofEpochSecond(_)) + private given Encoder[Instant] = Encoder.forLong.contramap(_.getEpochSecond()) + + private given Decoder[Access] = MapBasedCodecs.deriveDecoder + private given Encoder[Access] = MapBasedCodecs.deriveEncoder + private given Decoder[AccountRoles] = MapBasedCodecs.deriveDecoder + private given Encoder[AccountRoles] = MapBasedCodecs.deriveEncoder + + given Decoder[RenkuToken] = MapBasedCodecs.deriveDecoder + given Encoder[RenkuToken] = MapBasedCodecs.deriveEncoder diff --git a/modules/jwt/src/test/resources/jwt1.json b/modules/jwt/src/test/resources/jwt1.json new file mode 100644 index 00000000..0d530149 --- /dev/null +++ b/modules/jwt/src/test/resources/jwt1.json @@ -0,0 +1,49 @@ +{ + "exp": 1716905204, + "iat": 1716903404, + "auth_time": 1716903404, + "jti": "488ecd30-a7bb-473b-9eac-3ec43d5447a6", + "iss": "https://ci-renku-3646.dev.renku.ch/auth/realms/Renku", + "aud": [ + "renku", + "account" + ], + "sub": "48c85c75-b407-4259-b06b-a611e71df5f0", + "typ": "Bearer", + "azp": "renku-ui", + "session_state": "77bd8cc2-8230-4ec8-82d3-617bc3bf8013", + "acr": "1", + "allowed-origins": [ + "https://ci-renku-3646.dev.renku.ch/*" + ], + "realm_access": { + "roles": [ + "offline_access", + "default-roles-renku", + "uma_authorization" + ] + }, + "resource_access": { + "account": { + "roles": [ + "manage-account", + "manage-account-links", + "view-profile" + ] + } + }, + "scope": "openid microprofile-jwt email profile", + "sid": "77bd8cc2-8230-4ec8-82d3-617bc3bf8013", + "upn": "eike.kettner@sdsc.ethz.ch", + "email_verified": false, + "name": "Eike Kettner", + "groups": [ + "offline_access", + "default-roles-renku", + "uma_authorization" + ], + "preferred_username": "eike.kettner@sdsc.ethz.ch", + "given_name": "Eike", + "family_name": "Kettner", + "email": "eike.kettner@sdsc.ethz.ch" +} diff --git a/modules/jwt/src/test/resources/jwt2.json b/modules/jwt/src/test/resources/jwt2.json new file mode 100644 index 00000000..855d8eaf --- /dev/null +++ b/modules/jwt/src/test/resources/jwt2.json @@ -0,0 +1,59 @@ +{ + "exp": 1716906437, + "iat": 1716904637, + "auth_time": 1716904637, + "jti": "349d20d3-5ac2-4df6-b5e9-44bc8426b0ed", + "iss": "https://ci-renku-3646.dev.renku.ch/auth/realms/Renku", + "aud": [ + "renku", + "realm-management", + "account" + ], + "sub": "48c85c75-b407-4259-b06b-a611e71df5f0", + "typ": "Bearer", + "azp": "renku-ui", + "session_state": "6f365bb5-2b3b-403c-ab97-e026292f5269", + "acr": "1", + "allowed-origins": [ + "https://ci-renku-3646.dev.renku.ch/*" + ], + "realm_access": { + "roles": [ + "offline_access", + "renku-admin", + "default-roles-renku", + "uma_authorization" + ] + }, + "resource_access": { + "realm-management": { + "roles": [ + "view-users", + "query-groups", + "query-users" + ] + }, + "account": { + "roles": [ + "manage-account", + "manage-account-links", + "view-profile" + ] + } + }, + "scope": "openid microprofile-jwt email profile", + "sid": "6f365bb5-2b3b-403c-ab97-e026292f5269", + "upn": "eike.kettner@sdsc.ethz.ch", + "email_verified": false, + "name": "Eike Kettner", + "groups": [ + "offline_access", + "renku-admin", + "default-roles-renku", + "uma_authorization" + ], + "preferred_username": "eike.kettner@sdsc.ethz.ch", + "given_name": "Eike", + "family_name": "Kettner", + "email": "eike.kettner@sdsc.ethz.ch" +} 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 index 4e9c6248..2e4de802 100644 --- a/modules/jwt/src/test/scala/io/renku/search/jwt/JwtBorerSpec.scala +++ b/modules/jwt/src/test/scala/io/renku/search/jwt/JwtBorerSpec.scala @@ -18,6 +18,8 @@ package io.renku.search.jwt +import java.time.Instant + import munit.FunSuite import pdi.jwt.Jwt import pdi.jwt.JwtAlgorithm @@ -30,6 +32,12 @@ class JwtBorerSpec extends FunSuite: val regexDecode = Jwt.decodeAll(exampleToken, secret).get + val expectClaim = RenkuToken( + issuedAt = Some(Instant.ofEpochSecond(1516239022L)), + subject = Some("my-user-id"), + name = Some("John Doe") + ) + test("decode"): val (header, claim, _) = JwtBorer.decodeAll(exampleToken, secret).get assertEquals(header.algorithm, Some(JwtAlgorithm.HS256)) @@ -37,14 +45,14 @@ class JwtBorerSpec extends FunSuite: assertEquals(header.keyId, None) assertEquals(header.contentType, None) - assertEquals(claim.subject, Some("my-user-id")) - assertEquals(claim.issuedAt, Some(1516239022L)) + assertEquals(claim.subject, expectClaim.subject) + assertEquals(claim.issuedAt, expectClaim.issuedAt) assertEquals(header, regexDecode._1) - assertEquals(claim, regexDecode._2.withContent("{}")) + assertEquals(claim, expectClaim) 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("{}")) + assertEquals(claim, expectClaim) diff --git a/modules/jwt/src/test/scala/io/renku/search/jwt/RenkuTokenSpec.scala b/modules/jwt/src/test/scala/io/renku/search/jwt/RenkuTokenSpec.scala new file mode 100644 index 00000000..ff5c459b --- /dev/null +++ b/modules/jwt/src/test/scala/io/renku/search/jwt/RenkuTokenSpec.scala @@ -0,0 +1,38 @@ +/* + * 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 scala.io.Source + +import io.bullet.borer.Json +import munit.FunSuite + +class RenkuTokenSpec extends FunSuite: + + test("decode jwt payload"): + val jsonStr = Source.fromResource("jwt1.json").mkString + val decoded = Json.decode(jsonStr.getBytes).to[RenkuToken].value + assertEquals(decoded.subject, Some("48c85c75-b407-4259-b06b-a611e71df5f0")) + assert(decoded.isAdmin == false) + + test("decode jwt payload (admin)"): + val jsonStr = Source.fromResource("jwt2.json").mkString + val decoded = Json.decode(jsonStr.getBytes).to[RenkuToken].value + assertEquals(decoded.subject, Some("48c85c75-b407-4259-b06b-a611e71df5f0")) + assert(decoded.isAdmin == true) diff --git a/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/DefaultJwtVerify.scala b/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/DefaultJwtVerify.scala index 26bf53c1..a9e50e2d 100644 --- a/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/DefaultJwtVerify.scala +++ b/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/DefaultJwtVerify.scala @@ -27,12 +27,11 @@ import cats.syntax.all.* import io.renku.openid.keycloak.DefaultJwtVerify.State import io.renku.search.http.borer.BorerEntityJsonCodec -import io.renku.search.jwt.JwtBorer +import io.renku.search.jwt.{JwtBorer, RenkuToken} import org.http4s.Method.GET import org.http4s.Uri import org.http4s.client.Client import org.http4s.client.dsl.Http4sClientDsl -import pdi.jwt.JwtClaim final class DefaultJwtVerify[F[_]: Async]( client: Client[F], @@ -45,10 +44,10 @@ final class DefaultJwtVerify[F[_]: Async]( private val logger = scribe.cats.effect[F] - def tryDecode(issuer: Uri, token: String): EitherT[F, JwtError, JwtClaim] = + def tryDecode(issuer: Uri, token: String): EitherT[F, JwtError, RenkuToken] = EitherT(state.get.flatMap(_.validate(issuer, clock, token))) - def tryDecodeOnly(token: String): F[Either[JwtError, JwtClaim]] = + def tryDecodeOnly(token: String): F[Either[JwtError, RenkuToken]] = JwtBorer.create[F](using clock).map { jwtb => jwtb .decodeNoSignatureCheck(token) @@ -56,7 +55,7 @@ final class DefaultJwtVerify[F[_]: Async]( .leftMap(ex => JwtError.JwtValidationError(token, None, None, ex)) } - def verify(token: String): F[Either[JwtError, JwtClaim]] = + def verify(token: String): F[Either[JwtError, RenkuToken]] = tryDecodeOnly(token).flatMap { case Left(err) => Left(err).pure[F] case Right(c) if !config.enableSignatureValidation => Right(c).pure[F] @@ -74,7 +73,7 @@ final class DefaultJwtVerify[F[_]: Async]( def updateCache(issuer: Uri, token: String)( jwtError: JwtError - ): F[Either[JwtError, JwtClaim]] = + ): F[Either[JwtError, RenkuToken]] = jwtError match case JwtError.JwtValidationError(_, _, Some(claim), _) => (for @@ -88,7 +87,7 @@ final class DefaultJwtVerify[F[_]: Async]( yield result).value case e => Left(e).pure[F] - def readIssuer(claim: JwtClaim): Either[JwtError, Uri] = + def readIssuer(claim: RenkuToken): Either[JwtError, Uri] = for issuerUri <- Uri .fromString(claim.issuer.getOrElse("")) @@ -96,7 +95,7 @@ final class DefaultJwtVerify[F[_]: Async]( _ <- config.checkIssuerUrl(issuerUri) yield issuerUri - def fetchJWKSGuarded(issuer: Uri, claim: JwtClaim): EitherT[F, JwtError, Jwks] = + def fetchJWKSGuarded(issuer: Uri, claim: RenkuToken): EitherT[F, JwtError, Jwks] = for _ <- checkLastUpdateDelay(issuer, config.minRequestDelay) result <- fetchJWKS(issuer, claim) @@ -110,7 +109,7 @@ final class DefaultJwtVerify[F[_]: Async]( } ) - def fetchJWKS(issuerUri: Uri, claim: JwtClaim): EitherT[F, JwtError, Jwks] = + def fetchJWKS(issuerUri: Uri, claim: RenkuToken): EitherT[F, JwtError, Jwks] = for _ <- EitherT.right( clock.monotonic.flatMap(t => state.update(_.setLastUpdate(issuerUri, t))) @@ -147,7 +146,7 @@ object DefaultJwtVerify: issuer: Uri, clock: Clock[F], token: String - ): F[Either[JwtError, JwtClaim]] = + ): F[Either[JwtError, RenkuToken]] = get(issuer).jwks.validate(clock)(token) def setLastUpdate(issuer: Uri, time: FiniteDuration): State = diff --git a/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/Jwks.scala b/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/Jwks.scala index 6ee5833c..7020aeaf 100644 --- a/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/Jwks.scala +++ b/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/Jwks.scala @@ -26,8 +26,7 @@ import cats.syntax.all.* import io.bullet.borer.derivation.MapBasedCodecs import io.bullet.borer.{Decoder, Encoder} -import io.renku.search.jwt.JwtBorer -import pdi.jwt.JwtClaim +import io.renku.search.jwt.{JwtBorer, RenkuToken} import pdi.jwt.JwtHeader final case class Jwks( @@ -46,7 +45,9 @@ final case class Jwks( pk <- wk.toPublicKey yield pk - def validate[F[_]: Monad](clock: Clock[F])(jwt: String): F[Either[JwtError, JwtClaim]] = + def validate[F[_]: Monad]( + clock: Clock[F] + )(jwt: String): F[Either[JwtError, RenkuToken]] = JwtBorer.create[F](using clock).map { jwtCheck => for (header, claim, _) <- jwtCheck diff --git a/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/JwtError.scala b/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/JwtError.scala index d5134bc0..14c33519 100644 --- a/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/JwtError.scala +++ b/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/JwtError.scala @@ -21,8 +21,8 @@ package io.renku.openid.keycloak import scala.concurrent.duration.FiniteDuration import io.renku.search.common.UrlPattern +import io.renku.search.jwt.RenkuToken import org.http4s.Uri -import pdi.jwt.JwtClaim import pdi.jwt.JwtHeader sealed trait JwtError extends Throwable @@ -57,7 +57,7 @@ object JwtError: final case class JwtValidationError( jwt: String, header: Option[JwtHeader], - claim: Option[JwtClaim], + claim: Option[RenkuToken], cause: Throwable ) extends RuntimeException( s"Error decoding token (header=$header, claimExists=${claim.isDefined}): ${cause.getMessage}", diff --git a/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/JwtVerify.scala b/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/JwtVerify.scala index e0703466..0c8707fa 100644 --- a/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/JwtVerify.scala +++ b/modules/openid-keycloak/src/main/scala/io/renku/openid/keycloak/JwtVerify.scala @@ -18,15 +18,25 @@ package io.renku.openid.keycloak +import cats.Applicative import cats.effect.* +import io.renku.search.jwt.RenkuToken import org.http4s.client.Client -import pdi.jwt.JwtClaim trait JwtVerify[F[_]]: - def verify(token: String): F[Either[JwtError, JwtClaim]] + def verify(token: String): F[Either[JwtError, RenkuToken]] object JwtVerify: def apply[F[_]: Async](client: Client[F], config: JwtVerifyConfig): F[JwtVerify[F]] = val clock = Clock[F] DefaultJwtVerify[F](client, clock, config) + + def fixed[F[_]: Applicative](result: JwtError | RenkuToken): JwtVerify[F] = + new JwtVerify[F] { + def verify(token: String): F[Either[JwtError, RenkuToken]] = + Applicative[F].pure(result match + case a: JwtError => Left(a) + case b: RenkuToken => Right(b) + ) + } diff --git a/modules/search-api/src/main/scala/io/renku/search/api/Routes.scala b/modules/search-api/src/main/scala/io/renku/search/api/Routes.scala index d635f521..59e0503a 100644 --- a/modules/search-api/src/main/scala/io/renku/search/api/Routes.scala +++ b/modules/search-api/src/main/scala/io/renku/search/api/Routes.scala @@ -23,6 +23,7 @@ import cats.syntax.all.* import fs2.io.net.Network import io.renku.openid.keycloak.{JwtVerify, JwtVerifyConfig} +import io.renku.search.api.auth.Authenticate import io.renku.search.api.routes.* import io.renku.search.http.ClientBuilder import io.renku.search.http.RetryConfig @@ -50,21 +51,23 @@ final class Routes[F[_]: Async: Network]( solrConfig: SolrConfig, jwtVerifyConfig: JwtVerifyConfig ): + private val logger = scribe.cats.effect[F] private val prefix = "/search" private val makeJwtVerify = ClientBuilder(EmberClientBuilder.default[F]) .withDefaultRetry(RetryConfig.default) - .withLogging(logBody = false, scribe.cats.effect[F]) + .withLogging(logBody = false, logger) .build .evalMap(JwtVerify(_, jwtVerifyConfig)) private val makeSearchRoutes = for jwtVerify <- makeJwtVerify + auth = Authenticate[F](jwtVerify, logger) api <- SearchApi[F](solrConfig) - yield SearchRoutes[F](api, jwtVerify) + yield SearchRoutes[F](api, auth) private def searchHttpRoutes(searchRoutes: SearchRoutes[F]) = Router[F]( diff --git a/modules/search-api/src/main/scala/io/renku/search/api/auth/Authenticate.scala b/modules/search-api/src/main/scala/io/renku/search/api/auth/Authenticate.scala new file mode 100644 index 00000000..a5421f62 --- /dev/null +++ b/modules/search-api/src/main/scala/io/renku/search/api/auth/Authenticate.scala @@ -0,0 +1,45 @@ +/* + * 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.auth + +import cats.Monad +import cats.data.EitherT +import cats.syntax.all.* + +import io.renku.openid.keycloak.JwtVerify +import io.renku.search.api.data.* +import scribe.Scribe + +trait Authenticate[F[_]]: + def apply(token: AuthToken): F[Either[String, AuthContext]] + +object Authenticate: + + def apply[F[_]: Monad](verify: JwtVerify[F], logger: Scribe[F]): Authenticate[F] = + new Authenticate[F] { + def apply(token: AuthToken): F[Either[String, AuthContext]] = + token match + case AuthToken.None => Right(AuthContext.anonymous).pure[F] + case AuthToken.AnonymousId(id) => + Right(AuthContext.anonymousId(id.value)).pure[F] + case AuthToken.JwtToken(token) => + EitherT(verify.verify(token).map(ClaimToContext.from)).leftSemiflatMap { + err => logger.warn(err.sanitized, err.cause).as(err.sanitized) + }.value + } diff --git a/modules/search-api/src/main/scala/io/renku/search/api/auth/ClaimToContext.scala b/modules/search-api/src/main/scala/io/renku/search/api/auth/ClaimToContext.scala new file mode 100644 index 00000000..1e7dff21 --- /dev/null +++ b/modules/search-api/src/main/scala/io/renku/search/api/auth/ClaimToContext.scala @@ -0,0 +1,54 @@ +/* + * 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.auth + +import scala.util.control.NoStackTrace + +import io.renku.openid.keycloak.JwtError +import io.renku.search.api.data.AuthContext +import io.renku.search.jwt.RenkuToken +import io.renku.search.model.Id + +private[auth] object ClaimToContext: + final case class Failure(cause: Throwable, sanitized: String) + final case class ClaimHasNoSubject(claim: RenkuToken) + extends RuntimeException + with NoStackTrace + + def apply(claim: RenkuToken): Either[Failure, AuthContext] = + claim.subject + .filter(_.nonEmpty) + .map(userId => + if (claim.isAdmin) AuthContext.admin(Id(userId)) + else AuthContext.authenticated(Id(userId)) + ) + .toRight(Failure(ClaimHasNoSubject(claim), "Claim doesn't contain a subject")) + + def from(claim: Either[JwtError, RenkuToken]): Either[Failure, AuthContext] = + claim.left + .map { + case error: JwtError.TooManyValidationRequests => + Failure( + error, + "Token validation failed due to too many validation requests, please try again later!" + ) + case error => + Failure(error, "Token validation failed.") + } + .flatMap(apply) 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 index 534087b3..f7c871dd 100644 --- 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 @@ -30,52 +30,69 @@ sealed trait AuthContext: def isAnonymous: Boolean def isAuthenticated: Boolean = !isAnonymous def fold[A]( - fa: AuthContext.Authenticated => A, - fb: AuthContext.AnonymousId => A, - fc: => A + fa: AuthContext.Admin => A, + fb: AuthContext.Authenticated => A, + fc: AuthContext.AnonymousId => A, + fd: => A ): A - def authenticated: Option[AuthContext.Authenticated] def searchRole: SearchRole = - fold(a => SearchRole.user(a.userId), _ => SearchRole.Anonymous, SearchRole.Anonymous) + fold( + a => SearchRole.admin(a.userId), + 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) + def admin(userId: Id): AuthContext = Admin(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 + fa: AuthContext.Admin => A, + fb: AuthContext.Authenticated => A, + fc: AuthContext.AnonymousId => A, + fd: => A + ): A = fd } 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 + fa: AuthContext.Admin => A, + fb: AuthContext.Authenticated => A, + fc: AuthContext.AnonymousId => A, + fd: => A + ): A = fc(this) 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 + fa: AuthContext.Admin => A, + fb: AuthContext.Authenticated => A, + fc: AuthContext.AnonymousId => A, + fd: => A + ): A = fb(this) + def render = JwtBorer.encode(JwtClaim(subject = userId.value.some)) + + final case class Admin(userId: Id) extends AuthContext: + val isAnonymous = false + def fold[A]( + fa: AuthContext.Admin => A, + fb: AuthContext.Authenticated => A, + fc: AuthContext.AnonymousId => A, + fd: => A ): A = fa(this) def render = JwtBorer.encode(JwtClaim(subject = userId.value.some)) given Show[AuthContext] = Show.show { + case Admin(id) => s"Admin(${id.value})" 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/routes/SearchRoutes.scala b/modules/search-api/src/main/scala/io/renku/search/api/routes/SearchRoutes.scala index 044a2ff1..03cf55b9 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,15 +19,12 @@ package io.renku.search.api.routes import cats.effect.Async -import cats.syntax.all.* -import io.renku.openid.keycloak.JwtError -import io.renku.openid.keycloak.JwtVerify import io.renku.search.api.SearchApi +import io.renku.search.api.auth.Authenticate import io.renku.search.api.data.* import io.renku.search.api.tapir.* import io.renku.search.http.borer.TapirBorerJson -import io.renku.search.model.Id import io.renku.search.query.docs.SearchQueryManual import org.http4s.HttpRoutes import sttp.tapir.* @@ -36,7 +33,7 @@ import sttp.tapir.server.http4s.Http4sServerInterpreter import sttp.tapir.server.http4s.Http4sServerOptions import sttp.tapir.server.interceptor.cors.CORSInterceptor -final class SearchRoutes[F[_]: Async](api: SearchApi[F], jwtVerify: JwtVerify[F]) +final class SearchRoutes[F[_]: Async](api: SearchApi[F], authenticate: Authenticate[F]) extends TapirBorerJson with TapirCodecs { @@ -52,38 +49,10 @@ final class SearchRoutes[F[_]: Async](api: SearchApi[F], jwtVerify: JwtVerify[F] .out(Params.searchResult) .description(SearchQueryManual.markdown) - def authenticate(token: AuthToken): F[Either[String, AuthContext]] = token match - case AuthToken.None => Right(AuthContext.anonymous).pure[F] - case AuthToken.AnonymousId(id) => Right(AuthContext.anonymousId(id.value)).pure[F] - case AuthToken.JwtToken(token) => - jwtVerify.verify(token).flatMap { - case Right(claim) => - claim.subject - .filter(_.nonEmpty) - .map(userId => AuthContext.authenticated(Id(userId))) - .toRight(s"Claim doesn't contain a subject: $claim") - .pure[F] - case Left(error: JwtError.TooManyValidationRequests) => - logger - .warn("Token validation failed!", error) - .as( - Left( - "Token validation failed due to too many validation requests, please try again later!" - ) - ) - - case Left(error) => - logger - .warn("Token validation failed!", error) - .as( - Left("Token validation failed.") - ) - } - val endpoints: List[ServerEndpoint[Any, F]] = List( searchEndpointGet - .serverSecurityLogic(authenticate) + .serverSecurityLogic(authenticate.apply) .serverLogic(api.query) ) 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 1d15e4a4..0dda5ef6 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 @@ -70,9 +70,11 @@ object Params extends TapirCodecs with TapirBorerJson { borerJsonBody[SearchResult].and(pagingInfo).map(_._1)(r => (r, r.pagingInfo)) private val renkuAuthIdToken: EndpointInput[Option[AuthToken.JwtToken]] = - header[Option[AuthToken.JwtToken]]("Renku-Auth-Id-Token") + auth.bearer[Option[String]]().map(_.map(t => AuthToken.JwtToken(t)))(_.map(_.token)) + private val renkuAuthAnonId: EndpointInput[Option[AuthToken.AnonymousId]] = header[Option[AuthToken.AnonymousId]]("Renku-Auth-Anon-Id") + val renkuAuth: EndpointInput[AuthToken] = (renkuAuthIdToken / renkuAuthAnonId).map { case (token, id) => token.orElse(id).getOrElse(AuthToken.None) diff --git a/modules/search-api/src/test/scala/io/renku/search/api/auth/AuthenticateSpec.scala b/modules/search-api/src/test/scala/io/renku/search/api/auth/AuthenticateSpec.scala new file mode 100644 index 00000000..fb76c7a8 --- /dev/null +++ b/modules/search-api/src/test/scala/io/renku/search/api/auth/AuthenticateSpec.scala @@ -0,0 +1,102 @@ +/* + * 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.auth + +import cats.Id +import cats.syntax.all.* + +import io.renku.openid.keycloak.JwtError +import io.renku.openid.keycloak.JwtVerify +import io.renku.search.api.data.{AuthContext, AuthToken} +import io.renku.search.jwt.RenkuToken +import io.renku.search.model +import munit.FunSuite +import scribe.{Level, LogRecord, Scribe} + +class AuthenticateSpec extends FunSuite: + + val adminToken: RenkuToken = RenkuToken( + subject = Some("admin"), + realmAccess = Some(RenkuToken.Access(Set("renku-admin"))) + ) + val userToken: RenkuToken = RenkuToken(subject = Some("user")) + val noSubjectToken: RenkuToken = RenkuToken() + val verifyError: JwtError = + JwtError.JwtValidationError("", None, None, new RuntimeException) + + def makeAuthenticate(result: RenkuToken | JwtError): AuthenticateSpec.TestAuthenticate = + val verify = JwtVerify.fixed[Id](result) + AuthenticateSpec.testAuthenticate(verify) + + test("anonymous when no token, not calling verify"): + assertEquals( + makeAuthenticate(verifyError).apply(AuthToken.None), + Right(AuthContext.anonymous) + ) + + test("anonymous when anon-id, not calling verify"): + assertEquals( + makeAuthenticate(verifyError).apply(AuthToken.AnonymousId(model.Id("123"))), + Right(AuthContext.anonymousId("123")) + ) + + test("failure when verify fails, log warning"): + val auth = makeAuthenticate(verifyError) + val result = auth(AuthToken.JwtToken("dummy")) + assert(result.isLeft) + assert(auth.logged.nonEmpty) + assertEquals(auth.logged.head.level, Level.Warn) + + test("fail when claim has no subject, log warning"): + val auth = makeAuthenticate(noSubjectToken) + val result = auth(AuthToken.JwtToken("dummy")) + assert(result.isLeft) + assert(auth.logged.nonEmpty) + assertEquals(auth.logged.head.level, Level.Warn) + + test("success with admin token"): + val ctx = makeAuthenticate(adminToken).apply(AuthToken.JwtToken("dummy")) + assertEquals( + ctx.map(_.fold(_.userId.value.some, _ => None, _ => None, None)), + adminToken.subject.asRight[String] + ) + + test("success with user token"): + val ctx = makeAuthenticate(userToken).apply(AuthToken.JwtToken("dummy")) + assertEquals( + ctx.map(_.fold(_ => None, _.userId.value.some, _ => None, None)), + userToken.subject.asRight[String] + ) + +object AuthenticateSpec: + trait TestAuthenticate extends Authenticate[Id]: + def logged: List[LogRecord] + + def testAuthenticate(verify: JwtVerify[Id]): TestAuthenticate = + new TestAuthenticate { + var records: List[LogRecord] = Nil + def logger: Scribe[Id] = new Scribe[Id] { + def log(record: scribe.LogRecord): Id[Unit] = + records = record :: records + } + val inner = Authenticate[Id](verify, logger) + + def logged: List[LogRecord] = records.reverse + def apply(token: AuthToken) = inner(token) + } diff --git a/modules/search-api/src/test/scala/io/renku/search/api/auth/ClaimToContextSpec.scala b/modules/search-api/src/test/scala/io/renku/search/api/auth/ClaimToContextSpec.scala new file mode 100644 index 00000000..4dda717a --- /dev/null +++ b/modules/search-api/src/test/scala/io/renku/search/api/auth/ClaimToContextSpec.scala @@ -0,0 +1,59 @@ +/* + * 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.auth + +import cats.syntax.all.* + +import io.renku.search.api.data.AuthContext +import io.renku.search.jwt.RenkuToken +import munit.FunSuite + +class ClaimToContextSpec extends FunSuite: + + val adminToken: RenkuToken = RenkuToken( + subject = Some("admin"), + realmAccess = Some(RenkuToken.Access(Set("renku-admin"))) + ) + val userToken: RenkuToken = RenkuToken(subject = Some("user")) + val noSubjectToken: RenkuToken = RenkuToken() + + def assertFailure(actual: ClaimToContext.Failure, expect: Throwable) = + assertEquals(actual.cause, expect) + + test("renku token without subject"): + ClaimToContext(noSubjectToken) match + case Left(err) => + assertFailure(err, ClaimToContext.ClaimHasNoSubject(noSubjectToken)) + case Right(_) => fail("expected error") + + test("renku token is admin"): + assert(adminToken.isAdmin) + ClaimToContext(adminToken) match + case Left(err) => throw err.cause + case Right(AuthContext.Admin(id)) => + assertEquals(id.value.some, adminToken.subject) + case Right(ctx) => fail(s"Invalid auth context: $ctx") + + test("renku token is user"): + assert(!userToken.isAdmin) + ClaimToContext(userToken) match + case Left(err) => throw err.cause + case Right(AuthContext.Authenticated(id)) => + assertEquals(id.value.some, userToken.subject) + case Right(ctx) => fail(s"Invalid auth context: $ctx") 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 index 2d8cc0a0..6a53d323 100644 --- 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 @@ -21,11 +21,11 @@ package io.renku.search.solr import io.renku.search.model.Id enum SearchRole: - case Admin + case Admin(id: Id) case User(id: Id) case Anonymous object SearchRole: - val admin: SearchRole = Admin + def admin(id: Id): SearchRole = Admin(id) 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/documents/EntityMembers.scala b/modules/search-solr-client/src/main/scala/io/renku/search/solr/documents/EntityMembers.scala index 064c6975..3b03c713 100644 --- a/modules/search-solr-client/src/main/scala/io/renku/search/solr/documents/EntityMembers.scala +++ b/modules/search-solr-client/src/main/scala/io/renku/search/solr/documents/EntityMembers.scala @@ -89,6 +89,13 @@ final case class EntityMembers( owners.contains(id) || editors.contains(id) || viewers.contains(id) || members.contains(id) + def isEmpty: Boolean = + MemberRole.values.forall(getMemberIds(_).isEmpty) + + def nonEmpty: Boolean = !isEmpty + + def allIds: Set[Id] = MemberRole.values.flatMap(getMemberIds).toSet + def ++(other: EntityMembers): EntityMembers = MemberRole.valuesLowerFirst.foldLeft(this) { (acc, role) => acc.addMembers(role, other.getMemberIds(role)) 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 1b75d465..b7de834a 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 @@ -67,7 +67,7 @@ trait LuceneQueryEncoders: SolrTokenEncoder.create[F, FieldTerm.RoleIs] { case (ctx, FieldTerm.RoleIs(values)) => SolrQuery { ctx.role match - case SearchRole.Admin => SolrToken.empty + case SearchRole.Admin(_) => SolrToken.empty case SearchRole.Anonymous => SolrToken.publicOnly case SearchRole.User(id) => SolrToken.roleIn(id, values) }.pure[F] 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 386f39f2..e44bd92a 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 @@ -41,7 +41,7 @@ final class LuceneQueryInterpreter[F[_]: Monad] role match case SearchRole.Anonymous => query.asAnonymous case SearchRole.User(id) => query.asUser(id) - case SearchRole.Admin => query.asAdmin + case SearchRole.Admin(_) => query.asAdmin } object LuceneQueryInterpreter: 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 abfd2ebe..dd6c143b 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 @@ -24,6 +24,9 @@ import cats.syntax.all.* import io.bullet.borer.Decoder import io.bullet.borer.derivation.MapBasedCodecs.deriveDecoder import io.renku.search.GeneratorSyntax.* +import io.renku.search.model.Id +import io.renku.search.model.ModelGenerators +import io.renku.search.model.projects.Visibility import io.renku.search.model.users import io.renku.search.query.Query import io.renku.search.solr.SearchRole @@ -46,7 +49,7 @@ class SearchSolrClientSpec extends CatsEffectSuite with SearchSolrSuite: client <- IO(searchSolrClient()) _ <- client.upsert(Seq(project.widen)) qr <- client.queryEntity( - SearchRole.Admin, + SearchRole.admin(Id("admin")), Query.parse("solr").toOption.get, 10, 0 @@ -73,7 +76,7 @@ class SearchSolrClientSpec extends CatsEffectSuite with SearchSolrSuite: client <- IO(searchSolrClient()) _ <- client.upsert(Seq(user.widen)) qr <- client.queryEntity( - SearchRole.Admin, + SearchRole.admin(Id("admin")), Query.parse(firstName.value).toOption.get, 10, 0 @@ -111,3 +114,29 @@ class SearchSolrClientSpec extends CatsEffectSuite with SearchSolrSuite: ) _ = assert(gr.responseBody.docs.map(_.id) contains user.id.value) } yield () + + test("query entities with different roles"): + for + client <- IO(searchSolrClient()) + entityMembers <- IO(entityMembersGen.suchThat(_.nonEmpty).generateOne) + project <- IO( + projectDocumentGen + .map(p => p.setMembers(entityMembers).copy(visibility = Visibility.Private)) + .generateOne + ) + _ <- client.upsertSuccess(Seq(project)) + member = entityMembers.allIds.head + nonMember <- IO(ModelGenerators.idGen.generateOne) + query = Query(Query.Segment.idIs(project.id.value)) + anonResult <- client.queryEntity(SearchRole.anonymous, query, 1, 0) + nonMemberResult <- client.queryEntity(SearchRole.user(nonMember), query, 1, 0) + memberResult <- client.queryEntity(SearchRole.user(member), query, 1, 0) + adminResult <- client.queryEntity(SearchRole.admin(Id("admin")), query, 1, 0) + + _ = assert(anonResult.responseBody.docs.isEmpty) + _ = assert(nonMemberResult.responseBody.docs.isEmpty) + _ = assertEquals(memberResult.responseBody.docs.size, 1) + _ = assertEquals(adminResult.responseBody.docs.size, 1) + _ = assertEquals(memberResult.responseBody.docs.head.id, project.id) + _ = assertEquals(adminResult.responseBody.docs.head.id, project.id) + yield () diff --git a/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/LuceneQueryEncoderSpec.scala b/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/LuceneQueryEncoderSpec.scala index 60908106..c1c98635 100644 --- a/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/LuceneQueryEncoderSpec.scala +++ b/modules/search-solr-client/src/test/scala/io/renku/search/solr/query/LuceneQueryEncoderSpec.scala @@ -36,7 +36,7 @@ class LuceneQueryEncoderSpec extends FunSuite with LuceneQueryEncoders: val refDate: Instant = Instant.parse("2024-02-27T15:34:55Z") val utc: ZoneId = ZoneId.of("UTC") - val ctx: Context[Id] = Context.fixed(refDate, utc, SearchRole.Admin) + val ctx: Context[Id] = Context.fixed(refDate, utc, SearchRole.admin(model.Id("admin"))) val createdEncoder = SolrTokenEncoder[Id, FieldTerm.Created] test("use date-max for greater-than"): @@ -100,55 +100,58 @@ class LuceneQueryEncoderSpec extends FunSuite with LuceneQueryEncoders: ) ) - List(SearchRole.Admin, SearchRole.Anonymous, SearchRole.User(model.Id("5"))).foreach { - role => - val encoder = SolrTokenEncoder[Id, FieldTerm.RoleIs] - val ctx = Context.fixed[Id](refDate, utc, role) - val encode = encoder.encode(ctx, _) + List( + SearchRole.Admin(model.Id("admin")), + SearchRole.Anonymous, + SearchRole.User(model.Id("5")) + ).foreach { role => + val encoder = SolrTokenEncoder[Id, FieldTerm.RoleIs] + val ctx = Context.fixed[Id](refDate, utc, role) + val encode = encoder.encode(ctx, _) - test(s"role filter: $role"): - val memberQuery: FieldTerm.RoleIs = FieldTerm.RoleIs(Nel.of(MemberRole.Member)) - val ownerQuery: FieldTerm.RoleIs = FieldTerm.RoleIs(Nel.of(MemberRole.Owner)) - val allQuery: FieldTerm.RoleIs = - FieldTerm.RoleIs(Nel.of(MemberRole.Member, MemberRole.Owner, MemberRole.Member)) - role match - case SearchRole.Admin => - assertEquals( - encode(memberQuery), - SolrQuery(SolrToken.empty) - ) - assertEquals( - encode(ownerQuery), - SolrQuery(SolrToken.empty) - ) - assertEquals( - encode(allQuery), - SolrQuery(SolrToken.empty) - ) - case SearchRole.Anonymous => - assertEquals( - encode(memberQuery), - SolrQuery(SolrToken.publicOnly) - ) - assertEquals( - encode(ownerQuery), - SolrQuery(SolrToken.publicOnly) - ) - assertEquals( - encode(allQuery), - SolrQuery(SolrToken.publicOnly) - ) - case SearchRole.User(id) => - assertEquals( - encode(memberQuery), - SolrQuery(SolrToken.roleIs(id, MemberRole.Member)) - ) - assertEquals( - encode(ownerQuery), - SolrQuery(SolrToken.roleIs(id, MemberRole.Owner)) - ) - assertEquals( - encode(allQuery), - SolrQuery(SolrToken.roleIn(id, Nel.of(MemberRole.Member, MemberRole.Owner))) - ) + test(s"role filter: $role"): + val memberQuery: FieldTerm.RoleIs = FieldTerm.RoleIs(Nel.of(MemberRole.Member)) + val ownerQuery: FieldTerm.RoleIs = FieldTerm.RoleIs(Nel.of(MemberRole.Owner)) + val allQuery: FieldTerm.RoleIs = + FieldTerm.RoleIs(Nel.of(MemberRole.Member, MemberRole.Owner, MemberRole.Member)) + role match + case SearchRole.Admin(_) => + assertEquals( + encode(memberQuery), + SolrQuery(SolrToken.empty) + ) + assertEquals( + encode(ownerQuery), + SolrQuery(SolrToken.empty) + ) + assertEquals( + encode(allQuery), + SolrQuery(SolrToken.empty) + ) + case SearchRole.Anonymous => + assertEquals( + encode(memberQuery), + SolrQuery(SolrToken.publicOnly) + ) + assertEquals( + encode(ownerQuery), + SolrQuery(SolrToken.publicOnly) + ) + assertEquals( + encode(allQuery), + SolrQuery(SolrToken.publicOnly) + ) + case SearchRole.User(id) => + assertEquals( + encode(memberQuery), + SolrQuery(SolrToken.roleIs(id, MemberRole.Member)) + ) + assertEquals( + encode(ownerQuery), + SolrQuery(SolrToken.roleIs(id, MemberRole.Owner)) + ) + assertEquals( + encode(allQuery), + SolrQuery(SolrToken.roleIn(id, Nel.of(MemberRole.Member, MemberRole.Owner))) + ) } 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 5cdef386..c380228e 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 @@ -52,7 +52,9 @@ class LuceneQueryInterpreterSpec extends SearchSolrSuite with ScalaCheckEffectSu () } - def query(s: String | Query, role: SearchRole = SearchRole.Admin): QueryData = + val adminRole: SearchRole = SearchRole.admin(model.Id("admin")) + + def query(s: String | Query, role: SearchRole = adminRole): QueryData = val userQuery: Query = s match case str: String => Query.parse(str).fold(sys.error, identity) case qq: Query => qq @@ -71,7 +73,7 @@ class LuceneQueryInterpreterSpec extends SearchSolrSuite with ScalaCheckEffectSu "((content_all:help~) AND visibility:public AND _kind:fullentity)" ) assertEquals( - query("help", SearchRole.Admin).query, + query("help", adminRole).query, "(content_all:help~ AND _kind:fullentity)" ) @@ -84,7 +86,7 @@ class LuceneQueryInterpreterSpec extends SearchSolrSuite with ScalaCheckEffectSu query("", SearchRole.Anonymous).query, "(visibility:public AND _kind:fullentity)" ) - assertEquals(query("", SearchRole.Admin).query, "(_kind:fullentity)") + assertEquals(query("", adminRole).query, "(_kind:fullentity)") test("valid content_all query") { IO(solrClientWithSchema()).flatMap { client =>