diff --git a/.gitignore b/.gitignore index 1a2434d8..862ce978 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ _testmain.go /build/ coverage.out /vendor/ +.idea diff --git a/cmd/main.go b/cmd/main.go index cb794910..d144cf52 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -33,6 +33,7 @@ func addFlags(s *server.Server, fs *pflag.FlagSet) { fs.BoolVar(&s.NamespaceRestriction, "namespace-restrictions", false, "Enable namespace restrictions") fs.StringVar(&s.NamespaceRestrictionFormat, "namespace-restriction-format", s.NamespaceRestrictionFormat, "Namespace Restriction Format (glob/regexp)") fs.StringVar(&s.NamespaceKey, "namespace-key", s.NamespaceKey, "Namespace annotation key used to retrieve the IAM roles allowed (value in annotation should be json array)") + fs.StringVar(&s.NamespaceIAMRoleKey, "namespace-iam-role-key", s.NamespaceIAMRoleKey, "") fs.DurationVar(&s.CacheResyncPeriod, "cache-resync-period", s.CacheResyncPeriod, "Kubernetes caches resync period") fs.StringVar(&s.HostIP, "host-ip", s.HostIP, "IP address of host") fs.StringVar(&s.NodeName, "node", s.NodeName, "Name of the node where kube2iam is running") diff --git a/mappings/mapper.go b/mappings/mapper.go index e36af3d6..3c963572 100644 --- a/mappings/mapper.go +++ b/mappings/mapper.go @@ -23,6 +23,7 @@ type RoleMapper struct { iam *iam.Client store store namespaceRestrictionFormat string + namespaceIamRoleKey string } type store interface { @@ -47,14 +48,24 @@ func (r *RoleMapper) GetRoleMapping(IP string) (*RoleMappingResult, error) { return nil, err } + namespace := pod.GetNamespace() + role, err := r.extractRoleARN(pod) if err != nil { return nil, err } + namespaceRole, err := r.extractNamespaceRoleARN(namespace) + if err != nil { + return nil, err + } + if len(namespaceRole) > 0 { + role = namespaceRole + } + // Determine if normalized role is allowed to be used in pod's namespace - if r.checkRoleForNamespace(role, pod.GetNamespace()) { - return &RoleMappingResult{Role: role, Namespace: pod.GetNamespace(), IP: IP}, nil + if r.checkRoleForNamespace(role, namespace) { + return &RoleMappingResult{Role: role, Namespace: namespace, IP: IP}, nil } return nil, fmt.Errorf("role requested %s not valid for namespace of pod at %s with namespace %s", role, IP, pod.GetNamespace()) @@ -73,12 +84,11 @@ func (r *RoleMapper) GetExternalIDMapping(IP string) (string, error) { return externalID, nil } -// extractQualifiedRoleName extracts a fully qualified ARN for a given pod, +// extractRoleARN extracts a fully qualified ARN for a given pod, // taking into consideration the appropriate fallback logic and defaulting // logic along with the namespace role restrictions func (r *RoleMapper) extractRoleARN(pod *v1.Pod) (string, error) { rawRoleName, annotationPresent := pod.GetAnnotations()[r.iamRoleKey] - if !annotationPresent && r.defaultRoleARN == "" { return "", fmt.Errorf("unable to find role for IP %s", pod.Status.PodIP) } @@ -91,6 +101,23 @@ func (r *RoleMapper) extractRoleARN(pod *v1.Pod) (string, error) { return r.iam.RoleARN(rawRoleName), nil } +// extractNamespaceRoleARN extracts a fully qualified ARN for a given namespace, +// if the role is not set on the namespace level we will return empty role arn without error +func (r *RoleMapper) extractNamespaceRoleARN(namespace string) (string, error) { + ns, err := r.store.NamespaceByName(namespace) + if err != nil { + log.Debugf("Unable to find an indexed namespace of %s in order to check if the role iam annotation is present", namespace) + return "", fmt.Errorf("unable to find an indexed namespace of %s", namespace) + } + + rawRoleName, annotationPresent := ns.GetAnnotations()[r.iamRoleKey] + if !annotationPresent { + return "", nil + } + + return r.iam.RoleARN(rawRoleName), nil +} + // checkRoleForNamespace checks the 'database' for a role allowed in a namespace, // returns true if the role is found, otheriwse false func (r *RoleMapper) checkRoleForNamespace(roleArn string, namespace string) bool { @@ -100,7 +127,7 @@ func (r *RoleMapper) checkRoleForNamespace(roleArn string, namespace string) boo ns, err := r.store.NamespaceByName(namespace) if err != nil { - log.Debug("Unable to find an indexed namespace of %s", namespace) + log.Debugf("Unable to find an indexed namespace of %s", namespace) return false } @@ -134,7 +161,8 @@ func (r *RoleMapper) DumpDebugInfo() map[string]interface{} { output := make(map[string]interface{}) rolesByIP := make(map[string]string) namespacesByIP := make(map[string]string) - rolesByNamespace := make(map[string][]string) + rolesRestrictionsByNamespace := make(map[string][]string) + rolesByNamespace := make(map[string]string) for _, ip := range r.store.ListPodIPs() { // When pods have `hostNetwork: true` they share an IP and we receive an error @@ -150,18 +178,25 @@ func (r *RoleMapper) DumpDebugInfo() map[string]interface{} { for _, namespaceName := range r.store.ListNamespaces() { if namespace, err := r.store.NamespaceByName(namespaceName); err == nil { - rolesByNamespace[namespace.GetName()] = kube2iam.GetNamespaceRoleAnnotation(namespace, r.namespaceKey) + rolesRestrictionsByNamespace[namespace.GetName()] = kube2iam.GetNamespaceRoleAnnotation(namespace, r.namespaceKey) + + rawRoleName, annotationPresent := namespace.GetAnnotations()[r.namespaceIamRoleKey] + rolesByNamespace[namespace.GetName()] = "" + if annotationPresent { + rolesByNamespace[namespace.GetName()] = rawRoleName + } } } output["rolesByIP"] = rolesByIP output["namespaceByIP"] = namespacesByIP output["rolesByNamespace"] = rolesByNamespace + output["rolesRestrictionsByNamespace"] = rolesRestrictionsByNamespace return output } // NewRoleMapper returns a new RoleMapper for use. -func NewRoleMapper(roleKey string, externalIDKey string, defaultRole string, namespaceRestriction bool, namespaceKey string, iamInstance *iam.Client, kubeStore store, namespaceRestrictionFormat string) *RoleMapper { +func NewRoleMapper(roleKey string, externalIDKey string, defaultRole string, namespaceRestriction bool, namespaceKey string, iamInstance *iam.Client, kubeStore store, namespaceRestrictionFormat string, namespaceRoleKey string) *RoleMapper { return &RoleMapper{ defaultRoleARN: iamInstance.RoleARN(defaultRole), iamRoleKey: roleKey, @@ -171,5 +206,6 @@ func NewRoleMapper(roleKey string, externalIDKey string, defaultRole string, nam iam: iamInstance, store: kubeStore, namespaceRestrictionFormat: namespaceRestrictionFormat, + namespaceIamRoleKey: namespaceRoleKey, } } diff --git a/mappings/mapper_test.go b/mappings/mapper_test.go index 769c55e7..8d18b16f 100644 --- a/mappings/mapper_test.go +++ b/mappings/mapper_test.go @@ -93,6 +93,84 @@ func TestExtractRoleARN(t *testing.T) { } } +func TestExtractNamespaceRoleARN(t *testing.T) { + var roleExtractionTests = []struct { + test string + namespace string + requestedNamespace string + annotations map[string]string + expectedARN string + expectError bool + }{ + { + test: "No annotation", + namespace: "default", + requestedNamespace: "default", + annotations: map[string]string{}, + expectError: false, + expectedARN: "", + }, + { + test: "Namespace is not indexed", + namespace: "default", + requestedNamespace: "default-not-indexed", + annotations: map[string]string{}, + expectError: true, + expectedARN: "", + }, + { + test: "No annotation at the namespace level", + namespace: "default", + requestedNamespace: "default", + annotations: map[string]string{}, + expectError: false, + expectedARN: "", + }, + { + test: "Annotation at the namespace level but not the one to request a specific role", + namespace: "default", + requestedNamespace: "default", + annotations: map[string]string{"nonMatchingAnnotation": "something"}, + expectError: false, + expectedARN: "", + }, + { + test: "Annotation at the namespace level", + namespace: "default", + requestedNamespace: "default", + annotations: map[string]string{roleKey: "arn:aws:iam::999999999999:role/explicit-arn"}, + expectError: false, + expectedARN: "arn:aws:iam::999999999999:role/explicit-arn", + }, + } + for _, tt := range roleExtractionTests { + t.Run(tt.test, func(t *testing.T) { + rp := RoleMapper{} + rp.iamRoleKey = "roleKey" + rp.iamExternalIDKey = "externalIDKey" + rp.iam = &iam.Client{BaseARN: defaultBaseRole} + rp.store = &storeMock{ + namespace: tt.namespace, + annotations: tt.annotations, + } + + resp, err := rp.extractNamespaceRoleARN(tt.requestedNamespace) + if tt.expectError && err == nil { + t.Error("Expected error however didn't recieve one") + return + } + if !tt.expectError && err != nil { + t.Errorf("Didn't expect error but recieved %s", err) + return + } + if resp != tt.expectedARN { + t.Errorf("Response [%s] did not equal expected [%s]", resp, tt.expectedARN) + return + } + }) + } +} + func TestCheckRoleForNamespace(t *testing.T) { var roleCheckTests = []struct { test string @@ -371,6 +449,7 @@ func TestCheckRoleForNamespace(t *testing.T) { annotations: tt.namespaceAnnotations, }, tt.namespaceRestrictionFormat, + "iam.amazonaws.com/role", ) resp := rp.checkRoleForNamespace(tt.roleARN, tt.namespace) diff --git a/server/server.go b/server/server.go index 36af58c3..d8e3f102 100644 --- a/server/server.go +++ b/server/server.go @@ -36,6 +36,7 @@ const ( defaultMaxInterval = 1 * time.Second defaultMetadataAddress = "169.254.169.254" defaultNamespaceKey = "iam.amazonaws.com/allowed-roles" + defaultNamespaceIAMRoleKey = "iam.amazonaws.com/role" defaultCacheResyncPeriod = 30 * time.Minute defaultNamespaceRestrictionFormat = "glob" healthcheckInterval = 30 * time.Second @@ -61,6 +62,7 @@ type Server struct { HostIP string NodeName string NamespaceKey string + NamespaceIAMRoleKey string CacheResyncPeriod time.Duration LogLevel string LogFormat string @@ -376,7 +378,7 @@ func (s *Server) Run(host, token, nodeName string, insecure bool) error { s.k8s = k s.iam = iam.NewClient(s.BaseRoleARN, s.UseRegionalStsEndpoint) log.Debugln("Caches have been synced. Proceeding with server.") - s.roleMapper = mappings.NewRoleMapper(s.IAMRoleKey, s.IAMExternalID, s.DefaultIAMRole, s.NamespaceRestriction, s.NamespaceKey, s.iam, s.k8s, s.NamespaceRestrictionFormat) + s.roleMapper = mappings.NewRoleMapper(s.IAMRoleKey, s.IAMExternalID, s.DefaultIAMRole, s.NamespaceRestriction, s.NamespaceKey, s.iam, s.k8s, s.NamespaceRestrictionFormat, s.NamespaceIAMRoleKey) log.Debugf("Starting pod and namespace sync jobs with %s resync period", s.CacheResyncPeriod.String()) podSynched := s.k8s.WatchForPods(kube2iam.NewPodHandler(s.IAMRoleKey), s.CacheResyncPeriod) namespaceSynched := s.k8s.WatchForNamespaces(kube2iam.NewNamespaceHandler(s.NamespaceKey), s.CacheResyncPeriod) @@ -438,6 +440,7 @@ func NewServer() *Server { LogFormat: defaultLogFormat, MetadataAddress: defaultMetadataAddress, NamespaceKey: defaultNamespaceKey, + NamespaceIAMRoleKey: defaultNamespaceIAMRoleKey, CacheResyncPeriod: defaultCacheResyncPeriod, NamespaceRestrictionFormat: defaultNamespaceRestrictionFormat, HealthcheckFailReason: "Healthcheck not yet performed",