Skip to content

Commit

Permalink
added "exec" authorization provider
Browse files Browse the repository at this point in the history
  • Loading branch information
cgbaker committed Aug 14, 2018
1 parent d9fc216 commit 5315def
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 27 deletions.
3 changes: 2 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ resolvers += "Typesafe Releases" at "http://repo.typesafe.com/typesafe/releases/

val scalaCheck = "org.scalacheck" %% "scalacheck" % "1.14.0"
val specs2 = "org.specs2" %% "specs2-core" % "4.3.2"
val specs2mock = "org.specs2" %% "specs2-mock" % "4.3.2"
val scalaTest = "org.scalatest" %% "scalatest" % "3.0.5"

val snakeYaml = "org.yaml" % "snakeyaml" % "1.21"
Expand Down Expand Up @@ -61,7 +62,7 @@ lazy val commonSettings = Seq(

lazy val skuberSettings = Seq(
name := "skuber",
libraryDependencies ++= Seq(akkaHttp, akkaStream, playJson, snakeYaml, commonsIO, commonsCodec, bouncyCastle, scalaCheck % Test,specs2 % Test).
libraryDependencies ++= Seq(akkaHttp, akkaStream, playJson, snakeYaml, commonsIO, commonsCodec, bouncyCastle, scalaCheck % Test,specs2 % Test,specs2mock % Test).
map(_.exclude("commons-logging","commons-logging"))
)

Expand Down
25 changes: 22 additions & 3 deletions client/src/main/scala/skuber/api/Configuration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,28 @@ object Configuration {
}
}

val maybeAuth = optionalValueAt[YamlMap](userConfig, "auth-provider") match {
case Some(authProvider) => authProviderRead(authProvider)
case None =>
def execAuthRead(execProvider: YamlMap): Option[AuthProviderAuth] = {
import scala.collection.JavaConverters._
for {
cmd <- optionalValueAt[String](execProvider, "command")
args = optionalValueAt[java.util.List[String]](execProvider, "args").getOrElse(new java.util.ArrayList())
env = optionalValueAt[java.util.List[YamlMap]](execProvider, "env").getOrElse(new java.util.ArrayList()).asScala.flatMap(
m => for {
name <- optionalValueAt[String](m, "name")
value <- optionalValueAt[String](m, "value")
} yield name -> value
)
} yield ExecAuth(
command = cmd,
args = args.asScala,
env = env
)
}

val maybeAuth =
optionalValueAt[YamlMap](userConfig, "exec").flatMap(execAuthRead)
.orElse(optionalValueAt[YamlMap](userConfig, "auth-provider").flatMap(authProviderRead))
.orElse {
val clientCertificate = pathOrDataValueAt(userConfig, "client-certificate", "client-certificate-data")
val clientKey = pathOrDataValueAt(userConfig, "client-key", "client-key-data")

Expand Down
100 changes: 80 additions & 20 deletions client/src/main/scala/skuber/api/package.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package skuber.api

import scala.concurrent.{ExecutionContext, Future}
import scala.sys.SystemProperties
import scala.sys.{SystemProperties, process}
import scala.util.{Failure, Success}
import java.net.URL
import java.time.Instant
Expand Down Expand Up @@ -108,21 +108,71 @@ package object client {

// 'jwt' supports an oidc id token per https://kubernetes.io/docs/admin/authentication/#option-1---oidc-authenticator
// - but does not yet support token refresh
final case class OidcAuth(idToken: String) extends AuthProviderAuth {
override val name = "oidc"
final case class OidcAuth(idToken: String) extends AuthProviderAuth {
override val name = "oidc"

override def accessToken: String = idToken
override def accessToken: String = idToken

override def toString = """OidcAuth(idToken=<redacted>)"""
override def toString = """OidcAuth(idToken=<redacted>)"""
}

final case class ExecAuth private(private[api] val cmd: ExecAuthCommand, executioner: CommandExecutioner) extends AuthProviderAuth {
override def name: String = "exec"

@volatile private var refresh: ExecRefresh = new ExecRefresh("", None)

def refreshToken(): ExecRefresh = {
val output = executioner.execute(
command = cmd.command +: cmd.args,
env = cmd.env
)
Json.parse(output).as[ExecRefresh]
}

def accessToken: String = this.synchronized {
if(refresh.expired)
refresh = refreshToken()
refresh.accessToken
}

override def toString = """ExecAuth(token=<redacted>)""".stripMargin
}

final private[client] case class ExecRefresh(accessToken: String, maybeExpiry: Option[Instant]) {
def expired: Boolean = !maybeExpiry.exists(expiry => Instant.now.isBefore(expiry.minusSeconds(20)))
}

private[client] object ExecRefresh {
implicit val execRefreshReads: Reads[ExecRefresh] = (
(JsPath \ "status" \ "token").read[String] and
(JsPath \ "status" \ "expirationTimestamp").readNullable[Instant]
)(ExecRefresh.apply _)
}

final case class GcpAuth private(private val config: GcpConfiguration) extends AuthProviderAuth {
trait CommandExecutioner {
def execute(command: Seq[String], env: Seq[(String,String)]): String
}

implicit val defaultCommandExecution = new CommandExecutioner {
override def execute(command: Seq[String], env: Seq[(String, String)]): String = {
scala.sys.process.Process(
command = command,
cwd = None,
extraEnv = env:_*
).!!
}
}

final case class GcpAuth private(private val config: GcpConfiguration, executioner: CommandExecutioner) extends AuthProviderAuth {
override val name = "gcp"

@volatile private var refresh: GcpRefresh = new GcpRefresh(config.accessToken, config.expiry)

def refreshGcpToken(): GcpRefresh = {
val output = config.cmd.execute()
val output = executioner.execute(
command = config.cmd.cmd +: config.cmd.args.split("""\s+""").toSeq,
env = Seq.empty
)
Json.parse(output).as[GcpRefresh]
}

Expand All @@ -141,28 +191,38 @@ package object client {
def expired: Boolean = Instant.now.isAfter(expiry.minusSeconds(20))
}

private[client] object GcpRefresh {
implicit val gcpRefreshReads: Reads[GcpRefresh] = (
(JsPath \ "credential" \ "access_token").read[String] and
(JsPath \ "credential" \ "token_expiry").read[Instant]
)(GcpRefresh.apply _)
}
private[client] object GcpRefresh {
implicit val gcpRefreshReads: Reads[GcpRefresh] = (
(JsPath \ "credential" \ "access_token").read[String] and
(JsPath \ "credential" \ "token_expiry").read[Instant]
)(GcpRefresh.apply _)
}

final case class GcpConfiguration(accessToken: String, expiry: Instant, cmd: GcpCommand)

final case class GcpCommand(cmd: String, args: String) {
import scala.sys.process._
def execute(): String = s"$cmd $args".!!
}
final case class ExecAuthCommand(command: String, args: Seq[String], env: Seq[(String,String)])

final case class GcpCommand(cmd: String, args: String)

object ExecAuth {
def apply(command: String, args: Seq[String], env: Seq[(String,String)])
(implicit executioner: CommandExecutioner): ExecAuth =
new ExecAuth(
cmd = ExecAuthCommand(command, args, env),
executioner
)
}

object GcpAuth {
def apply(accessToken: String, expiry: Instant, cmdPath: String, cmdArgs: String): GcpAuth =
def apply(accessToken: String, expiry: Instant, cmdPath: String, cmdArgs: String)
(implicit executioner: CommandExecutioner): GcpAuth =
new GcpAuth(
GcpConfiguration(
accessToken = accessToken,
expiry = expiry,
GcpCommand(cmdPath, cmdArgs)
)
),
executioner
)
}

Expand All @@ -179,7 +239,7 @@ package object client {

// This class offers a fine-grained choice over events to be logged by the API (applicable only if INFO level is enabled)
case class LoggingConfig(
logConfiguration: Boolean=loggingEnabled("config", true), // outputs configuration on initialisation)
logConfiguration: Boolean=loggingEnabled("config", true), // outputs configuration on initialisation
logRequestBasic: Boolean=loggingEnabled("request", true), // logs method and URL for request
logRequestBasicMetadata: Boolean=loggingEnabled("request.metadata", false), // logs key resource metadata information if available
logRequestFullObjectResource: Boolean=loggingEnabled("request.object.full", false), // outputs full object resource if available
Expand Down
18 changes: 17 additions & 1 deletion client/src/test/scala/skuber/api/ConfigurationSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ users:
expiry-key: '{.credential.token_expiry}'
token-key: '{.credential.access_token}'
name: gcp
- name: aws-user
user:
exec:
apiVersion: "client.authentication.k8s.io/v1alpha1"
command: "/usr/local/bin/heptio-authenticator-aws"
args: ["token", "-i", "CLUSTER_ID", "-r", "ROLE_ARN"]
env:
- name: "1"
value: "2"
- name: "3"
value: "4"
"""

implicit val system=ActorSystem("test")
Expand All @@ -100,7 +111,12 @@ users:
val jwtUser= OidcAuth(idToken = "jwt-token")
val gcpUser = GcpAuth(accessToken = "myAccessToken", expiry = Instant.parse("2018-03-04T14:08:18Z"),
cmdPath = "/home/user/google-cloud-sdk/bin/gcloud", cmdArgs = "config config-helper --format=json")
val users=Map("blue-user"->blueUser,"green-user"->greenUser,"jwt-user"->jwtUser, "gke-user"->gcpUser)
val awsUser = ExecAuth(
command = "/usr/local/bin/heptio-authenticator-aws",
args = Seq("token", "-i", "CLUSTER_ID", "-r", "ROLE_ARN"),
env = Seq("1" -> "2", "3" -> "4")
)
val users=Map("blue-user"->blueUser,"green-user"->greenUser,"jwt-user"->jwtUser, "gke-user"->gcpUser, "aws-user"->awsUser)

val federalContext=K8SContext(horseCluster,greenUser,Namespace.forName("chisel-ns"))
val queenAnneContext=K8SContext(pigCluster,blueUser, Namespace.forName("saw-ns"))
Expand Down
56 changes: 56 additions & 0 deletions client/src/test/scala/skuber/api/ExternalAuthSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package skuber.api

import java.time.Instant

import org.specs2.mock.Mockito
import org.specs2.mutable.Specification
import skuber.api.client._

/**
* @author C. G. Baker
*/
class ExternalAuthSpec extends Specification with Mockito {
"This is a unit specification for the external auth classes. ".txt

"GcpAuth passes command and all args" >> {
val executioner = mock[CommandExecutioner]
executioner.execute(any, any) returns
"""
|{
| "credential": {
| "access_token": "the-token",
| "token_expiry": "2006-01-02T15:04:05Z"
| }
|}
""".stripMargin

val auth = GcpAuth(accessToken = "MyAccessToken", expiry = Instant.now, cmdPath = "gcp", cmdArgs = "1 2")(executioner)
auth.accessToken must_== "the-token"
there was one(executioner).execute(
command = Seq("gcp", "1", "2"),
env = Seq.empty
)
}

"ExecAuth passes env, command and all args" >> {
val executioner = mock[CommandExecutioner]
executioner.execute(any, any) returns
s"""
|{
| "apiVersion": "client.authentication.k8s.io/v1alpha1",
| "kind": "ExecCredential",
| "status": {
| "token": "the-token",
| "expirationTimestamp": "2006-01-02T15:04:05Z"
| }
|}
""".stripMargin
val auth = ExecAuth("/usr/local/bin/some-command", Seq("1", "2"), Seq("3" -> "4", "5" -> "6"))(executioner)
auth.accessToken must_== "the-token"
there was one(executioner).execute(
command = Seq("/usr/local/bin/some-command", "1", "2"),
env = Seq("3" -> "4", "5" -> "6")
)
}

}
7 changes: 5 additions & 2 deletions client/src/test/scala/skuber/model/AuthSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import skuber.api.client._
*/
class AuthSpec extends Specification {
"This is a unit specification for the auth data model. ".txt



// Auth
"Auth toString works when empty" >> {
NoAuth.toString mustEqual "NoAuth"
Expand Down Expand Up @@ -45,6 +44,10 @@ class AuthSpec extends Specification {
"GcpAuth(accessToken=<redacted>)"
}

"ExecAuth toString masks token" >> {
ExecAuth("", Seq.empty, Seq.empty).toString mustEqual "ExecAuth(token=<redacted>)"
}

"OidcAuth toString masks idToken" >> {
OidcAuth(idToken = "MyToken").toString mustEqual "OidcAuth(idToken=<redacted>)"
}
Expand Down

0 comments on commit 5315def

Please sign in to comment.