github.com/grailbio/base@v0.0.11/cmd/ticket-server/k8sblesser.go (about)

     1  package main
     2  
     3  import (
     4  	"context"
     5  	"encoding/base64"
     6  	"fmt"
     7  	"net/http"
     8  	"strings"
     9  	"time"
    10  
    11  	auth "k8s.io/api/authentication/v1"
    12  	client "k8s.io/client-go/kubernetes/typed/authentication/v1"
    13  	rest "k8s.io/client-go/rest"
    14  
    15  	"github.com/aws/aws-sdk-go/aws"
    16  	"github.com/aws/aws-sdk-go/aws/arn"
    17  	awsclient "github.com/aws/aws-sdk-go/aws/client"
    18  	"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
    19  	"github.com/aws/aws-sdk-go/aws/endpoints"
    20  	awssession "github.com/aws/aws-sdk-go/aws/session"
    21  	awssigner "github.com/aws/aws-sdk-go/aws/signer/v4"
    22  	"github.com/aws/aws-sdk-go/service/eks"
    23  	"github.com/aws/aws-sdk-go/service/sts"
    24  
    25  	"github.com/grailbio/base/common/log"
    26  	"github.com/grailbio/base/errors"
    27  	"github.com/grailbio/base/security/identity"
    28  
    29  	v23context "v.io/v23/context"
    30  	"v.io/v23/rpc"
    31  	"v.io/v23/security"
    32  )
    33  
    34  // AWSSessionWrapper is a composition struture that wraps the
    35  // aws session element and returns values that can be more
    36  // easily mocked for testing purposes. SessionI interface
    37  // is passed into the wrapper so that functionality within the
    38  // session function can
    39  type AWSSessionWrapper struct {
    40  	session SessionI
    41  }
    42  
    43  // AWSSessionWrapperI provides a means to mock an aws client session.
    44  type AWSSessionWrapperI interface {
    45  	GetAuthV1Client(ctx context.Context, headers map[string]string, caCrt string, region string, endpoint string) (client.AuthenticationV1Interface, error)
    46  	ListEKSClusters(input *eks.ListClustersInput, roleARN string, region string) (*eks.ListClustersOutput, error)
    47  	DescribeEKSCluster(input *eks.DescribeClusterInput, roleARN string, region string) (*eks.DescribeClusterOutput, error)
    48  }
    49  
    50  // newSessionWrapper generates an AWSSessionWrapper that contains
    51  // an awsSession.Session struct and provides multiple mockable interfaces
    52  // for interacting with aws and its remote data.
    53  func newSessionWrapper(session SessionI) *AWSSessionWrapper {
    54  	// in order to update the sessionI config we must cast it as an awssession.Session struct
    55  	newSession := session.(*awssession.Session)
    56  	newSession.Config.STSRegionalEndpoint = endpoints.RegionalSTSEndpoint
    57  
    58  	return &AWSSessionWrapper{session: newSession}
    59  }
    60  
    61  // ListEKSClusters provides a mockable interface for AWS sessions to
    62  // obtain and iterate over a list of available EKS clusters with the
    63  // provided input configuration
    64  func (w *AWSSessionWrapper) ListEKSClusters(input *eks.ListClustersInput, roleARN string, region string) (*eks.ListClustersOutput, error) {
    65  	config := aws.Config{
    66  		Credentials: stscreds.NewCredentials(w.session, roleARN), // w.session.GetStsCreds(roleARN),
    67  		Region:      &region,
    68  	}
    69  
    70  	svc := eks.New(w.session, &config)
    71  	return svc.ListClusters(input)
    72  }
    73  
    74  // DescribeEKSCluster provides a mockable interface for AWS sessions to
    75  // obtain information regarding a specific EKS cluster with the
    76  // provided input configuration
    77  func (w *AWSSessionWrapper) DescribeEKSCluster(input *eks.DescribeClusterInput, roleARN string, region string) (*eks.DescribeClusterOutput, error) {
    78  	config := aws.Config{
    79  		Credentials: stscreds.NewCredentials(w.session, roleARN), // w.session.GetStsCreds(roleARN),
    80  		Region:      &region,
    81  	}
    82  	svc := eks.New(w.session, &config)
    83  	return svc.DescribeCluster(input)
    84  }
    85  
    86  // GetAuthV1Client provides a mockable interface for returning an AWS auth client
    87  func (w *AWSSessionWrapper) GetAuthV1Client(ctx context.Context, headers map[string]string, caCrt string, region string, endpoint string) (client.AuthenticationV1Interface, error) {
    88  	var (
    89  		err          error
    90  		authV1Client *client.AuthenticationV1Client
    91  	)
    92  	svc := sts.New(w.session, aws.NewConfig().WithRegion(region))
    93  	req, _ := http.NewRequest("GET", fmt.Sprintf("%s/?Action=GetCallerIdentity&Version=2011-06-15", svc.Client.Endpoint), nil)
    94  
    95  	for key, header := range headers {
    96  		req.Header.Add(key, header)
    97  	}
    98  
    99  	var sessionInterface = w.session
   100  	var credentials = sessionInterface.(*awssession.Session).Config.Credentials
   101  
   102  	signer := awssigner.NewSigner(credentials)
   103  	emptyBody := strings.NewReader("")
   104  	_, err = signer.Presign(req, emptyBody, "sts", region, 60*time.Second, time.Now())
   105  
   106  	log.Debug(ctx, "Request was built and presigned", "req", req)
   107  
   108  	if err != nil {
   109  		return authV1Client, errors.E(err, "unable to presign request for STS credentials")
   110  	}
   111  
   112  	bearerToken := fmt.Sprintf("k8s-aws-v1.%s", strings.TrimRight(base64.StdEncoding.EncodeToString([]byte(req.URL.String())), "="))
   113  
   114  	log.Debug(ctx, "Bearer token generated", "bearerToken", bearerToken, "url", req.URL.String())
   115  
   116  	tlsConfig := rest.TLSClientConfig{CAData: []byte(caCrt)}
   117  	config := rest.Config{
   118  		Host:            endpoint,
   119  		BearerToken:     bearerToken,
   120  		TLSClientConfig: tlsConfig,
   121  	}
   122  
   123  	return client.NewForConfigOrDie(&config), err
   124  }
   125  
   126  // SessionI interface provides a mockable interface for session data
   127  type SessionI interface {
   128  	awsclient.ConfigProvider
   129  }
   130  
   131  // V23 Blesser utility for generating blessings for k8s cluster principals. Implements
   132  // interface K8sBlesserServerStubMethods, which requires a BlessK8s method.
   133  // Stores awsConn information in addition to the v23 session and blessing expiration intervals.
   134  // Mock this by creating a separate implementation of K8sBlesserServerStubMethods interface.
   135  type k8sBlesser struct {
   136  	identity.K8sBlesserServerMethods
   137  	sessionWrapper     AWSSessionWrapperI
   138  	expirationInterval time.Duration
   139  	awsConn            *awsConn
   140  }
   141  
   142  func newK8sBlesser(sessionWrapper AWSSessionWrapperI, expiration time.Duration, role string, awsAccountIDs []string, awsRegions []string) *k8sBlesser {
   143  	return &k8sBlesser{
   144  		sessionWrapper:     sessionWrapper,
   145  		expirationInterval: expiration,
   146  		awsConn:            newAwsConn(sessionWrapper, role, awsRegions, awsAccountIDs),
   147  	}
   148  }
   149  
   150  // BlessK8s uses the awsConn and k8sConn structs as well as the CreateK8sExtension func
   151  // in order to create a blessing for a k8s principle. It acts as an entrypoint that does not
   152  // perform any important logic on its own.
   153  func (blesser *k8sBlesser) BlessK8s(ctx *v23context.T, call rpc.ServerCall, caCrt string, namespace string, k8sSvcAcctToken string, region string) (security.Blessings, error) {
   154  	log.Info(ctx, "bless K8s request", "namespace", namespace, "region", region, "remoteAddr", call.RemoteEndpoint().Address)
   155  	var (
   156  		nullBlessings security.Blessings = security.Blessings{}
   157  		cluster       *eks.Cluster
   158  		err           error
   159  	)
   160  
   161  	// establish security call
   162  	securityCall := call.Security()
   163  	if securityCall.LocalPrincipal() == nil {
   164  		return nullBlessings, errors.New("server misconfiguration: no authentication happened")
   165  	}
   166  
   167  	// establish caveat
   168  	caveat, err := security.NewExpiryCaveat(time.Now().Add(blesser.expirationInterval))
   169  	if err != nil {
   170  		return nullBlessings, errors.E(err, "unable to presign request for STS credentials")
   171  	}
   172  
   173  	// next, we are ready to isolate a desired cluster by enumerating existing eks clusters in a region and matching the caCrt
   174  	cluster, err = blesser.awsConn.GetEKSCluster(ctx, region, caCrt)
   175  	if err != nil {
   176  		return nullBlessings, err
   177  	}
   178  
   179  	// now we can establish the k8s cluster obj because we know the cluster and can connect to it.
   180  	k8sConn := newK8sConn(blesser.sessionWrapper, cluster, region, caCrt, k8sSvcAcctToken)
   181  
   182  	// obtain username from cluster connection
   183  	username, err := k8sConn.GetK8sUsername(ctx)
   184  	if err != nil {
   185  		return nullBlessings, err
   186  	}
   187  
   188  	// create an extension based on the namespace and username
   189  	extension, err := CreateK8sExtension(ctx, cluster, username, namespace)
   190  	if err != nil {
   191  		return nullBlessings, err
   192  	}
   193  
   194  	// lastly we perform the blessing using the generated k8s extension
   195  	return call.Security().LocalPrincipal().Bless(securityCall.RemoteBlessings().PublicKey(), securityCall.LocalBlessings(), extension, caveat)
   196  }
   197  
   198  // Provides an interface for gather aws data using the context, region, caCrt which can be mocked for testing.
   199  type awsConn struct {
   200  	role           string
   201  	regions        []string
   202  	accountIDs     []string
   203  	sessionWrapper AWSSessionWrapperI
   204  }
   205  
   206  // Creates a new AWS Connect object that can be used to obtain data about AWS, EKS, etc.
   207  func newAwsConn(sessionWrapper AWSSessionWrapperI, role string, regions []string, accountIDs []string) *awsConn {
   208  	return &awsConn{
   209  		sessionWrapper: sessionWrapper,
   210  		role:           role,
   211  		regions:        regions,
   212  		accountIDs:     accountIDs,
   213  	}
   214  }
   215  
   216  // Interface for mocking awsConn.
   217  type awsConnI interface {
   218  	GetEKSCluster(caCrt string) (*eks.Cluster, error)
   219  	GetClusters(ctx *v23context.T, region string) []*eks.Cluster
   220  }
   221  
   222  // Gets an EKS Cluster with Matching AWS region and caCrt.
   223  func (conn *awsConn) GetEKSCluster(ctx *v23context.T, region string, caCrt string) (*eks.Cluster, error) {
   224  	var (
   225  		cluster *eks.Cluster
   226  		err     error
   227  	)
   228  
   229  	caCrtData := base64.StdEncoding.EncodeToString([]byte(caCrt))
   230  	// TODO(noah): If performance becomes an issue, populate allow-list of clusters on ticket-server startup.
   231  	for _, c := range conn.GetClusters(ctx, region) {
   232  		if caCrtData == *c.CertificateAuthority.Data {
   233  			cluster = c
   234  			break
   235  		}
   236  	}
   237  	if cluster == nil {
   238  		err = errors.New("CA certificate does not match any cluster")
   239  	}
   240  	return cluster, err
   241  }
   242  
   243  // Gets all EKS clusters in a given AWS region.
   244  func (conn *awsConn) GetClusters(ctx *v23context.T, region string) []*eks.Cluster {
   245  	var clusters []*eks.Cluster
   246  	for _, r := range conn.regions {
   247  		if r == region {
   248  			for _, id := range conn.accountIDs {
   249  				roleARN := fmt.Sprintf("arn:aws:iam::%s:role/%s", id, conn.role)
   250  				listClusterOutput, err := conn.sessionWrapper.ListEKSClusters(&eks.ListClustersInput{}, roleARN, region)
   251  				if err != nil {
   252  					log.Error(ctx, "Unable to fetch list of clusters.", "roleARN", roleARN, "region", region)
   253  				}
   254  				for _, name := range listClusterOutput.Clusters {
   255  					describeClusterOutput, err := conn.sessionWrapper.DescribeEKSCluster(&eks.DescribeClusterInput{Name: name}, roleARN, region)
   256  					if err != nil {
   257  						log.Error(ctx, "Unable to describe cluster.", "clusterName", *name)
   258  					}
   259  					clusters = append(clusters, describeClusterOutput.Cluster)
   260  				}
   261  			}
   262  		}
   263  	}
   264  	return clusters
   265  }
   266  
   267  // Defines connection parameters to a k8s cluster and can connect and return data. Isolated as an interface so complex http calls can be mocked for testing.
   268  type k8sConn struct {
   269  	cluster        *eks.Cluster
   270  	namespace      string
   271  	region         string
   272  	caCrt          string
   273  	svcAcctToken   string
   274  	sessionWrapper AWSSessionWrapperI
   275  }
   276  
   277  // Creates a new k8s connection object that can be used to connect to the k8s cluster and obtain relevant data.
   278  func newK8sConn(sessionWrapper AWSSessionWrapperI, cluster *eks.Cluster, region string, caCrt string, svcAcctToken string) *k8sConn {
   279  	return &k8sConn{
   280  		sessionWrapper: sessionWrapper,
   281  		cluster:        cluster,
   282  		region:         region,
   283  		caCrt:          caCrt,
   284  		svcAcctToken:   svcAcctToken,
   285  	}
   286  }
   287  
   288  // An interface for mocking k8sConn struct.
   289  type k8sConnI interface {
   290  	GetK8sUsername(ctx context.Context) (string, error)
   291  }
   292  
   293  func (conn *k8sConn) GetK8sUsername(ctx context.Context) (string, error) {
   294  	var (
   295  		username string
   296  		err      error
   297  	)
   298  
   299  	//svc := sts.New(conn.session)
   300  
   301  	var headers = make(map[string]string)
   302  	headers["x-k8s-aws-id"] = *conn.cluster.Name
   303  
   304  	authV1Client, err := conn.sessionWrapper.GetAuthV1Client(ctx, headers, conn.caCrt, conn.region, *conn.cluster.Endpoint)
   305  	if err != nil {
   306  		return username, err
   307  	}
   308  
   309  	log.Debug(ctx, "AuthV1Client retrieved", "caCrt", conn.caCrt, "region", conn.region, "endpoint", *conn.cluster.Endpoint)
   310  
   311  	tr := auth.TokenReview{
   312  		Spec: auth.TokenReviewSpec{
   313  			Token: conn.svcAcctToken,
   314  		},
   315  	}
   316  
   317  	log.Debug(ctx, "K8s Service account token configured for tokenReview request", "token", conn.svcAcctToken)
   318  
   319  	trResp, err := authV1Client.TokenReviews().Create(&tr)
   320  	username = trResp.Status.User.Username
   321  
   322  	if err != nil {
   323  		err = errors.E(err, "unable to create tokenreview")
   324  	} else if !trResp.Status.Authenticated {
   325  		err = errors.New("requestToken authentication failed")
   326  	}
   327  
   328  	return username, err
   329  }
   330  
   331  // CreateK8sExtension evaluates EKS Cluster configuration and tagging to produce a v23 Blessing extension.
   332  func CreateK8sExtension(ctx context.Context, cluster *eks.Cluster, username, namespace string) (string, error) {
   333  	var (
   334  		extension          string
   335  		err                error
   336  		clusterNameFromTag string
   337  	)
   338  
   339  	arn, err := arn.Parse(*cluster.Arn)
   340  	if err != nil {
   341  		return extension, err
   342  	}
   343  
   344  	// Username is of format: system:serviceaccount:(NAMESPACE):(SERVICEACCOUNT)
   345  	usernameSet := strings.Split(username, ":")
   346  
   347  	if len(usernameSet) != 4 {
   348  		return extension, errors.New("username does not match format system:serviceaccount:(NAMESPACE):(SERVICEACCOUNT)")
   349  	} else if namespace != usernameSet[2] {
   350  		return extension, errors.New("namespace does not match")
   351  	}
   352  
   353  	if val, ok := cluster.Tags["ClusterName"]; ok {
   354  		clusterNameFromTag = *val
   355  	}
   356  
   357  	/*
   358  		// leaving this implementation here, commented, as a relic of understanding; the intent was to use this to generate unique blessings
   359  		// for each cluster but allow the clusters blessing for vRPC calls to be authorized by parent blessing extensions, such as k8s:dev:svc:a
   360  		// authorizing when k8s:dev:svc is allowed. However, we cannot do this because Grailbook, and perhaps other services as well
   361  		// expect exact matches for blessings in some circumstances. In order to prevent a complicated rewrite of the code, we are removing
   362  		// the cluster mode specific blessings in order to keep implementation simple and plan to move out of vanadium sooner to something
   363  		// that better matches the authorization method desired.
   364  
   365  		if val, ok := cluster.Tags["ClusterMode"]; ok {
   366  			clusterModeFromTag = strings.ToLower(*val)
   367  		}
   368  	*/
   369  
   370  	if clusterNameFromTag != "" {
   371  		extension = fmt.Sprintf("k8s:%s:%s:%s", arn.AccountID, clusterNameFromTag, usernameSet[3])
   372  		log.Debug(ctx, "Using k8s cluster a/b extension generation.", "extension", extension)
   373  	} else {
   374  		extension = fmt.Sprintf("k8s:%s:%s:%s", arn.AccountID, *cluster.Name, usernameSet[3])
   375  		log.Debug(ctx, "Using standard k8s extension generation.", "extension", extension)
   376  	}
   377  
   378  	return extension, err
   379  }