Skip to content

Commit

Permalink
Add support for container credentials method (#189)
Browse files Browse the repository at this point in the history
Prior this change, the webhook expects the IAM Role ARN to be specified during pod admission.  The webhook mutates the pod spec by injecting `AWS_ROLE_ARN` and `AWS_WEB_IDENTITY_TOKEN_FILE` env vars, which will instruct the AWS SDK to get credentials via the [AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html#cli-configure-role-oidc) method.

This PR introduces a new method that utilizes [AWS SDK Containers Credential Provider](https://docs.aws.amazon.com/sdkref/latest/guide/feature-container-credentials.html).  This method mutates the pod spec by injecting `AWS_CONTAINER_CREDENTIALS_FULL_URI` and `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` env vars, which will instruct the AWS SDK to get credentials through the specified HTTP endpoint.  

To enable this new method, user must provide a config file by setting the argument `--watch-config-file=<path>`.  Pod will use this method if its namespace & serviceAccount are listed in the config file.  The config file should be a JSON file with the following format:

```
{
  "identities": [
    {
      "namespace": "foo",
      "serviceAccount": "bar"
    }
  ]
}
```

*Testing:*
- New unit tests
- Manual testing with the below

First, the webhook is configured to use the following config file:

```
{
  "identities": [
    {
      "namespace": "foo",
      "serviceAccount": "bar"
    }
  ]
}
```

Second, apply the following yaml:

```
apiVersion: v1
kind: Namespace
metadata:
 name: "foo"
---
apiVersion: v1
kind: ServiceAccount
metadata:
 name: "bar"
 namespace: foo
automountServiceAccountToken: false
---
apiVersion: v1
kind: Pod
metadata:
 name: test-pod-2
 namespace: foo
spec:
 serviceAccountName: bar
 containers:
 - name: test-container
   image: public.ecr.aws/eks-distro-build-tooling/builder-base:latest
   command: [ "/bin/bash", "-c", "--" ]
   args: [ "while true; do sleep 5; done" ]
---
```

Lastly, check mutated pod spec:

```
spec:
  containers:
  - args:
    - while true; do sleep 5; done
    command:
    - /bin/bash
    - -c
    - --
    env:
    - name: AWS_DEFAULT_REGION
      value: us-west-2
    - name: AWS_REGION
      value: us-west-2
    - name: AWS_CONTAINER_CREDENTIALS_FULL_URI
      value: http://169.254.170.23/v1/credentials
    - name: AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE
      value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
    image: public.ecr.aws/eks-distro-build-tooling/builder-base:latest
    imagePullPolicy: Always
    name: test-container
    ...
    volumeMounts:
    - mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
      name: aws-iam-token
      readOnly: true
  ...
  volumes:
  - name: aws-iam-token
    projected:
      defaultMode: 420
      sources:
      - serviceAccountToken:
          audience: pods.eks.amazonaws.com
          expirationSeconds: 86400
          path: token
```
  • Loading branch information
philljie authored Aug 17, 2023
1 parent 9f7a868 commit 254737f
Show file tree
Hide file tree
Showing 18 changed files with 1,148 additions and 196 deletions.
19 changes: 17 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"crypto/x509/pkix"
goflag "flag"
"fmt"
"github.com/aws/amazon-eks-pod-identity-webhook/pkg/containercredentials"
"net/http"
"os"
"strings"
Expand Down Expand Up @@ -74,6 +75,9 @@ func main() {
regionalSTS := flag.Bool("sts-regional-endpoint", false, "Whether to inject the AWS_STS_REGIONAL_ENDPOINTS=regional env var in mutated pods. Defaults to `false`.")
watchConfigMap := flag.Bool("watch-config-map", false, "Enables watching serviceaccounts that are configured through the pod-identity-webhook configmap instead of using annotations")
composeRoleArn := flag.Bool("compose-role-arn", false, "If true, then the role name and path can be used instead of the fully qualified ARN in the `role-arn` annotation. In this case, webhook will look up the partition and account ID using instance metadata. Defaults to `false`.")
watchContainerCredentialsConfig := flag.String("watch-container-credentials-config", "", "Absolute path to the container credential config file to watch for")
containerCredentialsAudience := flag.String("container-credentials-audience", "pods.eks.amazonaws.com", "The audience for tokens used by the AWS Container Credentials method")
containerCredentialsFullUri := flag.String("container-credentials-full-uri", "http://169.254.170.23/v1/credentials", "AWS_CONTAINER_CREDENTIALS_FULL_URI will be set to this value in mutated containers")

version := flag.Bool("version", false, "Display the version and exit")

Expand All @@ -94,6 +98,9 @@ func main() {
os.Exit(0)
}

// setup signal handler
signalHandlerCtx := signals.SetupSignalHandler()

config, err := clientcmd.BuildConfigFromFlags(*apiURL, *kubeconfig)
if err != nil {
klog.Fatalf("Error creating config: %v", err.Error())
Expand Down Expand Up @@ -178,10 +185,20 @@ func main() {
saCache.Start(stop)
defer close(stop)

containerCredentialsConfig := containercredentials.NewFileConfig(*containerCredentialsAudience, *containerCredentialsFullUri)
if watchContainerCredentialsConfig != nil && *watchContainerCredentialsConfig != "" {
klog.Infof("Watching container credentials config file %s", *watchContainerCredentialsConfig)
err = containerCredentialsConfig.StartWatcher(signalHandlerCtx, *watchContainerCredentialsConfig)
if err != nil {
klog.Fatalf("Error starting watcher on file %v: %v", *watchContainerCredentialsConfig, err.Error())
}
}

mod := handler.NewModifier(
handler.WithAnnotationDomain(*annotationPrefix),
handler.WithMountPath(*mountPath),
handler.WithServiceAccountCache(saCache),
handler.WithContainerCredentialsConfig(containerCredentialsConfig),
handler.WithRegion(*region),
)

Expand Down Expand Up @@ -212,8 +229,6 @@ func main() {
// Expose other debug paths
}

// setup signal handler to be passed to certwatcher and http server
signalHandlerCtx := signals.SetupSignalHandler()
tlsConfig := &tls.Config{}

if *inCluster {
Expand Down
75 changes: 45 additions & 30 deletions pkg/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type CacheResponse struct {
type ServiceAccountCache interface {
Start(stop chan struct{})
Get(name, namespace string) (role, aud string, useRegionalSTS bool, tokenExpiration int64)
GetCommonConfigurations(name, namespace string) (useRegionalSTS bool, tokenExpiration int64)
// ToJSON returns cache contents as JSON string
ToJSON() string
}
Expand Down Expand Up @@ -104,6 +105,18 @@ func (c *serviceAccountCache) Get(name, namespace string) (role, aud string, use
return "", "", false, pkg.DefaultTokenExpiration
}

// GetCommonConfigurations returns the common configurations that also applies to the new mutation method(i.e Container Credentials).
// The config file for the container credentials does not contain "TokenExpiration" or "UseRegionalSTS". For backward compatibility,
// Use these fields if they are set in the sa annotations or config map.
func (c *serviceAccountCache) GetCommonConfigurations(name, namespace string) (useRegionalSTS bool, tokenExpiration int64) {
if resp := c.getSA(name, namespace); resp != nil {
return resp.UseRegionalSTS, resp.TokenExpiration
} else if resp := c.getCM(name, namespace); resp != nil {
return resp.UseRegionalSTS, resp.TokenExpiration
}
return false, pkg.DefaultTokenExpiration
}

func (c *serviceAccountCache) getSA(name, namespace string) *CacheResponse {
c.mu.RLock()
defer c.mu.RUnlock()
Expand Down Expand Up @@ -151,46 +164,48 @@ func (c *serviceAccountCache) ToJSON() string {
}

func (c *serviceAccountCache) addSA(sa *v1.ServiceAccount) {
resp := &CacheResponse{}

arn, ok := sa.Annotations[c.annotationPrefix+"/"+pkg.RoleARNAnnotation]
if ok {
if !strings.Contains(arn, "arn:") && c.composeRoleArn.Enabled {
arn = fmt.Sprintf("arn:%s:iam::%s:role/%s", c.composeRoleArn.Partition, c.composeRoleArn.AccountID, arn)
}

if !strings.Contains(arn, "arn:") && c.composeRoleArn.Enabled {
arn = fmt.Sprintf("arn:%s:iam::%s:role/%s", c.composeRoleArn.Partition, c.composeRoleArn.AccountID, arn)
matched, err := regexp.Match(`^arn:aws[a-z0-9-]*:iam::\d{12}:role\/[\w-\/.@+=,]+$`, []byte(arn))
if err != nil {
klog.Errorf("Regex error: %v", err)
} else if !matched {
klog.Warningf("arn is invalid: %s", arn)
}
resp.RoleARN = arn
}

matched, err := regexp.Match(`^arn:aws[a-z0-9-]*:iam::\d{12}:role\/[\w-\/.@+=,]+$`, []byte(arn))
if err != nil {
klog.Errorf("Regex error: %v", err)
} else if !matched {
klog.Warningf("arn is invalid: %s", arn)
resp.Audience = c.defaultAudience
if audience, ok := sa.Annotations[c.annotationPrefix+"/"+pkg.AudienceAnnotation]; ok {
resp.Audience = audience
}
resp := &CacheResponse{}
if ok {
resp.RoleARN = arn
resp.Audience = c.defaultAudience
if audience, ok := sa.Annotations[c.annotationPrefix+"/"+pkg.AudienceAnnotation]; ok {
resp.Audience = audience
}

resp.UseRegionalSTS = c.defaultRegionalSTS
if useRegionalStr, ok := sa.Annotations[c.annotationPrefix+"/"+pkg.UseRegionalSTSAnnotation]; ok {
useRegional, err := strconv.ParseBool(useRegionalStr)
if err != nil {
klog.V(4).Infof("Ignoring service account %s/%s invalid value for disable-regional-sts annotation", sa.Namespace, sa.Name)
} else {
resp.UseRegionalSTS = useRegional
}
resp.UseRegionalSTS = c.defaultRegionalSTS
if useRegionalStr, ok := sa.Annotations[c.annotationPrefix+"/"+pkg.UseRegionalSTSAnnotation]; ok {
useRegional, err := strconv.ParseBool(useRegionalStr)
if err != nil {
klog.V(4).Infof("Ignoring service account %s/%s invalid value for disable-regional-sts annotation", sa.Namespace, sa.Name)
} else {
resp.UseRegionalSTS = useRegional
}
}

resp.TokenExpiration = c.defaultTokenExpiration
if tokenExpirationStr, ok := sa.Annotations[c.annotationPrefix+"/"+pkg.TokenExpirationAnnotation]; ok {
if tokenExpiration, err := strconv.ParseInt(tokenExpirationStr, 10, 64); err != nil {
klog.V(4).Infof("Found invalid value for token expiration, using %d seconds as default: %v", resp.TokenExpiration, err)
} else {
resp.TokenExpiration = pkg.ValidateMinTokenExpiration(tokenExpiration)
}
resp.TokenExpiration = c.defaultTokenExpiration
if tokenExpirationStr, ok := sa.Annotations[c.annotationPrefix+"/"+pkg.TokenExpirationAnnotation]; ok {
if tokenExpiration, err := strconv.ParseInt(tokenExpirationStr, 10, 64); err != nil {
klog.V(4).Infof("Found invalid value for token expiration, using %d seconds as default: %v", resp.TokenExpiration, err)
} else {
resp.TokenExpiration = pkg.ValidateMinTokenExpiration(tokenExpiration)
}
c.webhookUsage.Set(1)
}
c.webhookUsage.Set(1)

c.setSA(sa.Name, sa.Namespace, resp)
}

Expand Down
90 changes: 90 additions & 0 deletions pkg/cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,93 @@ func TestRoleArnComposition(t *testing.T) {
assert.Equal(t, accountID, arn.AccountID, "Expected account ID to be %s, got %s", accountID, arn.AccountID)
assert.Equal(t, resource, arn.Resource, "Expected resource to be %s, got %s", resource, arn.Resource)
}

func TestGetCommonConfigurations(t *testing.T) {
const (
namespaceName = "foo"
serviceAccountName = "foo-sa"
)

k8sServiceAccount := &v1.ServiceAccount{}
k8sServiceAccount.Name = serviceAccountName
k8sServiceAccount.Namespace = namespaceName
k8sServiceAccount.Annotations = map[string]string{
"eks.amazonaws.com/sts-regional-endpoints": "true",
"eks.amazonaws.com/token-expiration": "10000",
}

k8sConfigMap := &v1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "pod-identity-webhook",
},
Data: map[string]string{
"config": "{\"foo/foo-sa\":{\"RoleARN\":\"arn:aws-test:iam::123456789012:role/my-role\",\"Audience\":\"amazonaws.com\",\"UseRegionalSTS\":true,\"TokenExpiration\":20000}}",
},
}

testcases := []struct {
name string
serviceAccount *v1.ServiceAccount
configMap *v1.ConfigMap
requestServiceAccount string
requestNamespace string
expectedUseRegionalSTS bool
expectedTokenExpiration int64
}{
{
name: "Entry not found in sa or cm",
requestServiceAccount: "sa",
requestNamespace: "ns",
expectedUseRegionalSTS: false,
expectedTokenExpiration: pkg.DefaultTokenExpiration,
},
{
name: "Service account is set, but not CM",
serviceAccount: k8sServiceAccount,
requestServiceAccount: serviceAccountName,
requestNamespace: namespaceName,
expectedUseRegionalSTS: true,
expectedTokenExpiration: 10000,
},
{
name: "Config map is set, but not service account",
configMap: k8sConfigMap,
requestServiceAccount: serviceAccountName,
requestNamespace: namespaceName,
expectedUseRegionalSTS: true,
expectedTokenExpiration: 20000,
},
{
name: "Both service account and config map is set, service account should take precedence",
serviceAccount: k8sServiceAccount,
configMap: k8sConfigMap,
requestServiceAccount: serviceAccountName,
requestNamespace: namespaceName,
expectedUseRegionalSTS: true,
expectedTokenExpiration: 10000,
},
}

for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
cache := &serviceAccountCache{
saCache: map[string]*CacheResponse{},
cmCache: map[string]*CacheResponse{},
defaultAudience: "sts.amazonaws.com",
annotationPrefix: "eks.amazonaws.com",
webhookUsage: prometheus.NewGauge(prometheus.GaugeOpts{}),
}

if tc.serviceAccount != nil {
cache.addSA(tc.serviceAccount)
}
if tc.configMap != nil {
cache.populateCacheFromCM(nil, tc.configMap)
}

useRegionalSTS, tokenExpiration := cache.GetCommonConfigurations(tc.requestServiceAccount, tc.requestNamespace)
assert.Equal(t, tc.expectedUseRegionalSTS, useRegionalSTS)
assert.Equal(t, tc.expectedTokenExpiration, tokenExpiration)
})
}
}
10 changes: 10 additions & 0 deletions pkg/cache/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ func (f *FakeServiceAccountCache) Get(name, namespace string) (role, aud string,
return resp.RoleARN, resp.Audience, resp.UseRegionalSTS, resp.TokenExpiration
}

func (f *FakeServiceAccountCache) GetCommonConfigurations(name, namespace string) (useRegionalSTS bool, tokenExpiration int64) {
f.mu.RLock()
defer f.mu.RUnlock()
resp, ok := f.cache[namespace+"/"+name]
if !ok {
return false, pkg.DefaultTokenExpiration
}
return resp.UseRegionalSTS, resp.TokenExpiration
}

// Add adds a cache entry
func (f *FakeServiceAccountCache) Add(name, namespace, role, aud string, regionalSTS bool, tokenExpiration int64) {
f.mu.Lock()
Expand Down
22 changes: 13 additions & 9 deletions pkg/constants.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
/*
Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
Copyright 2020 Amazon.com, Inc. or its affiliates. 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.
A copy of the License is located at
Licensed under the Apache License, Version 2.0 (the "License").
You may not use this file except in compliance with the License.
A copy of the License is located at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
or in the "license" file accompanying this file. This file 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.
or in the "license" file accompanying this file. This file 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 pkg

Expand All @@ -20,4 +20,8 @@ const (
DefaultTokenExpiration = int64(86400)
// 1hr is min for kube-apiserver
MinTokenExpiration = int64(3600)

// AWS SDK defined environment variables.
AwsEnvVarContainerCredentialsFullUri = "AWS_CONTAINER_CREDENTIALS_FULL_URI"
AwsEnvVarContainerAuthorizationTokenFile = "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"
)
Loading

0 comments on commit 254737f

Please sign in to comment.