diff --git a/pkg/auth/authorizer/interfaces.go b/pkg/auth/authorizer/interfaces.go index 8ebbf65ea07..11ed3cc64bc 100644 --- a/pkg/auth/authorizer/interfaces.go +++ b/pkg/auth/authorizer/interfaces.go @@ -50,6 +50,12 @@ type Authorizer interface { Authorize(a Attributes) (err error) } +type AuthorizerFunc func(a Attributes) error + +func (f AuthorizerFunc) Authorize(a Attributes) error { + return f(a) +} + // AttributesRecord implements Attributes interface. type AttributesRecord struct { User user.Info diff --git a/test/e2e/service_accounts.go b/test/e2e/service_accounts.go new file mode 100644 index 00000000000..c14801a36c2 --- /dev/null +++ b/test/e2e/service_accounts.go @@ -0,0 +1,101 @@ +/* +Copyright 2014 The Kubernetes Authors All rights reserved. + +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 + + http://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 e2e + +import ( + "fmt" + "time" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/fields" + "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util/wait" + "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/admission/serviceaccount" + + . "github.com/onsi/ginkgo" +) + +var _ = Describe("ServiceAccounts", func() { + var c *client.Client + var ns string + + BeforeEach(func() { + var err error + c, err = loadClient() + expectNoError(err) + ns_, err := createTestingNS("service-accounts", c) + ns = ns_.Name + expectNoError(err) + }) + + AfterEach(func() { + // Clean up the namespace if a non-default one was used + if ns != api.NamespaceDefault { + By("Cleaning up the namespace") + err := c.Namespaces().Delete(ns) + expectNoError(err) + } + }) + + It("should mount an API token into pods", func() { + var tokenName string + var tokenContent string + + // Standard get, update retry loop + expectNoError(wait.Poll(time.Millisecond*500, time.Second*10, func() (bool, error) { + By("getting the auto-created API token") + tokenSelector := fields.SelectorFromSet(map[string]string{client.SecretType: string(api.SecretTypeServiceAccountToken)}) + secrets, err := c.Secrets(ns).List(labels.Everything(), tokenSelector) + if err != nil { + return false, err + } + if len(secrets.Items) == 0 { + return false, nil + } + if len(secrets.Items) > 1 { + return false, fmt.Errorf("Expected 1 token secret, got %d", len(secrets.Items)) + } + tokenName = secrets.Items[0].Name + tokenContent = string(secrets.Items[0].Data[api.ServiceAccountTokenKey]) + return true, nil + })) + + pod := &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Name: "pod-service-account-" + string(util.NewUUID()), + }, + Spec: api.PodSpec{ + Containers: []api.Container{ + { + Name: "service-account-test", + Image: "kubernetes/mounttest:0.1", + Args: []string{ + fmt.Sprintf("--file_content=%s/%s", serviceaccount.DefaultAPITokenMountPath, api.ServiceAccountTokenKey), + }, + }, + }, + RestartPolicy: api.RestartPolicyNever, + }, + } + + testContainerOutputInNamespace("consume service account token", c, pod, []string{ + fmt.Sprintf(`content of file "%s/%s": %s`, serviceaccount.DefaultAPITokenMountPath, api.ServiceAccountTokenKey, tokenContent), + }, ns) + }) +}) diff --git a/test/integration/service_account_test.go b/test/integration/service_account_test.go new file mode 100644 index 00000000000..77b8a9d4518 --- /dev/null +++ b/test/integration/service_account_test.go @@ -0,0 +1,565 @@ +// +build integration,!no-etcd + +/* +Copyright 2014 The Kubernetes Authors All rights reserved. + +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 + + http://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 integration + +// This file tests authentication and (soon) authorization of HTTP requests to a master object. +// It does not use the client in pkg/client/... because authentication and authorization needs +// to work for any client of the HTTP interface. + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api/errors" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api/testapi" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/authenticator" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/authenticator/bearertoken" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/authorizer" + "github.com/GoogleCloudPlatform/kubernetes/pkg/auth/user" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/fields" + "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" + "github.com/GoogleCloudPlatform/kubernetes/pkg/master" + "github.com/GoogleCloudPlatform/kubernetes/pkg/serviceaccount" + "github.com/GoogleCloudPlatform/kubernetes/pkg/tools/etcdtest" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util/wait" + serviceaccountadmission "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/admission/serviceaccount" + "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/auth/authenticator/request/union" +) + +const ( + rootUserName = "root" + rootToken = "root-user-token" + + readOnlyServiceAccountName = "ro" + readWriteServiceAccountName = "rw" +) + +func init() { + requireEtcd() +} + +func TestServiceAccountAutoCreate(t *testing.T) { + c, _, stopFunc := startServiceAccountTestServer(t) + defer stopFunc() + + ns := "test-service-account-creation" + + // Create namespace + _, err := c.Namespaces().Create(&api.Namespace{ObjectMeta: api.ObjectMeta{Name: ns}}) + if err != nil { + t.Fatalf("could not create namespace: %v", err) + } + + // Get service account + defaultUser, err := getServiceAccount(c, ns, "default", true) + if err != nil { + t.Fatalf("Default serviceaccount not created: %v", err) + } + + // Delete service account + err = c.ServiceAccounts(ns).Delete(defaultUser.Name) + if err != nil { + t.Fatalf("Could not delete default serviceaccount: %v", err) + } + + // Get recreated service account + defaultUser2, err := getServiceAccount(c, ns, "default", true) + if err != nil { + t.Fatalf("Default serviceaccount not created: %v", err) + } + if defaultUser2.UID == defaultUser.UID { + t.Fatalf("Expected different UID with recreated serviceaccount") + } +} + +func TestServiceAccountTokenAutoCreate(t *testing.T) { + c, _, stopFunc := startServiceAccountTestServer(t) + defer stopFunc() + + ns := "test-service-account-token-creation" + name := "my-service-account" + + // Create namespace + _, err := c.Namespaces().Create(&api.Namespace{ObjectMeta: api.ObjectMeta{Name: ns}}) + if err != nil { + t.Fatalf("could not create namespace: %v", err) + } + + // Create service account + serviceAccount, err := c.ServiceAccounts(ns).Create(&api.ServiceAccount{ObjectMeta: api.ObjectMeta{Name: name}}) + if err != nil { + t.Fatalf("Service Account not created: %v", err) + } + + // Get token + token1Name, token1, err := getReferencedServiceAccountToken(c, ns, name, true) + if err != nil { + t.Fatal(err) + } + + // Delete token + err = c.Secrets(ns).Delete(token1Name) + if err != nil { + t.Fatalf("Could not delete token: %v", err) + } + + // Get recreated token + token2Name, token2, err := getReferencedServiceAccountToken(c, ns, name, true) + if err != nil { + t.Fatal(err) + } + if token1Name == token2Name { + t.Fatalf("Expected new auto-created token name") + } + if token1 == token2 { + t.Fatalf("Expected new auto-created token value") + } + + // Trigger creation of a new referenced token + serviceAccount, err = c.ServiceAccounts(ns).Get(name) + if err != nil { + t.Fatal(err) + } + serviceAccount.Secrets = []api.ObjectReference{} + _, err = c.ServiceAccounts(ns).Update(serviceAccount) + if err != nil { + t.Fatal(err) + } + + // Get rotated token + token3Name, token3, err := getReferencedServiceAccountToken(c, ns, name, true) + if err != nil { + t.Fatal(err) + } + if token3Name == token2Name { + t.Fatalf("Expected new auto-created token name") + } + if token3 == token2 { + t.Fatalf("Expected new auto-created token value") + } + + // Delete service account + err = c.ServiceAccounts(ns).Delete(name) + if err != nil { + t.Fatal(err) + } + + // Wait for tokens to be deleted + tokensToCleanup := util.NewStringSet(token1Name, token2Name, token3Name) + err = wait.Poll(time.Second, 10*time.Second, func() (bool, error) { + // Get all secrets in the namespace + secrets, err := c.Secrets(ns).List(labels.Everything(), fields.Everything()) + // Retrieval errors should fail + if err != nil { + return false, err + } + for _, s := range secrets.Items { + if tokensToCleanup.Has(s.Name) { + // Still waiting for tokens to be cleaned up + return false, nil + } + } + // All clean + return true, nil + }) + if err != nil { + t.Fatalf("Error waiting for tokens to be deleted: %v", err) + } +} + +func TestServiceAccountTokenAutoMount(t *testing.T) { + c, _, stopFunc := startServiceAccountTestServer(t) + defer stopFunc() + + ns := "auto-mount-ns" + + // Create "my" namespace + _, err := c.Namespaces().Create(&api.Namespace{ObjectMeta: api.ObjectMeta{Name: ns}}) + if err != nil && !errors.IsAlreadyExists(err) { + t.Fatalf("could not create namespace: %v", err) + } + + // Get default token + defaultTokenName, _, err := getReferencedServiceAccountToken(c, ns, serviceaccountadmission.DefaultServiceAccountName, true) + if err != nil { + t.Fatal(err) + } + + // Pod to create + protoPod := api.Pod{ + ObjectMeta: api.ObjectMeta{Name: "protopod"}, + Spec: api.PodSpec{ + Containers: []api.Container{ + { + Name: "container-1", + Image: "container-1-image", + }, + { + Name: "container-2", + Image: "container-2-image", + VolumeMounts: []api.VolumeMount{ + {Name: "empty-dir", MountPath: serviceaccountadmission.DefaultAPITokenMountPath}, + }, + }, + }, + Volumes: []api.Volume{ + { + Name: "empty-dir", + VolumeSource: api.VolumeSource{EmptyDir: &api.EmptyDirVolumeSource{}}, + }, + }, + }, + } + + // Pod we expect to get created + expectedServiceAccount := serviceaccountadmission.DefaultServiceAccountName + expectedVolumes := append(protoPod.Spec.Volumes, api.Volume{ + Name: defaultTokenName, + VolumeSource: api.VolumeSource{ + Secret: &api.SecretVolumeSource{ + SecretName: defaultTokenName, + }, + }, + }) + expectedContainer1VolumeMounts := []api.VolumeMount{ + {Name: defaultTokenName, MountPath: serviceaccountadmission.DefaultAPITokenMountPath, ReadOnly: true}, + } + expectedContainer2VolumeMounts := protoPod.Spec.Containers[1].VolumeMounts + + createdPod, err := c.Pods(ns).Create(&protoPod) + if err != nil { + t.Fatal(err) + } + if createdPod.Spec.ServiceAccount != expectedServiceAccount { + t.Fatalf("Expected %s, got %s", expectedServiceAccount, createdPod.Spec.ServiceAccount) + } + if !api.Semantic.DeepEqual(&expectedVolumes, &createdPod.Spec.Volumes) { + t.Fatalf("Expected\n\t%#v\n\tgot\n\t%#v", expectedVolumes, createdPod.Spec.Volumes) + } + if !api.Semantic.DeepEqual(&expectedContainer1VolumeMounts, &createdPod.Spec.Containers[0].VolumeMounts) { + t.Fatalf("Expected\n\t%#v\n\tgot\n\t%#v", expectedContainer1VolumeMounts, createdPod.Spec.Containers[0].VolumeMounts) + } + if !api.Semantic.DeepEqual(&expectedContainer2VolumeMounts, &createdPod.Spec.Containers[1].VolumeMounts) { + t.Fatalf("Expected\n\t%#v\n\tgot\n\t%#v", expectedContainer2VolumeMounts, createdPod.Spec.Containers[1].VolumeMounts) + } +} + +func TestServiceAccountTokenAuthentication(t *testing.T) { + c, config, stopFunc := startServiceAccountTestServer(t) + defer stopFunc() + + myns := "auth-ns" + otherns := "other-ns" + + // Create "my" namespace + _, err := c.Namespaces().Create(&api.Namespace{ObjectMeta: api.ObjectMeta{Name: myns}}) + if err != nil && !errors.IsAlreadyExists(err) { + t.Fatalf("could not create namespace: %v", err) + } + + // Create "other" namespace + _, err = c.Namespaces().Create(&api.Namespace{ObjectMeta: api.ObjectMeta{Name: otherns}}) + if err != nil && !errors.IsAlreadyExists(err) { + t.Fatalf("could not create namespace: %v", err) + } + + // Create "ro" user in myns + _, err = c.ServiceAccounts(myns).Create(&api.ServiceAccount{ObjectMeta: api.ObjectMeta{Name: readOnlyServiceAccountName}}) + if err != nil { + t.Fatalf("Service Account not created: %v", err) + } + roTokenName, roToken, err := getReferencedServiceAccountToken(c, myns, readOnlyServiceAccountName, true) + if err != nil { + t.Fatal(err) + } + roClientConfig := config + roClientConfig.BearerToken = roToken + roClient := client.NewOrDie(&roClientConfig) + doServiceAccountAPIRequests(t, roClient, myns, true, true, false) + doServiceAccountAPIRequests(t, roClient, otherns, true, false, false) + err = c.Secrets(myns).Delete(roTokenName) + if err != nil { + t.Fatalf("could not delete token: %v", err) + } + doServiceAccountAPIRequests(t, roClient, myns, false, false, false) + + // Create "rw" user in myns + _, err = c.ServiceAccounts(myns).Create(&api.ServiceAccount{ObjectMeta: api.ObjectMeta{Name: readWriteServiceAccountName}}) + if err != nil { + t.Fatalf("Service Account not created: %v", err) + } + _, rwToken, err := getReferencedServiceAccountToken(c, myns, readWriteServiceAccountName, true) + if err != nil { + t.Fatal(err) + } + rwClientConfig := config + rwClientConfig.BearerToken = rwToken + rwClient := client.NewOrDie(&rwClientConfig) + doServiceAccountAPIRequests(t, rwClient, myns, true, true, true) + doServiceAccountAPIRequests(t, rwClient, otherns, true, false, false) + + // Get default user and token which should have been automatically created + _, defaultToken, err := getReferencedServiceAccountToken(c, myns, "default", true) + if err != nil { + t.Fatalf("could not get default user and token: %v", err) + } + defaultClientConfig := config + defaultClientConfig.BearerToken = defaultToken + defaultClient := client.NewOrDie(&defaultClientConfig) + doServiceAccountAPIRequests(t, defaultClient, myns, true, false, false) +} + +// startServiceAccountTestServer returns a started server +// It is the responsibility of the caller to ensure the returned stopFunc is called +func startServiceAccountTestServer(t *testing.T) (*client.Client, client.Config, func()) { + + deleteAllEtcdKeys() + + // Etcd + helper, err := master.NewEtcdHelper(newEtcdClient(), testapi.Version(), etcdtest.PathPrefix()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Listener + var m *master.Master + apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + m.Handler.ServeHTTP(w, req) + })) + + // Anonymous client config + clientConfig := client.Config{Host: apiServer.URL, Version: testapi.Version()} + // Root client + rootClient := client.NewOrDie(&client.Config{Host: apiServer.URL, Version: testapi.Version(), BearerToken: rootToken}) + + // Set up two authenticators: + // 1. A token authenticator that maps the rootToken to the "root" user + // 2. A ServiceAccountToken authenticator that validates ServiceAccount tokens + rootTokenAuth := authenticator.TokenFunc(func(token string) (user.Info, bool, error) { + if token == rootToken { + return &user.DefaultInfo{rootUserName, "", []string{}}, true, nil + } + return nil, false, nil + }) + serviceAccountKey, err := rsa.GenerateKey(rand.Reader, 2048) + serviceAccountTokenAuth := serviceaccount.JWTTokenAuthenticator([]*rsa.PublicKey{&serviceAccountKey.PublicKey}, true, rootClient) + authenticator := union.New( + bearertoken.New(rootTokenAuth), + bearertoken.New(serviceAccountTokenAuth), + ) + + // Set up a stub authorizer: + // 1. The "root" user is allowed to do anything + // 2. ServiceAccounts named "ro" are allowed read-only operations in their namespace + // 3. ServiceAccounts named "rw" are allowed any operation in their namespace + authorizer := authorizer.AuthorizerFunc(func(attrs authorizer.Attributes) error { + username := attrs.GetUserName() + ns := attrs.GetNamespace() + + // If the user is "root"... + if username == rootUserName { + // allow them to do anything + return nil + } + + // If the user is a service account... + if serviceAccountNamespace, serviceAccountName, err := serviceaccount.SplitUsername(username); err == nil { + // Limit them to their own namespace + if serviceAccountNamespace == ns { + switch serviceAccountName { + case readOnlyServiceAccountName: + if attrs.IsReadOnly() { + return nil + } + case readWriteServiceAccountName: + return nil + } + } + } + + return fmt.Errorf("User %s is denied (ns=%s, readonly=%v, resource=%s)", username, ns, attrs.IsReadOnly(), attrs.GetResource()) + }) + + // Set up admission plugin to auto-assign serviceaccounts to pods + serviceAccountAdmission := serviceaccountadmission.NewServiceAccount(rootClient) + + // Create a master and install handlers into mux. + m = master.New(&master.Config{ + EtcdHelper: helper, + KubeletClient: client.FakeKubeletClient{}, + EnableLogsSupport: false, + EnableUISupport: false, + EnableIndex: true, + APIPrefix: "/api", + Authenticator: authenticator, + Authorizer: authorizer, + AdmissionControl: serviceAccountAdmission, + }) + + // Start the service account and service account token controllers + tokenController := serviceaccount.NewTokensController(rootClient, serviceaccount.DefaultTokenControllerOptions(serviceaccount.JWTTokenGenerator(serviceAccountKey))) + tokenController.Run() + serviceAccountController := serviceaccount.NewServiceAccountsController(rootClient, serviceaccount.DefaultServiceAccountControllerOptions()) + serviceAccountController.Run() + // Start the admission plugin reflectors + serviceAccountAdmission.Run() + + stop := func() { + tokenController.Stop() + serviceAccountController.Stop() + serviceAccountAdmission.Stop() + apiServer.Close() + } + + return rootClient, clientConfig, stop +} + +func getServiceAccount(c *client.Client, ns string, name string, shouldWait bool) (*api.ServiceAccount, error) { + if !shouldWait { + return c.ServiceAccounts(ns).Get(name) + } + + var user *api.ServiceAccount + var err error + err = wait.Poll(time.Second, 10*time.Second, func() (bool, error) { + user, err = c.ServiceAccounts(ns).Get(name) + if errors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil + }) + return user, err +} + +func getReferencedServiceAccountToken(c *client.Client, ns string, name string, shouldWait bool) (string, string, error) { + tokenName := "" + token := "" + + findToken := func() (bool, error) { + user, err := c.ServiceAccounts(ns).Get(name) + if errors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, err + } + + for _, ref := range user.Secrets { + secret, err := c.Secrets(ns).Get(ref.Name) + if errors.IsNotFound(err) { + continue + } + if err != nil { + return false, err + } + if secret.Type != api.SecretTypeServiceAccountToken { + continue + } + name := secret.Annotations[api.ServiceAccountNameKey] + uid := secret.Annotations[api.ServiceAccountUIDKey] + tokenData := secret.Data[api.ServiceAccountTokenKey] + if name == user.Name && uid == string(user.UID) && len(tokenData) > 0 { + tokenName = secret.Name + token = string(tokenData) + return true, nil + } + } + + return false, nil + } + + if shouldWait { + err := wait.Poll(time.Second, 10*time.Second, findToken) + if err != nil { + return "", "", err + } + } else { + ok, err := findToken() + if err != nil { + return "", "", err + } + if !ok { + return "", "", fmt.Errorf("No token found for %s/%s", ns, name) + } + } + return tokenName, token, nil +} + +type testOperation func() error + +func doServiceAccountAPIRequests(t *testing.T, c *client.Client, ns string, authenticated bool, canRead bool, canWrite bool) { + testSecret := &api.Secret{ + ObjectMeta: api.ObjectMeta{Name: "testSecret"}, + Data: map[string][]byte{"test": []byte("data")}, + } + + readOps := []testOperation{ + func() error { _, err := c.Secrets(ns).List(labels.Everything(), fields.Everything()); return err }, + func() error { _, err := c.Pods(ns).List(labels.Everything(), fields.Everything()); return err }, + } + writeOps := []testOperation{ + func() error { _, err := c.Secrets(ns).Create(testSecret); return err }, + func() error { return c.Secrets(ns).Delete(testSecret.Name) }, + } + + for _, op := range readOps { + err := op() + unauthorizedError := errors.IsUnauthorized(err) + forbiddenError := errors.IsForbidden(err) + + switch { + case !authenticated && !unauthorizedError: + t.Fatalf("expected unauthorized error, got %v", err) + case authenticated && unauthorizedError: + t.Fatalf("unexpected unauthorized error: %v", err) + case authenticated && canRead && forbiddenError: + t.Fatalf("unexpected forbidden error: %v", err) + case authenticated && !canRead && !forbiddenError: + t.Fatalf("expected forbidden error, got: %v", err) + } + } + + for _, op := range writeOps { + err := op() + unauthorizedError := errors.IsUnauthorized(err) + forbiddenError := errors.IsForbidden(err) + + switch { + case !authenticated && !unauthorizedError: + t.Fatalf("expected unauthorized error, got %v", err) + case authenticated && unauthorizedError: + t.Fatalf("unexpected unauthorized error: %v", err) + case authenticated && canWrite && forbiddenError: + t.Fatalf("unexpected forbidden error: %v", err) + case authenticated && !canWrite && !forbiddenError: + t.Fatalf("expected forbidden error, got: %v", err) + } + } +}