Skip to content

Commit

Permalink
feat: Use authenticated information to amend query (#59)
Browse files Browse the repository at this point in the history
- Uses information from the request to amend the search query such
  that visibility of projects is honored
- public projects (and users, that have a public visibility by
  default) are always included
- private projects are only included, if the calling subject is owner
  or member
- Rename field `projectId` to `id` for the user defined query. The id
  can select any entity type
  • Loading branch information
eikek authored Mar 15, 2024
1 parent a317f3e commit 7d34c4e
Show file tree
Hide file tree
Showing 42 changed files with 771 additions and 160 deletions.
16 changes: 15 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ lazy val root = project
)
.aggregate(
commons,
jwt,
httpClient,
events,
redisClient,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
95 changes: 95 additions & 0 deletions modules/jwt/src/main/scala/io/renku/search/jwt/BorerCodec.scala
Original file line number Diff line number Diff line change
@@ -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
)
51 changes: 51 additions & 0 deletions modules/jwt/src/main/scala/io/renku/search/jwt/JwtBorer.scala
Original file line number Diff line number Diff line change
@@ -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)
50 changes: 50 additions & 0 deletions modules/jwt/src/test/scala/io/renku/search/jwt/JwtBorerSpec.scala
Original file line number Diff line number Diff line change
@@ -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("{}"))
Original file line number Diff line number Diff line change
Expand Up @@ -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](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading

0 comments on commit 7d34c4e

Please sign in to comment.