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: ®ion, 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: ®ion, 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 }