diff --git a/cluster/aws/config-default.sh b/cluster/aws/config-default.sh index 3734a297997..63a84877c71 100644 --- a/cluster/aws/config-default.sh +++ b/cluster/aws/config-default.sh @@ -72,7 +72,7 @@ DNS_DOMAIN="kubernetes.local" DNS_REPLICAS=1 # Admission Controllers to invoke prior to persisting objects in cluster -ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ResourceQuota +ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ServiceAccount,ResourceQuota # Optional: Enable/disable public IP assignment for minions. # Important Note: disable only if you have setup a NAT instance for internet access and configured appropriate routes! diff --git a/cluster/azure/config-default.sh b/cluster/azure/config-default.sh index 8b8f91f0619..8b8bbb6dfc9 100644 --- a/cluster/azure/config-default.sh +++ b/cluster/azure/config-default.sh @@ -49,4 +49,4 @@ ELASTICSEARCH_LOGGING_REPLICAS=1 ENABLE_CLUSTER_MONITORING="${KUBE_ENABLE_CLUSTER_MONITORING:-true}" # Admission Controllers to invoke prior to persisting objects in cluster -ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ResourceQuota +ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ServiceAccount,ResourceQuota diff --git a/cluster/gce/config-default.sh b/cluster/gce/config-default.sh index 37a6c401a7c..fa5934f9a62 100755 --- a/cluster/gce/config-default.sh +++ b/cluster/gce/config-default.sh @@ -76,4 +76,4 @@ DNS_DOMAIN="kubernetes.local" DNS_REPLICAS=1 # Admission Controllers to invoke prior to persisting objects in cluster -ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ResourceQuota +ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ServiceAccount,ResourceQuota diff --git a/cluster/gce/config-test.sh b/cluster/gce/config-test.sh index f0406ca8ec3..b699fe37037 100755 --- a/cluster/gce/config-test.sh +++ b/cluster/gce/config-test.sh @@ -74,4 +74,4 @@ DNS_SERVER_IP="10.0.0.10" DNS_DOMAIN="kubernetes.local" DNS_REPLICAS=1 -ADMISSION_CONTROL=NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ResourceQuota +ADMISSION_CONTROL=NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ServiceAccount,ResourceQuota diff --git a/cluster/vagrant/config-default.sh b/cluster/vagrant/config-default.sh index 2653cbca19d..9b910d3d6a7 100755 --- a/cluster/vagrant/config-default.sh +++ b/cluster/vagrant/config-default.sh @@ -50,7 +50,7 @@ MASTER_USER=vagrant MASTER_PASSWD=vagrant # Admission Controllers to invoke prior to persisting objects in cluster -ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ResourceQuota +ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ServiceAccount,ResourceQuota # Optional: Install node monitoring. ENABLE_NODE_MONITORING=true diff --git a/cmd/kube-apiserver/app/plugins.go b/cmd/kube-apiserver/app/plugins.go index 5a9bf030214..fe2dca4cea7 100644 --- a/cmd/kube-apiserver/app/plugins.go +++ b/cmd/kube-apiserver/app/plugins.go @@ -37,4 +37,5 @@ import ( _ "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/admission/namespace/lifecycle" _ "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/admission/resourcequota" _ "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/admission/securitycontext/scdeny" + _ "github.com/GoogleCloudPlatform/kubernetes/plugin/pkg/admission/serviceaccount" ) diff --git a/contrib/ansible/roles/master/templates/apiserver.j2 b/contrib/ansible/roles/master/templates/apiserver.j2 index de2d1df749b..eaabd3bf3e4 100644 --- a/contrib/ansible/roles/master/templates/apiserver.j2 +++ b/contrib/ansible/roles/master/templates/apiserver.j2 @@ -20,7 +20,7 @@ KUBE_SERVICE_ADDRESSES="--portal_net={{ kube_service_addresses }}" KUBE_ETCD_SERVERS="--etcd_servers=http://{{ groups['etcd'][0] }}:2379" # default admission control policies -KUBE_ADMISSION_CONTROL="--admission_control=NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ResourceQuota" +KUBE_ADMISSION_CONTROL="--admission_control=NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ServiceAccount,ResourceQuota" # Add your own! KUBE_API_ARGS="" diff --git a/hack/local-up-cluster.sh b/hack/local-up-cluster.sh index e4268bd9847..34f6e86a587 100755 --- a/hack/local-up-cluster.sh +++ b/hack/local-up-cluster.sh @@ -140,7 +140,7 @@ if [[ ! -f "${SERVICE_ACCOUNT_KEY}" ]]; then fi # Admission Controllers to invoke prior to persisting objects in cluster -ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ResourceQuota +ADMISSION_CONTROL=NamespaceLifecycle,NamespaceAutoProvision,LimitRanger,SecurityContextDeny,ServiceAccount,ResourceQuota APISERVER_LOG=/tmp/kube-apiserver.log sudo -E "${GO_OUT}/kube-apiserver" \ diff --git a/plugin/pkg/admission/serviceaccount/admission.go b/plugin/pkg/admission/serviceaccount/admission.go new file mode 100644 index 00000000000..8d94ba5a6c1 --- /dev/null +++ b/plugin/pkg/admission/serviceaccount/admission.go @@ -0,0 +1,373 @@ +/* +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 serviceaccount + +import ( + "fmt" + "io" + "math/rand" + "time" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/admission" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api/errors" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client/cache" + "github.com/GoogleCloudPlatform/kubernetes/pkg/fields" + "github.com/GoogleCloudPlatform/kubernetes/pkg/kubelet" + "github.com/GoogleCloudPlatform/kubernetes/pkg/labels" + "github.com/GoogleCloudPlatform/kubernetes/pkg/runtime" + "github.com/GoogleCloudPlatform/kubernetes/pkg/util" + "github.com/GoogleCloudPlatform/kubernetes/pkg/watch" +) + +// DefaultServiceAccountName is the name of the default service account to set on pods which do not specify a service account +const DefaultServiceAccountName = "default" + +// DefaultAPITokenMountPath is the path that ServiceAccountToken secrets are automounted to. +// The token file would then be accessible at /var/run/secrets/kubernetes.io/serviceaccount +const DefaultAPITokenMountPath = "/var/run/secrets/kubernetes.io/serviceaccount" + +func init() { + admission.RegisterPlugin("ServiceAccount", func(client client.Interface, config io.Reader) (admission.Interface, error) { + serviceAccountAdmission := NewServiceAccount(client) + serviceAccountAdmission.Run() + return serviceAccountAdmission, nil + }) +} + +var _ = admission.Interface(&serviceAccount{}) + +type serviceAccount struct { + // LimitSecretReferences rejects pods that reference secrets their service accounts do not reference + LimitSecretReferences bool + // MountServiceAccountToken creates Volume and VolumeMounts for the first referenced ServiceAccountToken for the pod's service account + MountServiceAccountToken bool + + client client.Interface + + serviceAccounts cache.Indexer + secrets cache.Indexer + + stopChan chan struct{} + serviceAccountsReflector *cache.Reflector + secretsReflector *cache.Reflector +} + +// NewServiceAccount returns an admission.Interface implementation which limits admission of Pod CREATE requests based on the pod's ServiceAccount: +// 1. If the pod does not specify a ServiceAccount, it sets the pod's ServiceAccount to "default" +// 2. It ensures the ServiceAccount referenced by the pod exists +// 3. If LimitSecretReferences is true, it rejects the pod if the pod references Secret objects which the pod's ServiceAccount does not reference +// 4. If MountServiceAccountToken is true, it adds a VolumeMount with the pod's ServiceAccount's api token secret to containers +func NewServiceAccount(cl client.Interface) *serviceAccount { + serviceAccountsIndexer, serviceAccountsReflector := cache.NewNamespaceKeyedIndexerAndReflector( + &cache.ListWatch{ + ListFunc: func() (runtime.Object, error) { + return cl.ServiceAccounts(api.NamespaceAll).List(labels.Everything(), fields.Everything()) + }, + WatchFunc: func(resourceVersion string) (watch.Interface, error) { + return cl.ServiceAccounts(api.NamespaceAll).Watch(labels.Everything(), fields.Everything(), resourceVersion) + }, + }, + &api.ServiceAccount{}, + 0, + ) + + tokenSelector := fields.SelectorFromSet(map[string]string{client.SecretType: string(api.SecretTypeServiceAccountToken)}) + secretsIndexer, secretsReflector := cache.NewNamespaceKeyedIndexerAndReflector( + &cache.ListWatch{ + ListFunc: func() (runtime.Object, error) { + return cl.Secrets(api.NamespaceAll).List(labels.Everything(), tokenSelector) + }, + WatchFunc: func(resourceVersion string) (watch.Interface, error) { + return cl.Secrets(api.NamespaceAll).Watch(labels.Everything(), tokenSelector, resourceVersion) + }, + }, + &api.Secret{}, + 0, + ) + + return &serviceAccount{ + // TODO: enable this once we've swept secret usage to account for adding secret references to service accounts + LimitSecretReferences: false, + // Auto mount service account API token secrets + MountServiceAccountToken: true, + + client: cl, + serviceAccounts: serviceAccountsIndexer, + serviceAccountsReflector: serviceAccountsReflector, + secrets: secretsIndexer, + secretsReflector: secretsReflector, + } +} + +func (s *serviceAccount) Run() { + if s.stopChan == nil { + s.stopChan = make(chan struct{}) + s.serviceAccountsReflector.RunUntil(s.stopChan) + s.secretsReflector.RunUntil(s.stopChan) + } +} +func (s *serviceAccount) Stop() { + if s.stopChan != nil { + close(s.stopChan) + s.stopChan = nil + } +} + +func (s *serviceAccount) Admit(a admission.Attributes) (err error) { + // We only care about Pod CREATE operations + if a.GetOperation() != "CREATE" { + return nil + } + if a.GetResource() != string(api.ResourcePods) { + return nil + } + obj := a.GetObject() + if obj == nil { + return nil + } + pod, ok := obj.(*api.Pod) + if !ok { + return nil + } + + // Don't modify the spec of mirror pods. + // That makes the kubelet very angry and confused, and it immediately deletes the pod (because the spec doesn't match) + // That said, don't allow mirror pods to reference ServiceAccounts or SecretVolumeSources either + if _, isMirrorPod := pod.Annotations[kubelet.ConfigMirrorAnnotationKey]; isMirrorPod { + if len(pod.Spec.ServiceAccount) != 0 { + return admission.NewForbidden(a, fmt.Errorf("A mirror pod may not reference service accounts")) + } + for _, volume := range pod.Spec.Volumes { + if volume.VolumeSource.Secret != nil { + return admission.NewForbidden(a, fmt.Errorf("A mirror pod may not reference secrets")) + } + } + return nil + } + + // Set the default service account if needed + if len(pod.Spec.ServiceAccount) == 0 { + pod.Spec.ServiceAccount = DefaultServiceAccountName + } + + // Ensure the referenced service account exists + serviceAccount, err := s.getServiceAccount(a.GetNamespace(), pod.Spec.ServiceAccount) + if err != nil { + return admission.NewForbidden(a, fmt.Errorf("Error looking up service account %s/%s: %v", a.GetNamespace(), pod.Spec.ServiceAccount, err)) + } + if serviceAccount == nil { + return admission.NewForbidden(a, fmt.Errorf("Missing service account %s/%s: %v", a.GetNamespace(), pod.Spec.ServiceAccount, err)) + } + + if s.LimitSecretReferences { + if err := s.limitSecretReferences(serviceAccount, pod); err != nil { + return admission.NewForbidden(a, err) + } + } + + if s.MountServiceAccountToken { + if err := s.mountServiceAccountToken(serviceAccount, pod); err != nil { + return admission.NewForbidden(a, err) + } + } + + return nil +} + +// getServiceAccount returns the ServiceAccount for the given namespace and name if it exists +func (s *serviceAccount) getServiceAccount(namespace string, name string) (*api.ServiceAccount, error) { + key := &api.ServiceAccount{ObjectMeta: api.ObjectMeta{Namespace: namespace}} + index, err := s.serviceAccounts.Index("namespace", key) + if err != nil { + return nil, err + } + + for _, obj := range index { + serviceAccount := obj.(*api.ServiceAccount) + if serviceAccount.Name == name { + return serviceAccount, nil + } + } + + // Could not find in cache, attempt to look up directly + numAttempts := 1 + if name == DefaultServiceAccountName { + // If this is the default serviceaccount, attempt more times, since it should be auto-created by the controller + numAttempts = 10 + } + retryInterval := time.Duration(rand.Int63n(100)+int64(100)) * time.Millisecond + for i := 0; i < numAttempts; i++ { + if i != 0 { + time.Sleep(retryInterval) + } + serviceAccount, err := s.client.ServiceAccounts(namespace).Get(name) + if err == nil { + return serviceAccount, nil + } + if !errors.IsNotFound(err) { + return nil, err + } + } + + return nil, nil +} + +// getReferencedServiceAccountToken returns the name of the first referenced secret which is a ServiceAccountToken for the service account +func (s *serviceAccount) getReferencedServiceAccountToken(serviceAccount *api.ServiceAccount) (string, error) { + if len(serviceAccount.Secrets) == 0 { + return "", nil + } + + tokens, err := s.getServiceAccountTokens(serviceAccount) + if err != nil { + return "", err + } + + references := util.NewStringSet() + for _, secret := range serviceAccount.Secrets { + references.Insert(secret.Name) + } + for _, token := range tokens { + if references.Has(token.Name) { + return token.Name, nil + } + } + + return "", nil +} + +// getServiceAccountTokens returns all ServiceAccountToken secrets for the given ServiceAccount +func (s *serviceAccount) getServiceAccountTokens(serviceAccount *api.ServiceAccount) ([]*api.Secret, error) { + key := &api.Secret{ObjectMeta: api.ObjectMeta{Namespace: serviceAccount.Namespace}} + index, err := s.secrets.Index("namespace", key) + if err != nil { + return nil, err + } + + tokens := []*api.Secret{} + for _, obj := range index { + token := obj.(*api.Secret) + if token.Type != api.SecretTypeServiceAccountToken { + continue + } + name := token.Annotations[api.ServiceAccountNameKey] + uid := token.Annotations[api.ServiceAccountUIDKey] + if name != serviceAccount.Name { + // Name must match + continue + } + if len(uid) > 0 && uid != string(serviceAccount.UID) { + // If UID is set, it must match + continue + } + tokens = append(tokens, token) + } + return tokens, nil +} + +func (s *serviceAccount) limitSecretReferences(serviceAccount *api.ServiceAccount, pod *api.Pod) error { + // Ensure all secrets the pod references are allowed by the service account + referencedSecrets := util.NewStringSet() + for _, s := range serviceAccount.Secrets { + referencedSecrets.Insert(s.Name) + } + for _, volume := range pod.Spec.Volumes { + source := volume.VolumeSource + if source.Secret == nil { + continue + } + secretName := source.Secret.SecretName + if !referencedSecrets.Has(secretName) { + return fmt.Errorf("Volume with secret.secretName=\"%s\" is not allowed because service account %s does not reference that secret", secretName, serviceAccount.Name) + } + } + return nil +} + +func (s *serviceAccount) mountServiceAccountToken(serviceAccount *api.ServiceAccount, pod *api.Pod) error { + // Find the name of a referenced ServiceAccountToken secret we can mount + serviceAccountToken, err := s.getReferencedServiceAccountToken(serviceAccount) + if err != nil { + fmt.Errorf("Error looking up service account token for %s/%s: %v", serviceAccount.Namespace, serviceAccount.Name, err) + } + if len(serviceAccountToken) == 0 { + // We don't have an API token to mount, so return + return nil + } + + // Find the volume and volume name for the ServiceAccountTokenSecret if it already exists + tokenVolumeName := "" + hasTokenVolume := false + allVolumeNames := util.NewStringSet() + for _, volume := range pod.Spec.Volumes { + allVolumeNames.Insert(volume.Name) + if volume.Secret != nil && volume.Secret.SecretName == serviceAccountToken { + tokenVolumeName = volume.Name + hasTokenVolume = true + break + } + } + + // Determine a volume name for the ServiceAccountTokenSecret in case we need it + if len(tokenVolumeName) == 0 { + // Try naming the volume the same as the serviceAccountToken, and uniquify if needed + tokenVolumeName = serviceAccountToken + if allVolumeNames.Has(tokenVolumeName) { + tokenVolumeName = api.SimpleNameGenerator.GenerateName(fmt.Sprintf("%s-", serviceAccountToken)) + } + } + + // Create the prototypical VolumeMount + volumeMount := api.VolumeMount{ + Name: tokenVolumeName, + ReadOnly: true, + MountPath: DefaultAPITokenMountPath, + } + + // Ensure every container mounts the APISecret volume + needsTokenVolume := false + for i, container := range pod.Spec.Containers { + existingContainerMount := false + for _, volumeMount := range container.VolumeMounts { + // Existing mounts at the default mount path prevent mounting of the API token + if volumeMount.MountPath == DefaultAPITokenMountPath { + existingContainerMount = true + break + } + } + if !existingContainerMount { + pod.Spec.Containers[i].VolumeMounts = append(pod.Spec.Containers[i].VolumeMounts, volumeMount) + needsTokenVolume = true + } + } + + // Add the volume if a container needs it + if !hasTokenVolume && needsTokenVolume { + volume := api.Volume{ + Name: tokenVolumeName, + VolumeSource: api.VolumeSource{ + Secret: &api.SecretVolumeSource{ + SecretName: serviceAccountToken, + }, + }, + } + pod.Spec.Volumes = append(pod.Spec.Volumes, volume) + } + return nil +} diff --git a/plugin/pkg/admission/serviceaccount/admission_test.go b/plugin/pkg/admission/serviceaccount/admission_test.go new file mode 100644 index 00000000000..61f178d160f --- /dev/null +++ b/plugin/pkg/admission/serviceaccount/admission_test.go @@ -0,0 +1,399 @@ +/* +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 serviceaccount + +import ( + "reflect" + "testing" + + "github.com/GoogleCloudPlatform/kubernetes/pkg/admission" + "github.com/GoogleCloudPlatform/kubernetes/pkg/api" + "github.com/GoogleCloudPlatform/kubernetes/pkg/client/testclient" + "github.com/GoogleCloudPlatform/kubernetes/pkg/kubelet" + "github.com/GoogleCloudPlatform/kubernetes/pkg/types" +) + +func TestIgnoresNonCreate(t *testing.T) { + pod := &api.Pod{} + for _, op := range []string{"UPDATE", "DELETE", "CUSTOM"} { + attrs := admission.NewAttributesRecord(pod, "Pod", "myns", string(api.ResourcePods), op) + err := NewServiceAccount(nil).Admit(attrs) + if err != nil { + t.Errorf("Expected %s operation allowed, got err: %v", op, err) + } + } +} + +func TestIgnoresNonPodResource(t *testing.T) { + pod := &api.Pod{} + attrs := admission.NewAttributesRecord(pod, "Pod", "myns", "CustomResource", "CREATE") + err := NewServiceAccount(nil).Admit(attrs) + if err != nil { + t.Errorf("Expected non-pod resource allowed, got err: %v", err) + } +} + +func TestIgnoresNilObject(t *testing.T) { + attrs := admission.NewAttributesRecord(nil, "Pod", "myns", string(api.ResourcePods), "CREATE") + err := NewServiceAccount(nil).Admit(attrs) + if err != nil { + t.Errorf("Expected nil object allowed allowed, got err: %v", err) + } +} + +func TestIgnoresNonPodObject(t *testing.T) { + obj := &api.Namespace{} + attrs := admission.NewAttributesRecord(obj, "Pod", "myns", string(api.ResourcePods), "CREATE") + err := NewServiceAccount(nil).Admit(attrs) + if err != nil { + t.Errorf("Expected non pod object allowed, got err: %v", err) + } +} + +func TestIgnoresMirrorPod(t *testing.T) { + pod := &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{ + kubelet.ConfigMirrorAnnotationKey: "true", + }, + }, + Spec: api.PodSpec{ + Volumes: []api.Volume{ + {VolumeSource: api.VolumeSource{}}, + }, + }, + } + attrs := admission.NewAttributesRecord(pod, "Pod", "myns", string(api.ResourcePods), "CREATE") + err := NewServiceAccount(nil).Admit(attrs) + if err != nil { + t.Errorf("Expected mirror pod without service account or secrets allowed, got err: %v", err) + } +} + +func TestRejectsMirrorPodWithServiceAccount(t *testing.T) { + pod := &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{ + kubelet.ConfigMirrorAnnotationKey: "true", + }, + }, + Spec: api.PodSpec{ + ServiceAccount: "default", + }, + } + attrs := admission.NewAttributesRecord(pod, "Pod", "myns", string(api.ResourcePods), "CREATE") + err := NewServiceAccount(nil).Admit(attrs) + if err == nil { + t.Errorf("Expected a mirror pod to be prevented from referencing a service account") + } +} + +func TestRejectsMirrorPodWithSecretVolumes(t *testing.T) { + pod := &api.Pod{ + ObjectMeta: api.ObjectMeta{ + Annotations: map[string]string{ + kubelet.ConfigMirrorAnnotationKey: "true", + }, + }, + Spec: api.PodSpec{ + Volumes: []api.Volume{ + {VolumeSource: api.VolumeSource{Secret: &api.SecretVolumeSource{}}}, + }, + }, + } + attrs := admission.NewAttributesRecord(pod, "Pod", "myns", string(api.ResourcePods), "CREATE") + err := NewServiceAccount(nil).Admit(attrs) + if err == nil { + t.Errorf("Expected a mirror pod to be prevented from referencing a secret volume") + } +} + +func TestAssignsDefaultServiceAccountAndToleratesMissingAPIToken(t *testing.T) { + ns := "myns" + + admit := NewServiceAccount(nil) + admit.MountServiceAccountToken = true + + // Add the default service account for the ns into the cache + admit.serviceAccounts.Add(&api.ServiceAccount{ + ObjectMeta: api.ObjectMeta{ + Name: DefaultServiceAccountName, + Namespace: ns, + }, + }) + + pod := &api.Pod{} + attrs := admission.NewAttributesRecord(pod, "Pod", ns, string(api.ResourcePods), "CREATE") + err := admit.Admit(attrs) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if pod.Spec.ServiceAccount != DefaultServiceAccountName { + t.Errorf("Expected service account %s assigned, got %s", DefaultServiceAccountName, pod.Spec.ServiceAccount) + } +} + +func TestFetchesUncachedServiceAccount(t *testing.T) { + ns := "myns" + + // Build a test client that the admission plugin can use to look up the service account missing from its cache + client := testclient.NewSimpleFake(&api.ServiceAccount{ + ObjectMeta: api.ObjectMeta{ + Name: DefaultServiceAccountName, + Namespace: ns, + }, + }) + + admit := NewServiceAccount(client) + + pod := &api.Pod{} + attrs := admission.NewAttributesRecord(pod, "Pod", ns, string(api.ResourcePods), "CREATE") + err := admit.Admit(attrs) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if pod.Spec.ServiceAccount != DefaultServiceAccountName { + t.Errorf("Expected service account %s assigned, got %s", DefaultServiceAccountName, pod.Spec.ServiceAccount) + } +} + +func TestDeniesInvalidServiceAccount(t *testing.T) { + ns := "myns" + + // Build a test client that the admission plugin can use to look up the service account missing from its cache + client := testclient.NewSimpleFake() + + admit := NewServiceAccount(client) + + pod := &api.Pod{} + attrs := admission.NewAttributesRecord(pod, "Pod", ns, string(api.ResourcePods), "CREATE") + err := admit.Admit(attrs) + if err == nil { + t.Errorf("Expected error for missing service account, got none") + } +} + +func TestAutomountsAPIToken(t *testing.T) { + ns := "myns" + tokenName := "token-name" + serviceAccountName := DefaultServiceAccountName + serviceAccountUID := "12345" + + expectedVolume := api.Volume{ + Name: tokenName, + VolumeSource: api.VolumeSource{ + Secret: &api.SecretVolumeSource{SecretName: tokenName}, + }, + } + expectedVolumeMount := api.VolumeMount{ + Name: tokenName, + ReadOnly: true, + MountPath: DefaultAPITokenMountPath, + } + + admit := NewServiceAccount(nil) + admit.MountServiceAccountToken = true + + // Add the default service account for the ns with a token into the cache + admit.serviceAccounts.Add(&api.ServiceAccount{ + ObjectMeta: api.ObjectMeta{ + Name: serviceAccountName, + Namespace: ns, + UID: types.UID(serviceAccountUID), + }, + Secrets: []api.ObjectReference{ + {Name: tokenName}, + }, + }) + // Add a token for the service account into the cache + admit.secrets.Add(&api.Secret{ + ObjectMeta: api.ObjectMeta{ + Name: tokenName, + Namespace: ns, + Annotations: map[string]string{ + api.ServiceAccountNameKey: serviceAccountName, + api.ServiceAccountUIDKey: serviceAccountUID, + }, + }, + Type: api.SecretTypeServiceAccountToken, + Data: map[string][]byte{ + api.ServiceAccountTokenKey: []byte("token-data"), + }, + }) + + pod := &api.Pod{ + Spec: api.PodSpec{ + Containers: []api.Container{ + {}, + }, + }, + } + attrs := admission.NewAttributesRecord(pod, "Pod", ns, string(api.ResourcePods), "CREATE") + err := admit.Admit(attrs) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if pod.Spec.ServiceAccount != DefaultServiceAccountName { + t.Errorf("Expected service account %s assigned, got %s", DefaultServiceAccountName, pod.Spec.ServiceAccount) + } + if len(pod.Spec.Volumes) != 1 { + t.Fatalf("Expected 1 volume, got %d", len(pod.Spec.Volumes)) + } + if !reflect.DeepEqual(expectedVolume, pod.Spec.Volumes[0]) { + t.Fatalf("Expected\n\t%#v\ngot\n\t%#v", expectedVolume, pod.Spec.Volumes[0]) + } + if len(pod.Spec.Containers[0].VolumeMounts) != 1 { + t.Fatalf("Expected 1 volume mount, got %d", len(pod.Spec.Containers[0].VolumeMounts)) + } + if !reflect.DeepEqual(expectedVolumeMount, pod.Spec.Containers[0].VolumeMounts[0]) { + t.Fatalf("Expected\n\t%#v\ngot\n\t%#v", expectedVolumeMount, pod.Spec.Containers[0].VolumeMounts[0]) + } +} + +func TestRespectsExistingMount(t *testing.T) { + ns := "myns" + tokenName := "token-name" + serviceAccountName := DefaultServiceAccountName + serviceAccountUID := "12345" + + expectedVolumeMount := api.VolumeMount{ + Name: "my-custom-mount", + ReadOnly: false, + MountPath: DefaultAPITokenMountPath, + } + + admit := NewServiceAccount(nil) + admit.MountServiceAccountToken = true + + // Add the default service account for the ns with a token into the cache + admit.serviceAccounts.Add(&api.ServiceAccount{ + ObjectMeta: api.ObjectMeta{ + Name: serviceAccountName, + Namespace: ns, + UID: types.UID(serviceAccountUID), + }, + Secrets: []api.ObjectReference{ + {Name: tokenName}, + }, + }) + // Add a token for the service account into the cache + admit.secrets.Add(&api.Secret{ + ObjectMeta: api.ObjectMeta{ + Name: tokenName, + Namespace: ns, + Annotations: map[string]string{ + api.ServiceAccountNameKey: serviceAccountName, + api.ServiceAccountUIDKey: serviceAccountUID, + }, + }, + Type: api.SecretTypeServiceAccountToken, + Data: map[string][]byte{ + api.ServiceAccountTokenKey: []byte("token-data"), + }, + }) + + // Define a pod with a container that already mounts a volume at the API token path + // Admission should respect that + // Additionally, no volume should be created if no container is going to use it + pod := &api.Pod{ + Spec: api.PodSpec{ + Containers: []api.Container{ + { + VolumeMounts: []api.VolumeMount{ + expectedVolumeMount, + }, + }, + }, + }, + } + attrs := admission.NewAttributesRecord(pod, "Pod", ns, string(api.ResourcePods), "CREATE") + err := admit.Admit(attrs) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if pod.Spec.ServiceAccount != DefaultServiceAccountName { + t.Errorf("Expected service account %s assigned, got %s", DefaultServiceAccountName, pod.Spec.ServiceAccount) + } + if len(pod.Spec.Volumes) != 0 { + t.Fatalf("Expected 0 volumes (shouldn't create a volume for a secret we don't need), got %d", len(pod.Spec.Volumes)) + } + if len(pod.Spec.Containers[0].VolumeMounts) != 1 { + t.Fatalf("Expected 1 volume mount, got %d", len(pod.Spec.Containers[0].VolumeMounts)) + } + if !reflect.DeepEqual(expectedVolumeMount, pod.Spec.Containers[0].VolumeMounts[0]) { + t.Fatalf("Expected\n\t%#v\ngot\n\t%#v", expectedVolumeMount, pod.Spec.Containers[0].VolumeMounts[0]) + } +} + +func TestAllowsReferencedSecretVolumes(t *testing.T) { + ns := "myns" + + admit := NewServiceAccount(nil) + admit.LimitSecretReferences = true + + // Add the default service account for the ns with a secret reference into the cache + admit.serviceAccounts.Add(&api.ServiceAccount{ + ObjectMeta: api.ObjectMeta{ + Name: DefaultServiceAccountName, + Namespace: ns, + }, + Secrets: []api.ObjectReference{ + {Name: "foo"}, + }, + }) + + pod := &api.Pod{ + Spec: api.PodSpec{ + Volumes: []api.Volume{ + {VolumeSource: api.VolumeSource{Secret: &api.SecretVolumeSource{SecretName: "foo"}}}, + }, + }, + } + attrs := admission.NewAttributesRecord(pod, "Pod", ns, string(api.ResourcePods), "CREATE") + err := admit.Admit(attrs) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } +} + +func TestRejectsUnreferencedSecretVolumes(t *testing.T) { + ns := "myns" + + admit := NewServiceAccount(nil) + admit.LimitSecretReferences = true + + // Add the default service account for the ns into the cache + admit.serviceAccounts.Add(&api.ServiceAccount{ + ObjectMeta: api.ObjectMeta{ + Name: DefaultServiceAccountName, + Namespace: ns, + }, + }) + + pod := &api.Pod{ + Spec: api.PodSpec{ + Volumes: []api.Volume{ + {VolumeSource: api.VolumeSource{Secret: &api.SecretVolumeSource{SecretName: "foo"}}}, + }, + }, + } + attrs := admission.NewAttributesRecord(pod, "Pod", ns, string(api.ResourcePods), "CREATE") + err := admit.Admit(attrs) + if err == nil { + t.Errorf("Expected rejection for using a secret the service account does not reference") + } +} diff --git a/plugin/pkg/admission/serviceaccount/doc.go b/plugin/pkg/admission/serviceaccount/doc.go new file mode 100644 index 00000000000..50a8b061498 --- /dev/null +++ b/plugin/pkg/admission/serviceaccount/doc.go @@ -0,0 +1,19 @@ +/* +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. +*/ + +// serviceaccount enforces all pods having an associated serviceaccount, +// and all containers mounting the API token for that serviceaccount at a known location +package serviceaccount