diff --git a/backend.go b/backend.go index 2d12c931..3628a788 100644 --- a/backend.go +++ b/backend.go @@ -51,6 +51,9 @@ type kubeAuthBackend struct { // review. Mocks should only be used in tests. reviewFactory tokenReviewFactory + // serviceAccountReaderFactory is used to read service account annotations + serviceAccountReaderFactory serviceAccountReaderFactory + // localSATokenReader caches the service account token in memory. // It periodically reloads the token to support token rotation/renewal. // Local token is used when running in a pod with following configuration @@ -105,6 +108,7 @@ func Backend() *kubeAuthBackend { // Set the review factory to default to calling into the kubernetes API. b.reviewFactory = tokenReviewAPIFactory + b.serviceAccountReaderFactory = serviceAccountAPIFactory return b } diff --git a/path_config.go b/path_config.go index d61b5d93..afee13f9 100644 --- a/path_config.go +++ b/path_config.go @@ -82,6 +82,14 @@ then this plugin will use kubernetes.io/serviceaccount as the default issuer. Name: "Disable use of local CA and service account JWT", }, }, + "enable_custom_metadata_from_annotations": { + Type: framework.TypeBool, + Description: "Enable reading and parsing annotations from service account for policy templating", + Default: false, + DisplayAttrs: &framework.DisplayAttributes{ + Name: "Enable reading and parsing service account annotations", + }, + }, }, Callbacks: map[logical.Operation]framework.OperationFunc{ logical.UpdateOperation: b.pathConfigWrite, @@ -110,6 +118,7 @@ func (b *kubeAuthBackend) pathConfigRead(ctx context.Context, req *logical.Reque "issuer": config.Issuer, "disable_iss_validation": config.DisableISSValidation, "disable_local_ca_jwt": config.DisableLocalCAJwt, + "enable_custom_metadata_from_annotations": config.EnableCustomMetadataFromAnnotations, }, } @@ -130,6 +139,7 @@ func (b *kubeAuthBackend) pathConfigWrite(ctx context.Context, req *logical.Requ issuer := data.Get("issuer").(string) disableIssValidation := data.Get("disable_iss_validation").(bool) tokenReviewer := data.Get("token_reviewer_jwt").(string) + enableCustomMetadata := data.Get("enable_custom_metadata_from_annotations").(bool) if tokenReviewer != "" { // Validate it's a JWT @@ -144,14 +154,15 @@ func (b *kubeAuthBackend) pathConfigWrite(ctx context.Context, req *logical.Requ } config := &kubeConfig{ - PublicKeys: make([]interface{}, len(pemList)), - PEMKeys: pemList, - Host: host, - CACert: caCert, - TokenReviewerJWT: tokenReviewer, - Issuer: issuer, - DisableISSValidation: disableIssValidation, - DisableLocalCAJwt: disableLocalJWT, + PublicKeys: make([]interface{}, len(pemList)), + PEMKeys: pemList, + Host: host, + CACert: caCert, + TokenReviewerJWT: tokenReviewer, + Issuer: issuer, + DisableISSValidation: disableIssValidation, + DisableLocalCAJwt: disableLocalJWT, + EnableCustomMetadataFromAnnotations: enableCustomMetadata, } var err error @@ -195,6 +206,9 @@ type kubeConfig struct { // the local CA cert and service account jwt when running in a Kubernetes // pod DisableLocalCAJwt bool `json:"disable_local_ca_jwt"` + // EnableCustomMetadataFromAnnotations is an optional parameter which will cause + // us to read the kubernetes ServiceAccount's annotations as metadata of auth alias. + EnableCustomMetadataFromAnnotations bool `json:"enable_custom_metadata_from_annotations"` } // PasrsePublicKeyPEM is used to parse RSA and ECDSA public keys from PEMs diff --git a/path_config_test.go b/path_config_test.go index e5cd86bb..0d6a4bba 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -47,6 +47,7 @@ func TestConfig_Read(t *testing.T) { "issuer": "", "disable_iss_validation": false, "disable_local_ca_jwt": false, + "enable_custom_metadata_from_annotations": false, } req := &logical.Request{ diff --git a/path_login.go b/path_login.go index ec8d8a1b..3c565ddd 100644 --- a/path_login.go +++ b/path_login.go @@ -92,7 +92,7 @@ func (b *kubeAuthBackend) pathLogin(ctx context.Context, req *logical.Request, d return nil, err } - serviceAccount, err := b.parseAndValidateJWT(jwtStr, role, config) + serviceAccount, err := b.parseAndValidateJWT(ctx, jwtStr, role, config) if err != nil { return nil, err } @@ -113,6 +113,7 @@ func (b *kubeAuthBackend) pathLogin(ctx context.Context, req *logical.Request, d if err != nil { return nil, err } + auth := &logical.Auth{ Alias: &logical.Alias{ Name: aliasName, @@ -136,6 +137,21 @@ func (b *kubeAuthBackend) pathLogin(ctx context.Context, req *logical.Request, d DisplayName: fmt.Sprintf("%s-%s", serviceAccount.namespace(), serviceAccount.name()), } + if serviceAccount.Annotations != nil { + for key, value := range serviceAccount.Annotations { + // Ensure it's not possible to overwrite service_account_* information + if _, exists := auth.Alias.Metadata[key]; exists { + continue + } + if _, exists := auth.Metadata[key]; exists { + continue + } + + auth.Alias.Metadata[key] = value + auth.Metadata[key] = value + } + } + role.PopulateTokenAuth(auth) return &logical.Response{ @@ -198,7 +214,7 @@ func (b *kubeAuthBackend) aliasLookahead(ctx context.Context, req *logical.Reque // validation of the JWT against the provided role ensures alias look ahead requests // are authentic. - sa, err := b.parseAndValidateJWT(jwtStr, role, config) + sa, err := b.parseAndValidateJWT(ctx, jwtStr, role, config) if err != nil { return nil, err } @@ -218,7 +234,7 @@ func (b *kubeAuthBackend) aliasLookahead(ctx context.Context, req *logical.Reque } // parseAndValidateJWT is used to parse, validate and lookup the JWT token. -func (b *kubeAuthBackend) parseAndValidateJWT(jwtStr string, role *roleStorageEntry, config *kubeConfig) (*serviceAccount, error) { +func (b *kubeAuthBackend) parseAndValidateJWT(ctx context.Context, jwtStr string, role *roleStorageEntry, config *kubeConfig) (*serviceAccount, error) { // Parse into JWT parsedJWT, err := jws.ParseJWT([]byte(jwtStr)) if err != nil { @@ -272,6 +288,15 @@ func (b *kubeAuthBackend) parseAndValidateJWT(jwtStr string, role *roleStorageEn return nil, err } + if config.EnableCustomMetadataFromAnnotations { + annotations, err := b.serviceAccountReaderFactory(config).ReadAnnotations(ctx, sa.Name, sa.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to read serviceaccount annotations: %v", err) + } + + sa.Annotations = annotations + } + // If we don't have any public keys to verify, return the sa and end early. if len(config.PublicKeys) == 0 { return sa, nil @@ -357,6 +382,11 @@ type serviceAccount struct { Kubernetes *projectedServiceToken `mapstructure:"kubernetes.io"` Expiration int64 `mapstructure:"exp"` IssuedAt int64 `mapstructure:"iat"` + + // Kubernetes annotations for the service account with the `allowedAnnotationPrefix`, + // which will be loaded here if `config.EnableCustomMetadataFromAnnotations` is + // enabled. + Annotations map[string]string } // uid returns the UID for the service account, preferring the projected service diff --git a/path_login_test.go b/path_login_test.go index 5d7ac0b8..802c67ba 100644 --- a/path_login_test.go +++ b/path_login_test.go @@ -13,10 +13,11 @@ import ( ) var ( - testNamespace = "default" - testName = "vault-auth" - testUID = "d77f89bc-9055-11e7-a068-0800276d99bf" - testMockFactory = mockTokenReviewFactory(testName, testNamespace, testUID) + testNamespace = "default" + testName = "vault-auth" + testUID = "d77f89bc-9055-11e7-a068-0800276d99bf" + testMockTokenReviewFactory = mockTokenReviewFactory(testName, testNamespace, testUID) + testMockServiceAccountReaderFactory = mockServiceAccountReaderFactory(map[string]string{"service_role": "authz"}) testGlobbedNamespace = "def*" testGlobbedName = "vault-*" @@ -32,10 +33,11 @@ var ( ) type testBackendConfig struct { - pems []string - saName string - saNamespace string - aliasNameSource string + pems []string + saName string + saNamespace string + aliasNameSource string + customMetadataFromAnnotations bool } func defaultTestBackendConfig() *testBackendConfig { @@ -55,6 +57,7 @@ func setupBackend(t *testing.T, config *testBackendConfig) (logical.Backend, log "pem_keys": config.pems, "kubernetes_host": "host", "kubernetes_ca_cert": testCACert, + "enable_custom_metadata_from_annotations": config.customMetadataFromAnnotations, } req := &logical.Request{ @@ -92,7 +95,8 @@ func setupBackend(t *testing.T, config *testBackendConfig) (logical.Backend, log t.Fatalf("err:%s resp:%#v\n", err, resp) } - b.(*kubeAuthBackend).reviewFactory = testMockFactory + b.(*kubeAuthBackend).reviewFactory = testMockTokenReviewFactory + b.(*kubeAuthBackend).serviceAccountReaderFactory = testMockServiceAccountReaderFactory return b, storage } @@ -586,6 +590,78 @@ func TestLoginSvcAcctAndNamespaceSplats(t *testing.T) { } } +func TestLoginWithServiceAccountAnnotations(t *testing.T) { + config := defaultTestBackendConfig() + config.customMetadataFromAnnotations = true + b, storage := setupBackend(t, config) + + data := map[string]interface{}{ + "role": "plugin-test", + "jwt": jwtData, + } + + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "login", + Storage: storage, + Data: data, + Connection: &logical.Connection{ + RemoteAddr: "127.0.0.1", + }, + } + + resp, err := b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + if role := resp.Auth.Metadata["service_role"]; role != "authz" { + t.Fatalf("expected service_role in Auth.Metadata, got: %s", role) + } + + if role := resp.Auth.Alias.Metadata["service_role"]; role != "authz" { + t.Fatalf("expected service_role in Auth.Alias.Metadata, got: %s", role) + } + + // test that we can't overwrite service_account_name and other properties with annotations + + b.(*kubeAuthBackend).serviceAccountReaderFactory = mockServiceAccountReaderFactory(map[string]string{ + "service_account_name": "overwritten", + "service_account_uid": "overwritten", + "service_account_namespace": "overwritten", + "service_account_secret_name": "overwritten", + }) + resp, err = b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + if val := resp.Auth.Metadata["service_account_name"]; val != "vault-auth" { + t.Fatalf("unexpected service_account_name: %s", val) + } + if val := resp.Auth.Alias.Metadata["service_account_name"]; val != "vault-auth" { + t.Fatalf("unexpected service_account_name: %s", val) + } + if val := resp.Auth.Metadata["service_account_namespace"]; val != "default" { + t.Fatalf("unexpected service_account_namespace: %s", val) + } + if val := resp.Auth.Alias.Metadata["service_account_namespace"]; val != "default" { + t.Fatalf("unexpected service_account_namespace: %s", val) + } + if val := resp.Auth.Metadata["service_account_uid"]; val != "d77f89bc-9055-11e7-a068-0800276d99bf" { + t.Fatalf("unexpected service_account_uid: %s", val) + } + if val := resp.Auth.Alias.Metadata["service_account_uid"]; val != "d77f89bc-9055-11e7-a068-0800276d99bf" { + t.Fatalf("unexpected service_account_uid: %s", val) + } + if val := resp.Auth.Metadata["service_account_secret_name"]; val != "vault-auth-token-t5pcn" { + t.Fatalf("unexpected service_account_secret_name: %s", val) + } + if val := resp.Auth.Alias.Metadata["service_account_secret_name"]; val != "vault-auth-token-t5pcn" { + t.Fatalf("unexpected service_account_secret_name: %s", val) + } +} + func TestAliasLookAhead(t *testing.T) { testCases := map[string]struct { role string @@ -904,12 +980,12 @@ func TestLoginProjectedToken(t *testing.T) { "normal": { role: "plugin-test", jwt: jwtData, - tokenReview: testMockFactory, + tokenReview: testMockTokenReviewFactory, }, "fail": { role: "plugin-test-x", jwt: jwtData, - tokenReview: testMockFactory, + tokenReview: testMockTokenReviewFactory, e: roleNameError, }, "projected-token": { @@ -950,6 +1026,7 @@ func TestLoginProjectedToken(t *testing.T) { } b.(*kubeAuthBackend).reviewFactory = tc.tokenReview + b.(*kubeAuthBackend).serviceAccountReaderFactory = testMockServiceAccountReaderFactory resp, err := b.HandleRequest(context.Background(), req) if err != nil && tc.e == nil { @@ -1009,6 +1086,26 @@ func TestAliasLookAheadProjectedToken(t *testing.T) { } } +type mockServiceAccountReader struct { + annotations map[string]string +} + +func mockServiceAccountReaderFactory(annotations map[string]string) serviceAccountReaderFactory { + return func(config *kubeConfig) serviceAccountReader { + return &mockServiceAccountReader{ + annotations: annotations, + } + } +} + +func (s *mockServiceAccountReader) ReadAnnotations(ctx context.Context, name, namespace string) (map[string]string, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + return s.annotations, nil +} + // jwtProjectedData is a Projected Service Account jwt with expiration set to // 05 Nov 2030 04:19:57 (UTC) // diff --git a/service_account_lookup.go b/service_account_lookup.go new file mode 100644 index 00000000..400c7822 --- /dev/null +++ b/service_account_lookup.go @@ -0,0 +1,112 @@ +package kubeauth + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + + cleanhttp "github.com/hashicorp/go-cleanhttp" + corev1 "k8s.io/api/core/v1" + kubeerrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const allowedAnnotationPrefix = "auth-metadata.vault.hashicorp.com/" + +type serviceAccountReader interface { + ReadAnnotations(ctx context.Context, name, namespace string) (map[string]string, error) +} + +type serviceAccountReaderFactory func(*kubeConfig) serviceAccountReader + +func serviceAccountAPIFactory(config *kubeConfig) serviceAccountReader { + s := &serviceAccountAPI{ + client: cleanhttp.DefaultPooledClient(), + config: config, + } + + // If we have a CA cert build the TLSConfig + if len(config.CACert) > 0 { + certPool := x509.NewCertPool() + certPool.AppendCertsFromPEM([]byte(config.CACert)) + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: certPool, + } + + s.client.Transport.(*http.Transport).TLSClientConfig = tlsConfig + } + + return s +} + +type serviceAccountAPI struct { + client *http.Client + config *kubeConfig +} + +func (s *serviceAccountAPI) ReadAnnotations(ctx context.Context, name, namespace string) (map[string]string, error) { + url := fmt.Sprintf("%s/api/v1/namespaces/%s/serviceaccounts/%s", strings.TrimSuffix(s.config.Host, "/"), namespace, name) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + bearer := fmt.Sprintf("Bearer %s", strings.TrimSpace(s.config.TokenReviewerJWT)) + + req.Header.Set("Authorization", bearer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + rsp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to talk to kubernetes API: %v", err) + } + + svcAccount, err := parseServiceAccountResponse(rsp) + if err != nil { + return nil, fmt.Errorf("failed to parse serviceaccount response: %v", err) + } + + // Filter for annotations that have a prefix and are destined for this plugin + filtered := map[string]string{} + for key, value := range svcAccount.Annotations { + if strings.HasPrefix(key, allowedAnnotationPrefix) { + // Normalise the annotations to match the current snake_case pattern. + // Ex: auth-metadata.vault.hashicorp.com/service-role: authorization + // Will become: service_role: authorization + key := strings.ReplaceAll(strings.TrimPrefix(key, allowedAnnotationPrefix), "-", "_") + filtered[key] = value + } + } + + return filtered, nil +} + +// parseResponse takes the API response and either returns the appropriate error +// or the TokenReview Object. +func parseServiceAccountResponse(rsp *http.Response) (*corev1.ServiceAccount, error) { + body, err := ioutil.ReadAll(rsp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read out body: %v", err) + } + defer rsp.Body.Close() + + if rsp.StatusCode < http.StatusOK || rsp.StatusCode > http.StatusPartialContent { + return nil, kubeerrors.NewGenericServerResponse(rsp.StatusCode, "POST", schema.GroupResource{}, "", strings.TrimSpace(string(body)), 0, true) + } + + svcAccount := &corev1.ServiceAccount{} + err = json.Unmarshal(body, svcAccount) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal into corev1.ServiceAccount: %v", err) + } + + return svcAccount, nil +}