auth/auth.go (262 lines of code) (raw):

// Copyright 2020 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package auth includes obtains auth tokens for workload identity. package auth import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "cloud.google.com/go/compute/metadata" credentials "cloud.google.com/go/iam/credentials/apiv1" "cloud.google.com/go/iam/credentials/apiv1/credentialspb" secretmanager "cloud.google.com/go/secretmanager/apiv1" "github.com/GoogleCloudPlatform/secrets-store-csi-driver-provider-gcp/config" "github.com/GoogleCloudPlatform/secrets-store-csi-driver-provider-gcp/csrmetrics" "github.com/GoogleCloudPlatform/secrets-store-csi-driver-provider-gcp/vars" "github.com/googleapis/gax-go/v2" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "google.golang.org/grpc" "google.golang.org/grpc/credentials/oauth" authenticationv1 "k8s.io/api/authentication/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/klog/v2" ) const cloudScope = "https://www.googleapis.com/auth/cloud-platform" type Client struct { KubeClient *kubernetes.Clientset MetadataClient *metadata.Client IAMClient *credentials.IamCredentialsClient HTTPClient *http.Client } // JSON key file types. const ( externalAccountKey = "external_account" ) // credentialsFile is the unmarshalled representation of a credentials file. type credentialsFile struct { Type string `json:"type"` // External Account fields Audience string `json:"audience"` } // TokenSource returns the correct oauth2.TokenSource depending on the auth // configuration of the MountConfig. func (c *Client) TokenSource(ctx context.Context, cfg *config.MountConfig) (oauth2.TokenSource, error) { allowSecretRef, err := vars.AllowNodepublishSeretRef.GetBooleanValue() if err != nil { klog.ErrorS(err, "failed to get ALLOW_NODE_PUBLISH_SECRET flag") klog.Fatal("failed to get ALLOW_NODE_PUBLISH_SECRET flag") } if cfg.AuthNodePublishSecret && allowSecretRef { creds, err := google.CredentialsFromJSON(ctx, cfg.AuthKubeSecret, cloudScope) if err != nil { return nil, fmt.Errorf("unable to generate credentials from key.json: %w", err) } return creds.TokenSource, nil } if cfg.AuthProviderADC { return google.DefaultTokenSource(ctx, cloudScope) } if cfg.AuthPodADC { token, err := c.Token(ctx, cfg) if err != nil { return nil, fmt.Errorf("unable to obtain workload identity auth: %v", err) } return oauth2.StaticTokenSource(token), nil } return nil, errors.New("mount configuration has no auth method configured") } // Token fetches a workload identity auth token for the pod for the MountConfig. // // This requires obtaining a ServiceAccount token from the K8S API for the pod, // trading that token for an identitybindingtoken using the // securetoken.googleapis.com API, and then trading that token for a GCP // Service Account token using the iamcredentials.googleapis.com API. // // Caveats: // // None of the API calls are cached since the plugin binary is executed once per // mount event. The tokens are to be used immediately so no refresh abilities are // implemented - blocking Issue #14. // // This method requires additional K8S API permission for the CSI driver // daemonset, including serviceaccounts/token create and serviceaccounts get. // These permissions could break node isolation and a long term solution is // tracked by Issue #13. // // Token sent by driver is extracted and used. However, if tokenRequests is not set // in driver spec, the provider does not receive any tokens from driver and generates // its own token. Token creation can be removed once driver implements the requiresRepublish. func (c *Client) Token(ctx context.Context, cfg *config.MountConfig) (*oauth2.Token, error) { var audience string idPool, idProvider, err := c.gkeWorkloadIdentity(ctx, cfg) if err != nil { idPool, idProvider, audience, err = c.fleetWorkloadIdentity(ctx, cfg) if err != nil { return nil, err } } if audience == "" { audience = fmt.Sprintf("identitynamespace:%s:%s", idPool, idProvider) klog.V(5).InfoS("workload id configured", "pool", idPool, "provider", idProvider) } else { klog.V(5).InfoS("workload federation pool audience", audience) } // Get iam.gke.io/gcp-service-account annotation to see if the // identitybindingtoken token should be traded for a GCP SA token. // See https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity#creating_a_relationship_between_ksas_and_gsas saResp, err := c.KubeClient. CoreV1(). ServiceAccounts(cfg.PodInfo.Namespace). Get(ctx, cfg.PodInfo.ServiceAccount, v1.GetOptions{}) if err != nil { return nil, fmt.Errorf("unable to fetch SA info: %w", err) } gcpSA := saResp.Annotations["iam.gke.io/gcp-service-account"] klog.V(5).InfoS("matched service account", "service_account", gcpSA) // Obtain a serviceaccount token for the pod. var saTokenVal string if cfg.PodInfo.ServiceAccountTokens != "" { saToken, err := c.extractSAToken(cfg, idPool, audience) // calling function to extract token received from driver. if err != nil { return nil, fmt.Errorf("unable to fetch SA token from driver: %w", err) } saTokenVal = saToken.Token } else { saToken, err := c.generatePodSAToken(ctx, cfg, idPool, audience) // if no token received, provider generates its own token. if err != nil { return nil, fmt.Errorf("unable to fetch pod token: %w", err) } saTokenVal = saToken.Token } // Trade the kubernetes token for an identitybindingtoken token. idBindToken, err := tradeIDBindToken(ctx, c.HTTPClient, saTokenVal, audience) if err != nil { return nil, fmt.Errorf("unable to fetch identitybindingtoken: %w", err) } // If no `iam.gke.io/gcp-service-account` annotation is present the // identitybindingtoken will be used directly, allowing bindings on secrets // of the form "serviceAccount:<project>.svc.id.goog[<namespace>/<sa>]". if gcpSA == "" { return idBindToken, nil } req := &credentialspb.GenerateAccessTokenRequest{ Name: fmt.Sprintf("projects/-/serviceAccounts/%s", gcpSA), Scope: secretmanager.DefaultAuthScopes(), } if gcpSADelegates, ok := saResp.Annotations["iam.gke.io/gcp-service-account-delegates"]; ok { var delegates []string if err := json.Unmarshal([]byte(gcpSADelegates), &delegates); err != nil { return nil, fmt.Errorf("unable to parse delegates annotation on SA: %w", err) } klog.V(5).InfoS("matched service account delegates", "service_account_delegates", delegates) for _, delegate := range delegates { req.Delegates = append(req.Delegates, fmt.Sprintf("projects/-/serviceAccounts/%s", delegate)) } } gcpSAResp, err := c.IAMClient.GenerateAccessToken(ctx, req, gax.WithGRPCOptions(grpc.PerRPCCredentials(oauth.TokenSource{TokenSource: oauth2.StaticTokenSource(idBindToken)}))) if err != nil { return nil, fmt.Errorf("unable to fetch gcp service account token: %w", err) } return &oauth2.Token{AccessToken: gcpSAResp.GetAccessToken()}, nil } func (c *Client) extractSAToken(cfg *config.MountConfig, idPool, audience string) (*authenticationv1.TokenRequestStatus, error) { audienceTokens := map[string]authenticationv1.TokenRequestStatus{} if err := json.Unmarshal([]byte(cfg.PodInfo.ServiceAccountTokens), &audienceTokens); err != nil { return nil, err } for k, v := range audienceTokens { if k == idPool || k == audience { // Only returns the token if the audience is the workload identity. Other tokens cannot be used. return &v, nil } } return nil, fmt.Errorf("no token has audience value of idPool") } func (c *Client) generatePodSAToken(ctx context.Context, cfg *config.MountConfig, idPool, audience string) (*authenticationv1.TokenRequestStatus, error) { ttl := int64((15 * time.Minute).Seconds()) _audience := idPool if _audience == "" { _audience = audience } resp, err := c.KubeClient.CoreV1(). ServiceAccounts(cfg.PodInfo.Namespace). CreateToken(ctx, cfg.PodInfo.ServiceAccount, &authenticationv1.TokenRequest{ Spec: authenticationv1.TokenRequestSpec{ ExpirationSeconds: &ttl, Audiences: []string{_audience}, BoundObjectRef: &authenticationv1.BoundObjectReference{ Kind: "Pod", // Pod and secret are the only valid types APIVersion: "v1", Name: cfg.PodInfo.Name, UID: cfg.PodInfo.UID, }, }, }, v1.CreateOptions{}, ) if err != nil { return nil, fmt.Errorf("unable to fetch pod token: %w", err) } return &resp.Status, nil } func (c *Client) gkeWorkloadIdentity(ctx context.Context, cfg *config.MountConfig) (string, string, error) { // Determine Workload ID parameters from the GCE instance metadata. projectID, err := c.MetadataClient.ProjectIDWithContext(ctx) if err != nil { return "", "", fmt.Errorf("unable to get project id: %w", err) } idPool := fmt.Sprintf("%s.svc.id.goog", projectID) clusterLocation, err := c.MetadataClient.InstanceAttributeValueWithContext(ctx, "cluster-location") if err != nil { return "", "", fmt.Errorf("unable to determine cluster location: %w", err) } clusterName, err := c.MetadataClient.InstanceAttributeValueWithContext(ctx, "cluster-name") if err != nil { return "", "", fmt.Errorf("unable to determine cluster name: %w", err) } gkeWorkloadIdentityProviderEndpoint, err := vars.GkeWorkloadIdentityEndPoint.GetValue() if err != nil { return "", "", fmt.Errorf("unable to read GKE workload identity provider endpoint: %w", err) } idProvider := fmt.Sprintf("%s/projects/%s/locations/%s/clusters/%s", gkeWorkloadIdentityProviderEndpoint, projectID, clusterLocation, clusterName) return idPool, idProvider, nil } func (c *Client) fleetWorkloadIdentity(ctx context.Context, cfg *config.MountConfig) (string, string, string, error) { const envVar = "GOOGLE_APPLICATION_CREDENTIALS" var jsonData []byte var err error if filename := os.Getenv(envVar); filename != "" { jsonData, err = os.ReadFile(filepath.Clean(filename)) if err != nil { return "", "", "", fmt.Errorf("google: error getting credentials using %v environment variable: %v", envVar, err) } } // Parse jsonData as one of the other supported credentials files. var f credentialsFile if err := json.Unmarshal(jsonData, &f); err != nil { return "", "", "", err } if f.Type != externalAccountKey { return "", "", "", fmt.Errorf("google: unexpected credentials type: %v, expected: %v", f.Type, externalAccountKey) } split := strings.SplitN(f.Audience, ":", 3) if len(split) < 3 { // If the audience is not in the expected format, return the audience as the audience since this is likely a federated pool. return "", "", f.Audience, nil } idPool := split[1] idProvider := split[2] return idPool, idProvider, "", nil } func tradeIDBindToken(ctx context.Context, client *http.Client, k8sToken, audience string) (*oauth2.Token, error) { body, err := json.Marshal(map[string]string{ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", "subject_token": k8sToken, "audience": audience, "scope": "https://www.googleapis.com/auth/cloud-platform", }) if err != nil { return nil, err } identityBindingTokenEndPoint, err := vars.IdentityBindingTokenEndPoint.GetValue() if err != nil { return nil, fmt.Errorf("unable to read identity binding token endpoint: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", identityBindingTokenEndPoint, bytes.NewBuffer(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") gcpIamMetricRecorder := csrmetrics.OutboundRPCStartRecorder("gcp_iam_get_id_bind_token_requests") resp, err := client.Do(req) if err != nil { return nil, err } gcpIamMetricRecorder(csrmetrics.OutboundRPCStatus(strconv.Itoa(resp.StatusCode))) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("could not get idbindtoken token, status: %v", resp.StatusCode) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, err } idBindToken := &oauth2.Token{} if err := json.Unmarshal(respBody, idBindToken); err != nil { return nil, err } return idBindToken, nil }