2019-04-26 17:49:28 +00:00
package kubeauth
import (
"bytes"
"encoding/json"
"encoding/pem"
2020-05-05 21:55:21 +00:00
"fmt"
2019-04-26 17:49:28 +00:00
"io/ioutil"
2020-05-05 21:55:21 +00:00
"log"
"net"
2019-04-26 17:49:28 +00:00
"net/http"
"net/http/httptest"
"net/url"
"regexp"
2020-05-05 21:55:21 +00:00
"strconv"
2019-04-26 17:49:28 +00:00
"strings"
"sync"
"time"
2020-05-05 21:55:21 +00:00
"github.com/hashicorp/consul/sdk/freeport"
"github.com/mitchellh/go-testing-interface"
2019-04-26 17:49:28 +00:00
"github.com/stretchr/testify/require"
authv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
// TestAPIServer is a way to mock the Kubernetes API server as it is used by
// the consul kubernetes auth method.
//
// - POST /apis/authentication.k8s.io/v1/tokenreviews
// - GET /api/v1/namespaces/<NAMESPACE>/serviceaccounts/<NAME>
//
type TestAPIServer struct {
2020-05-05 21:55:21 +00:00
srv * httptest . Server
caCert string
returnFunc func ( )
2019-04-26 17:49:28 +00:00
mu sync . Mutex
authorizedJWT string // token review and sa read
allowedServiceAccountJWT string // general service account
replyStatus * authv1 . TokenReview // general service account
replyRead * corev1 . ServiceAccount // general service account
}
// StartTestAPIServer creates a disposable TestAPIServer and binds it to a
// random free port.
2020-05-05 21:55:21 +00:00
func StartTestAPIServer ( t testing . T ) * TestAPIServer {
s := & TestAPIServer { }
2019-04-26 17:49:28 +00:00
2020-05-05 21:55:21 +00:00
ports := freeport . MustTake ( 1 )
s . returnFunc = func ( ) {
freeport . Return ( ports )
}
s . srv = httptestNewUnstartedServerWithPort ( s , ports [ 0 ] )
s . srv . Config . ErrorLog = log . New ( ioutil . Discard , "" , 0 )
s . srv . StartTLS ( )
2019-04-26 17:49:28 +00:00
bs := s . srv . TLS . Certificates [ 0 ] . Certificate [ 0 ]
var buf bytes . Buffer
require . NoError ( t , pem . Encode ( & buf , & pem . Block { Type : "CERTIFICATE" , Bytes : bs } ) )
s . caCert = buf . String ( )
return s
}
// AuthorizeJWT whitelists the given JWT as able to use the API server.
func ( s * TestAPIServer ) AuthorizeJWT ( jwt string ) {
s . mu . Lock ( )
defer s . mu . Unlock ( )
s . authorizedJWT = jwt
}
// SetAllowedServiceAccount configures the singular known Service Account
// installed in this API server. If any of namespace/name/uid/jwt are empty
// it removes anything previously configured.
//
// It is up to the caller to ensure that the provided JWT matches the other
// data.
func ( s * TestAPIServer ) SetAllowedServiceAccount (
namespace , name , uid , overrideAnnotation , jwt string ,
) {
s . mu . Lock ( )
defer s . mu . Unlock ( )
if namespace == "" || name == "" || uid == "" || jwt == "" {
s . allowedServiceAccountJWT = ""
s . replyStatus = nil
s . replyRead = nil
return
}
s . allowedServiceAccountJWT = jwt
s . replyRead = createReadServiceAccountFound ( namespace , name , uid , overrideAnnotation , jwt )
s . replyStatus = createTokenReviewFound ( namespace , name , uid , jwt )
}
// Stop stops the running TestAPIServer.
func ( s * TestAPIServer ) Stop ( ) {
s . srv . Close ( )
2020-05-05 21:55:21 +00:00
if s . returnFunc != nil {
s . returnFunc ( )
s . returnFunc = nil
}
2019-04-26 17:49:28 +00:00
}
// Addr returns the current base URL for the running webserver.
func ( s * TestAPIServer ) Addr ( ) string { return s . srv . URL }
// CACert returns the pem-encoded CA certificate used by the HTTPS server.
func ( s * TestAPIServer ) CACert ( ) string { return s . caCert }
var readServiceAccountPathRE = regexp . MustCompile ( "^/api/v1/namespaces/([^/]+)/serviceaccounts/([^/]+)$" )
func ( s * TestAPIServer ) ServeHTTP ( w http . ResponseWriter , req * http . Request ) {
s . mu . Lock ( )
defer s . mu . Unlock ( )
w . Header ( ) . Set ( "content-type" , "application/json" )
if req . URL . Path == "/apis/authentication.k8s.io/v1/tokenreviews" {
s . handleTokenReview ( w , req )
return
}
if m := readServiceAccountPathRE . FindStringSubmatch ( req . URL . Path ) ; m != nil {
namespace , err := url . QueryUnescape ( m [ 1 ] )
if err != nil {
w . WriteHeader ( http . StatusBadRequest )
return
}
name , err := url . QueryUnescape ( m [ 2 ] )
if err != nil {
w . WriteHeader ( http . StatusBadRequest )
return
}
s . handleReadServiceAccount ( namespace , name , w , req )
return
}
w . WriteHeader ( http . StatusNotFound )
}
func writeJSON ( w http . ResponseWriter , out interface { } ) error {
enc := json . NewEncoder ( w )
return enc . Encode ( out )
}
func ( s * TestAPIServer ) handleTokenReview ( w http . ResponseWriter , req * http . Request ) {
if req . Method != "POST" {
w . WriteHeader ( http . StatusMethodNotAllowed )
return
}
if auth , anon := s . isAuthenticated ( req ) ; ! auth {
var out interface { }
if anon {
out = createTokenReviewForbidden_NoAuthz ( )
} else {
out = createTokenReviewForbidden ( "default" , "fake-account" )
}
w . WriteHeader ( http . StatusForbidden )
if err := writeJSON ( w , out ) ; err != nil {
w . WriteHeader ( http . StatusInternalServerError )
}
return
}
if req . Body == nil {
w . WriteHeader ( http . StatusBadRequest )
return
}
defer req . Body . Close ( )
b , err := ioutil . ReadAll ( req . Body )
if err != nil {
w . WriteHeader ( http . StatusInternalServerError )
return
}
var trReq authv1 . TokenReview
if err := json . Unmarshal ( b , & trReq ) ; err != nil {
w . WriteHeader ( http . StatusInternalServerError )
return
}
reviewingJWT := trReq . Spec . Token
var out interface { }
if s . replyStatus == nil || reviewingJWT != s . allowedServiceAccountJWT {
out = createTokenReviewNotFound ( reviewingJWT )
} else {
out = s . replyStatus
}
w . WriteHeader ( http . StatusCreated )
if err := writeJSON ( w , out ) ; err != nil {
w . WriteHeader ( http . StatusInternalServerError )
return
}
}
func ( s * TestAPIServer ) handleReadServiceAccount (
namespace , name string ,
w http . ResponseWriter ,
req * http . Request ,
) {
if req . Method != "GET" {
w . WriteHeader ( http . StatusMethodNotAllowed )
return
}
var out interface { }
if auth , anon := s . isAuthenticated ( req ) ; ! auth {
if anon {
out = createReadServiceAccountForbidden_NoAuthz ( )
} else {
out = createReadServiceAccountForbidden ( namespace , name )
}
w . WriteHeader ( http . StatusForbidden )
} else if s . replyRead == nil {
out = createReadServiceAccountNotFound ( namespace , name )
w . WriteHeader ( http . StatusNotFound )
} else if s . replyRead . Namespace != namespace || s . replyRead . Name != name {
out = createReadServiceAccountNotFound ( namespace , name )
w . WriteHeader ( http . StatusNotFound )
} else {
out = s . replyRead
w . WriteHeader ( http . StatusOK )
}
if err := writeJSON ( w , out ) ; err != nil {
w . WriteHeader ( http . StatusInternalServerError )
return
}
}
func ( s * TestAPIServer ) isAuthenticated ( req * http . Request ) ( auth , anonymous bool ) {
authz := req . Header . Get ( "Authorization" )
if ! strings . HasPrefix ( authz , "Bearer " ) {
return false , true
}
jwt := strings . TrimPrefix ( authz , "Bearer " )
return s . authorizedJWT == jwt , false
}
func createTokenReviewForbidden_NoAuthz ( ) * metav1 . Status {
/ *
STATUS : 403
{
"kind" : "Status" ,
"apiVersion" : "v1" ,
"metadata" : { } ,
"status" : "Failure" ,
"message" : "tokenreviews.authentication.k8s.io is forbidden: User \"system:anonymous\" cannot create resource \"tokenreviews\" in API group \"authentication.k8s.io\" at the cluster scope" ,
"reason" : "Forbidden" ,
"details" : {
"group" : "authentication.k8s.io" ,
"kind" : "tokenreviews"
} ,
"code" : 403
}
* /
return createStatus (
metav1 . StatusFailure ,
"tokenreviews.authentication.k8s.io is forbidden: User \"system:anonymous\" cannot create resource \"tokenreviews\" in API group \"authentication.k8s.io\" in the cluster scope" ,
metav1 . StatusReasonForbidden ,
& metav1 . StatusDetails {
Group : "authentication.k8s.io" ,
Kind : "tokenreviews" ,
} ,
403 ,
)
}
func createTokenReviewForbidden ( namespace , name string ) * metav1 . Status {
/ *
STATUS : 403
{
"kind" : "Status" ,
"apiVersion" : "v1" ,
"metadata" : { } ,
"status" : "Failure" ,
"message" : "tokenreviews.authentication.k8s.io is forbidden: User \"system:serviceaccount:default:admin\" cannot create resource \"tokenreviews\" in API group \"authentication.k8s.io\" at the cluster scope" ,
"reason" : "Forbidden" ,
"details" : {
"group" : "authentication.k8s.io" ,
"kind" : "tokenreviews"
} ,
"code" : 403
}
* /
return createStatus (
metav1 . StatusFailure ,
"tokenreviews.authentication.k8s.io is forbidden: User \"system:serviceaccount:" + namespace + ":" + name + "\" cannot create resource \"tokenreviews\" in API group \"authentication.k8s.io\" in the cluster scope" ,
metav1 . StatusReasonForbidden ,
& metav1 . StatusDetails {
Group : "authentication.k8s.io" ,
Kind : "tokenreviews" ,
} ,
403 ,
)
}
func createTokenReviewNotFound ( jwt string ) * authv1 . TokenReview {
/ *
STATUS : 201
{
"kind" : "TokenReview" ,
"apiVersion" : "authentication.k8s.io/v1" ,
"metadata" : {
"creationTimestamp" : null
} ,
"spec" : {
"token" : "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJkZWZhdWx0Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZWNyZXQubmFtZSI6ImZha2UtdG9rZW4tano2YnYiLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC5uYW1lIjoiZmFrZSIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50LnVpZCI6IjgxYTY1Mjg2LTU3YzEtMTFlOS1iYzJhLTQ4ZTZjOGI4ZWNiNSIsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmZha2UifQ.DqjUXe34SzCP4NCwbhqV9EuksfzmTSLhJzkE_URyufeGJDn-Gw0_JS-_KmxZSdAO0XXNzB1tJNM1NCVW-V6YbThnPUw5WY4V2J6U1W72c2dzNBx_ipBxGBZ632ZnpViIRu6tL2guT36lWa8YnMDF_OY8sHhl_3kJ6MRxNxY41vAuf45mohi3gri46Kpzc3pf1g6PJ-0oogvUsZ2nBFv1mIdciGBV0zejMKc5Bnxur1L-hEQ9EgZrJ7o0yQRCWYgam_yo_M38EsB8b-suTzQJMA-pRgApOb9dHIV6YAE_b3g_pGkJjrPYzV4IJC1CiPfdz1SAjm7e0ARXtZmaoPltjQ"
} ,
"status" : {
"user" : { } ,
"error" : "[invalid bearer token, Token has been invalidated]"
}
}
* /
return & authv1 . TokenReview {
TypeMeta : metav1 . TypeMeta {
Kind : "TokenReview" ,
APIVersion : "authentication.k8s.io/v1" ,
} ,
ObjectMeta : metav1 . ObjectMeta { } ,
Spec : authv1 . TokenReviewSpec {
Token : jwt ,
} ,
Status : authv1 . TokenReviewStatus {
User : authv1 . UserInfo { } ,
Error : "[invalid bearer token, Token has been invalidated]" ,
} ,
}
}
func createTokenReviewFound ( namespace , name , uid , jwt string ) * authv1 . TokenReview {
/ *
STATUS : 201
{
"kind" : "TokenReview" ,
"apiVersion" : "authentication.k8s.io/v1" ,
"metadata" : {
"creationTimestamp" : null
} ,
"spec" : {
"token" : "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJkZWZhdWx0Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZWNyZXQubmFtZSI6ImRlbW8tdG9rZW4tbTljdm4iLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlcnZpY2UtYWNjb3VudC5uYW1lIjoiZGVtbyIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50LnVpZCI6IjlmZjUxZmY0LTU1N2UtMTFlOS05Njg3LTQ4ZTZjOGI4ZWNiNSIsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmRlbW8ifQ.UJEphtrN261gy9WCl4ZKjm2PRDLDkc3Xg9VcDGfzyroOqFQ6sog5dVAb9voc5Nc0-H5b1yGwxDViEMucwKvZpA5pi7VEx_OskK-KTWXSmafM0Xg_AvzpU9Ed5TSRno-OhXaAraxdjXoC4myh1ay2DMeHUusJg_ibqcYJrWx-6MO1bH_ObORtAKhoST_8fzkqNAlZmsQ87FinQvYN5mzDXYukl-eeRdBgQUBkWvEb-Ju6cc0-QE4sUQ4IH_fs0fUyX_xc0om0SZGWLP909FTz4V8LxV8kr6L7irxROiS1jn3Fvyc9ur1PamVf3JOPPrOyfmKbaGRiWJM32b3buQw7cg"
} ,
"status" : {
"authenticated" : true ,
"user" : {
"username" : "system:serviceaccount:default:demo" ,
"uid" : "9ff51ff4-557e-11e9-9687-48e6c8b8ecb5" ,
"groups" : [
"system:serviceaccounts" ,
"system:serviceaccounts:default" ,
"system:authenticated"
]
}
}
}
* /
return & authv1 . TokenReview {
TypeMeta : metav1 . TypeMeta {
Kind : "TokenReview" ,
APIVersion : "authentication.k8s.io/v1" ,
} ,
ObjectMeta : metav1 . ObjectMeta { } ,
Spec : authv1 . TokenReviewSpec {
Token : jwt ,
} ,
Status : authv1 . TokenReviewStatus {
Authenticated : true ,
User : authv1 . UserInfo {
Username : "system:serviceaccount:" + namespace + ":" + name ,
UID : uid ,
Groups : [ ] string {
"system:serviceaccounts" ,
"system:serviceaccounts:default" ,
"system:authenticated" ,
} ,
} ,
} ,
}
}
func createReadServiceAccountForbidden ( namespace , name string ) * metav1 . Status {
/ *
STATUS : 403
{
"kind" : "Status" ,
"apiVersion" : "v1" ,
"metadata" : { } ,
"status" : "Failure" ,
"message" : "serviceaccounts \"demo\" is forbidden: User \"system:serviceaccount:default:admin\" cannot get resource \"serviceaccounts\" in API group \"\" in the namespace \"default\"" ,
"reason" : "Forbidden" ,
"details" : {
"name" : "demo" ,
"kind" : "serviceaccounts"
} ,
"code" : 403
}
* /
return createStatus (
metav1 . StatusFailure ,
"serviceaccounts \"" + name + "\" is forbidden: User \"system:serviceaccount:" + namespace + ":" + name + "\" cannot get resource \"serviceaccounts\" in API group \"\" in the namespace \"" + namespace + "\"" ,
metav1 . StatusReasonForbidden ,
& metav1 . StatusDetails {
Kind : "serviceaccounts" ,
Name : name ,
} ,
403 ,
)
}
func createReadServiceAccountForbidden_NoAuthz ( ) * metav1 . Status {
// missing bearer token header 403
/ *
{
"kind" : "Status" ,
"apiVersion" : "v1" ,
"metadata" : { } ,
"status" : "Failure" ,
"message" : "serviceaccounts \"demo\" is forbidden: User \"system:anonymous\" cannot get resource \"serviceaccounts\" in API group \"\" in the namespace \"default\"" ,
"reason" : "Forbidden" ,
"details" : {
"name" : "demo" ,
"kind" : "serviceaccounts"
} ,
"code" : 403
}
* /
return createStatus (
metav1 . StatusFailure ,
"serviceaccounts \"PLACEHOLDER\" is forbidden: User \"system:anonymous\" cannot get resource \"serviceaccounts\" in API group \"\" in the namespace \"default\"" ,
metav1 . StatusReasonForbidden ,
& metav1 . StatusDetails {
Kind : "serviceaccounts" ,
Name : "PLACEHOLDER" ,
} ,
403 ,
)
}
func createReadServiceAccountNotFound ( namespace , name string ) * metav1 . Status {
/ *
STATUS : 404
{
"kind" : "Status" ,
"apiVersion" : "v1" ,
"metadata" : { } ,
"status" : "Failure" ,
"message" : "serviceaccounts \"demo\" not found" ,
"reason" : "NotFound" ,
"details" : {
"name" : "demo" ,
"kind" : "serviceaccounts"
} ,
"code" : 404
}
* /
return createStatus (
metav1 . StatusFailure ,
"serviceaccounts \"" + name + "\" not found" ,
metav1 . StatusReasonNotFound ,
& metav1 . StatusDetails {
Kind : "serviceaccounts" ,
Name : name ,
} ,
404 ,
)
}
func createReadServiceAccountFound ( namespace , name , uid , overrideAnnotation , jwt string ) * corev1 . ServiceAccount {
/ *
STATUS : 200
{
"kind" : "ServiceAccount" ,
"apiVersion" : "v1" ,
"metadata" : {
"name" : "demo" ,
"namespace" : "default" ,
"selfLink" : "/api/v1/namespaces/default/serviceaccounts/demo" ,
"uid" : "9ff51ff4-557e-11e9-9687-48e6c8b8ecb5" ,
"resourceVersion" : "2101" ,
"creationTimestamp" : "2019-04-02T19:36:34Z" ,
"annotations" : {
"consul.hashicorp.com/service-name" : "actual" ,
"kubectl.kubernetes.io/last-applied-configuration" : "{\"apiVersion\":\"v1\",\"kind\":\"ServiceAccount\",\"metadata\":{\"annotations\":{\"consul.hashicorp.com/service-name\":\"actual\"},\"name\":\"demo\",\"namespace\":\"default\"}}\n"
}
} ,
"secrets" : [
{
"name" : "demo-token-m9cvn"
}
]
}
* /
sa := & corev1 . ServiceAccount {
TypeMeta : metav1 . TypeMeta {
Kind : "ServiceAccount" ,
APIVersion : "v1" ,
} ,
ObjectMeta : metav1 . ObjectMeta {
Name : name ,
Namespace : namespace ,
SelfLink : "/api/v1/namespaces/" + namespace + "/serviceaccounts/" + name ,
UID : types . UID ( uid ) ,
ResourceVersion : "123" ,
CreationTimestamp : metav1 . Time { Time : time . Now ( ) } ,
} ,
Secrets : [ ] corev1 . ObjectReference {
corev1 . ObjectReference {
Name : name + "-token-m9cvn" ,
} ,
} ,
}
if overrideAnnotation != "" {
sa . ObjectMeta . Annotations = map [ string ] string {
"consul.hashicorp.com/service-name" : overrideAnnotation ,
}
}
return sa
}
func createStatus ( status , message string , reason metav1 . StatusReason , details * metav1 . StatusDetails , code int32 ) * metav1 . Status {
return & metav1 . Status {
TypeMeta : metav1 . TypeMeta {
Kind : "Status" ,
APIVersion : "v1" ,
} ,
ListMeta : metav1 . ListMeta { } ,
Status : status ,
Message : message ,
Reason : reason ,
Details : details ,
Code : code ,
}
}
2020-05-05 21:55:21 +00:00
func httptestNewUnstartedServerWithPort ( handler http . Handler , port int ) * httptest . Server {
if port == 0 {
return httptest . NewUnstartedServer ( handler )
}
addr := net . JoinHostPort ( "127.0.0.1" , strconv . Itoa ( port ) )
l , err := net . Listen ( "tcp" , addr )
if err != nil {
panic ( fmt . Sprintf ( "httptest: failed to listen on a port: %v" , err ) )
}
return & httptest . Server {
Listener : l ,
Config : & http . Server { Handler : handler } ,
}
}