Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support login with role_id and secret_id #303

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,12 @@ lazy val core = project.in(file("core"))
mimaBinaryIssueFilters ++= {
import com.typesafe.tools.mima.core.IncompatibleSignatureProblem
import com.typesafe.tools.mima.core.ProblemFilters.exclude
import com.typesafe.tools.mima.core.DirectMissingMethodProblem
// See https://github.com/lightbend/mima/issues/423
Seq(
exclude[DirectMissingMethodProblem]("com.banno.vault.Vault.login"),
exclude[DirectMissingMethodProblem]("com.banno.vault.Vault.loginAndKeep"),
exclude[DirectMissingMethodProblem]("com.banno.vault.Vault.loginAndKeepSecretLeased"),
)
},
)
Expand Down
80 changes: 62 additions & 18 deletions core/src/main/scala/com/banno/vault/Vault.scala
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,8 @@ object Vault {
/**
* https://www.vaultproject.io/api/auth/approle/index.html#login-with-approle
*/
def login[F[_]](client: Client[F], vaultUri: Uri)(roleId: String)(implicit F: Concurrent[F]): F[VaultToken] = {
val request = Request[F](
method = Method.POST,
uri = vaultUri / "v1" / "auth" / "approle" / "login"
).withEntity(Json.obj(("role_id", Json.fromString(roleId))))
for {
json <- F.handleErrorWith(client.expect[Json](request)
) { e =>
F.raiseError(VaultRequestError(request, e.some, s"roleId=$roleId".some))
}
token <- raiseKnownError(json.hcursor.get[VaultToken]("auth"))(decoderError)
} yield token
def login[F[_]](client: Client[F], vaultUri: Uri): VaultLoginOperation[F] = {
VaultLoginOperationImpl(client, vaultUri)
}


Expand Down Expand Up @@ -227,9 +217,8 @@ object Vault {
}
}

def loginAndKeepSecretLeased[F[_]: Temporal, A: Decoder](client: Client[F], vaultUri: Uri)
(roleId: String, secretPath: String, duration: FiniteDuration, waitInterval: FiniteDuration): Stream[F, A] =
Stream.eval(login(client, vaultUri)(roleId)).flatMap(token => keepLoginAndSecretLeased[F, A](client, vaultUri)(token, secretPath, duration, waitInterval))
def loginAndKeepSecretLeased[F[_], A](client: Client[F], vaultUri: Uri): VaultLoginAndKeepSecretLeasedOperation[F, A] =
VaultLoginAndKeepSecretLeasedOperationImpl(client, vaultUri)

def loginK8sAndKeepSecretLeased[F[_]: Temporal, A: Decoder](client: Client[F], vaultUri: Uri)
(roleId: String, jwt: String, secretPath: String, duration: FiniteDuration, waitInterval: FiniteDuration, loginMountPoint: Uri.Path = path"/auth/kubernetes" ): Stream[F, A] =
Expand Down Expand Up @@ -269,9 +258,9 @@ object Vault {
}
}

def loginAndKeep[F[_]: Async](client: Client[F], vaultUri: Uri)
(roleId: String, tokenLeaseExtension: FiniteDuration): Stream[F, String] =
Stream.eval(login(client, vaultUri)(roleId)).flatMap(token => keepLoginRenewed[F](client, vaultUri)(token, tokenLeaseExtension))
def loginAndKeep[F[_]](client: Client[F], vaultUri: Uri): VaultLoginAndKeepOperation[F] = {
VaultLoginAndKeepOperationImpl(client, vaultUri)
}


/**
Expand Down Expand Up @@ -333,4 +322,59 @@ object Vault {
override def getMessage(): String = s"Token lease $leaseId could not be renewed any longer"
}

trait VaultLoginOperation[F[_]] {
def withRoleSecretId(roleSecretId: String): VaultLoginOperation[F]
def apply(roleId: String)(implicit F: Concurrent[F]): F[VaultToken]
}

final case class VaultLoginOperationImpl[F[_]](client: Client[F], vaultUri: Uri, roleSecretId: Option[String] = None) extends VaultLoginOperation[F] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this is a case class, we can't add new parameters later in a bincompat fashion, so this isn't quite the builder pattern. Making it an abstract class with a private constructor should work. You'll need to implement your own copy then. I also don't think this needs to be public.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rossabaker thanks you for getting back to this
May I kindly ask you to provide some examples of similar implementations with "abstract class with a private constructor"?
I tried to get back to the examples that you provided before to get some insights, but it looks like the project was refactored and now it uses case class.

override def withRoleSecretId(roleSecretId: String): VaultLoginOperation[F] = copy(roleSecretId = Some(roleSecretId))
override def apply(roleId: String)(implicit F: Concurrent[F]): F[VaultToken] = {
val request = Request[F](
method = Method.POST,
uri = vaultUri / "v1" / "auth" / "approle" / "login"
).withEntity(
Json.fromFields(
Seq("role_id" -> Json.fromString(roleId)) ++
roleSecretId.fold(Seq[(String, Json)]())(sId => Seq("secret_id" -> Json.fromString(sId)))
)
)
for {
json <- F.handleErrorWith(client.expect[Json](request)
) { e =>
F.raiseError(VaultRequestError(request, e.some, s"roleId=$roleId, roleSecretId=$roleSecretId".some))
}
token <- raiseKnownError(json.hcursor.get[VaultToken]("auth"))(decoderError)
} yield token
}
}

trait VaultLoginAndKeepOperation[F[_]] {
def withRoleSecretId(roleSecretId: String): VaultLoginAndKeepOperation[F]
def apply(roleId: String, tokenLeaseExtension: FiniteDuration)(implicit A: Async[F]): Stream[F, String]
}

final case class VaultLoginAndKeepOperationImpl[F[_]](client: Client[F], vaultUri: Uri, roleSecretId: Option[String] = None) extends VaultLoginAndKeepOperation[F] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comments as VaultLoginOperationImpl.

override def withRoleSecretId(roleSecretId: String): VaultLoginAndKeepOperation[F] = copy(roleSecretId = Some(roleSecretId))
def apply(roleId: String, tokenLeaseExtension: FiniteDuration)(implicit A: Async[F]): Stream[F, String] = {
val loginOperation = login(client, vaultUri)
Stream.eval(roleSecretId.fold(loginOperation)(loginOperation.withRoleSecretId)(roleId))
.flatMap(token => keepLoginRenewed[F](client, vaultUri)(token, tokenLeaseExtension))
}
}

trait VaultLoginAndKeepSecretLeasedOperation[F[_], A] {
def withRoleSecretId(roleSecretId: String): VaultLoginAndKeepSecretLeasedOperation[F, A]
def apply(roleId: String, secretPath: String, duration: FiniteDuration, waitInterval: FiniteDuration)(implicit T: Temporal[F], D: Decoder[A]): Stream[F, A]
}

final case class VaultLoginAndKeepSecretLeasedOperationImpl[F[_], A](client: Client[F], vaultUri: Uri, roleSecretId: Option[String] = None) extends VaultLoginAndKeepSecretLeasedOperation[F, A] {
override def withRoleSecretId(roleSecretId: String): VaultLoginAndKeepSecretLeasedOperation[F, A] = copy(roleSecretId = Some(roleSecretId))
override def apply(roleId: String, secretPath: String, duration: FiniteDuration, waitInterval: FiniteDuration)(implicit T: Temporal[F], D: Decoder[A]): Stream[F, A] = {
val loginOperation = login(client, vaultUri)
Stream.eval(roleSecretId.fold(loginOperation)(loginOperation.withRoleSecretId)(roleId))
.flatMap(token => keepLoginAndSecretLeased[F, A](client, vaultUri)(token, secretPath, duration, waitInterval))
}
}

}
58 changes: 44 additions & 14 deletions core/src/test/scala/com/banno/vault/VaultSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,9 @@ import org.typelevel.ci.CIString

class VaultSpec extends CatsEffectSuite with ScalaCheckEffectSuite with MissingPieces {

case class RoleId(role_id: String)
object RoleId {
implicit val roleIdDecoder: Decoder[RoleId] = Decoder.instance[RoleId] { c =>
Decoder.resultInstance.map(c.downField("role_id").as[String])(RoleId(_))
}
case class RoleIdAndRoleSecretId(roleId: String, roleSecretId: Option[String])
object RoleIdAndRoleSecretId {
implicit val roleIdAndSecretIdDecoder: Decoder[RoleIdAndRoleSecretId] = Decoder.forProduct2("role_id", "secret_id")(RoleIdAndRoleSecretId.apply)
}

case class RoleAndJwt(role: String, jwt: String)
Expand Down Expand Up @@ -93,10 +91,11 @@ class VaultSpec extends CatsEffectSuite with ScalaCheckEffectSuite with MissingP
val private_key_type: String = UUID.randomUUID().toString
val serial_number: String = UUID.randomUUID().toString

val validRoleId: String = UUID.randomUUID().toString
val invalidJSONRoleId: String = UUID.randomUUID().toString
val roleIdWithoutToken: String = UUID.randomUUID().toString
val roleIdWithoutLease: String = UUID.randomUUID().toString
val validRoleId: String = UUID.randomUUID().toString
val validRoleIdAndRoleSecretId: (String, String) = UUID.randomUUID().toString -> UUID.randomUUID().toString
val invalidJSONRoleId: String = UUID.randomUUID().toString
val roleIdWithoutToken: String = UUID.randomUUID().toString
val roleIdWithoutLease: String = UUID.randomUUID().toString

val validKubernetesRole: String = UUID.randomUUID().toString
val validKubernetesJwt: String = Random.alphanumeric.take(20).mkString //simulate a signed jwt https://www.vaultproject.io/api/auth/kubernetes/index.html#login
Expand Down Expand Up @@ -159,8 +158,17 @@ class VaultSpec extends CatsEffectSuite with ScalaCheckEffectSuite with MissingP
checkVaultToken(req)( NoContent() )

case req @ POST -> Root / "v1" / "auth" / "approle" / "login" =>
req.decodeJson[RoleId].flatMap {
case RoleId(`validRoleId`) =>
req.decodeJson[RoleIdAndRoleSecretId].flatMap {
case RoleIdAndRoleSecretId(`validRoleId`, `None`) =>
Ok(s"""
|{
| "auth": {
| "client_token": "$clientToken",
| "lease_duration": $leaseDuration,
| "renewable": $renewable
| }
|}""".stripMargin)
case roleIdAndRoleSecretId if roleIdAndRoleSecretId.roleId == validRoleIdAndRoleSecretId._1 && roleIdAndRoleSecretId.roleSecretId == Some(validRoleIdAndRoleSecretId._2) =>
Ok(s"""
|{
| "auth": {
Expand All @@ -169,16 +177,16 @@ class VaultSpec extends CatsEffectSuite with ScalaCheckEffectSuite with MissingP
| "renewable": $renewable
| }
|}""".stripMargin)
case RoleId(`invalidJSONRoleId`) =>
case RoleIdAndRoleSecretId(`invalidJSONRoleId`, `None`) =>
Ok(s""" NOT A JSON """)
case RoleId(`roleIdWithoutToken`) =>
case RoleIdAndRoleSecretId(`roleIdWithoutToken`, `None`) =>
Ok(s"""
|{
| "auth": {
| "lease_duration": $leaseDuration
| }
|}""".stripMargin)
case RoleId(`roleIdWithoutLease`) =>
case RoleIdAndRoleSecretId(`roleIdWithoutLease`, `None`) =>
Ok(s"""
|{
| "auth": {
Expand Down Expand Up @@ -312,6 +320,12 @@ class VaultSpec extends CatsEffectSuite with ScalaCheckEffectSuite with MissingP
}
}

test("login works as expected when sending a valid roleId and roleSecretId") {
PropF.forAllF(VaultArbitraries.validVaultUri) { uri =>
Vault.login(mockClient, uri).withRoleSecretId(validRoleIdAndRoleSecretId._2)(validRoleIdAndRoleSecretId._1).assertEquals(validToken)
}
}

test("login should fail when sending an invalid roleId") {
PropF.forAllF(VaultArbitraries.validVaultUri){uri =>
Vault.login(mockClient, uri)(UUID.randomUUID().toString)
Expand Down Expand Up @@ -497,6 +511,22 @@ class VaultSpec extends CatsEffectSuite with ScalaCheckEffectSuite with MissingP
.assertEquals(Some(Left(Vault.InvalidRequirement("waitInterval longer than requested Lease Duration"))))
}}
}

test("loginAndKeepSecretLeased with roleSecretId fails when wait duration is longer than lease duration") {
PropF.forAllF(
VaultArbitraries.validVaultUri,
Arbitrary.arbitrary[FiniteDuration],
Arbitrary.arbitrary[FiniteDuration]
) { case (uri, leaseDuration, waitInterval) => PropF.boolean[IO](leaseDuration < waitInterval) ==> {

Vault.loginAndKeepSecretLeased[IO, Unit](mockClient, uri).withRoleSecretId(validRoleIdAndRoleSecretId._2)(validRoleIdAndRoleSecretId._1, "", leaseDuration, waitInterval)
.attempt
.compile
.last
.assertEquals(Some(Left(Vault.InvalidRequirement("waitInterval longer than requested Lease Duration"))))
}}
}

test("loginK8sAndKeepSecretLeased fails when wait duration is longer than lease duration") {
PropF.forAllF(
VaultArbitraries.validVaultUri,
Expand Down