diff --git a/cmd/aws-iam-authenticator/root.go b/cmd/aws-iam-authenticator/root.go index e4196b271..9a1fb7982 100644 --- a/cmd/aws-iam-authenticator/root.go +++ b/cmd/aws-iam-authenticator/root.go @@ -104,6 +104,7 @@ func getConfig() (config.Config, error) { EC2DescribeInstancesBurst: viper.GetInt("server.ec2DescribeInstancesBurst"), ScrubbedAWSAccounts: viper.GetStringSlice("server.scrubbedAccounts"), DynamicFilePath: viper.GetString("server.dynamicfilepath"), + DynamicFileUserIDStrict: viper.GetBool("server.dynamicfileUserIDStrict"), } if err := viper.UnmarshalKey("server.mapRoles", &cfg.RoleMappings); err != nil { return cfg, fmt.Errorf("invalid server role mappings: %v", err) diff --git a/hack/dev/access-entries.template b/hack/dev/access-entries.template index 6ad4eab67..1edb9c72a 100644 --- a/hack/dev/access-entries.template +++ b/hack/dev/access-entries.template @@ -5,7 +5,8 @@ "username": "kubernetes-admin", "groups": [ "system:masters" - ] + ], + "userid": "{{USER_ID}}" } ] } diff --git a/hack/e2e-dynamicfile.sh b/hack/e2e-dynamicfile.sh index 0598cd100..bd09aa967 100755 --- a/hack/e2e-dynamicfile.sh +++ b/hack/e2e-dynamicfile.sh @@ -103,7 +103,8 @@ function e2e_dynamicfile(){ echo "can't assume-role: "${AWS_TEST_ROLE} exit 1 fi - + USERID=$(aws sts get-caller-identity|jq -r '.UserId'|cut -d: -f1) + echo "userid: " $USERID #run kubectl cmd without adding the role into access entry if [ -f ${access_entry_json} ] then @@ -123,6 +124,7 @@ function e2e_dynamicfile(){ sed -e "s|{{AWS_ACCOUNT}}|${AWS_ACCOUNT}|g" \ -e "s|{{AWS_TEST_ROLE}}|${AWS_TEST_ROLE}|g" \ + -e "s|{{USER_ID}}|${USERID}|g" \ "${access_entry_template}" > "${access_entry_tmp}" mv "${access_entry_tmp}" "${access_entry_json}" #sleep 10 seconds to make access entry effective diff --git a/pkg/config/types.go b/pkg/config/types.go index 7c7fac4fd..904786cdc 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -52,7 +52,10 @@ type RoleMapping struct { // Groups is a list of Kubernetes groups this role will authenticate // as (e.g., `system:masters`). Each group name can include placeholders. - Groups []string `json:"groups"` + Groups []string `json:"groups" yaml:"groups"` + + // UserId is the AWS PrincipalId of the role. (e.g., "ABCXSOTJDDV"). + UserId string `json:"userid,omitempty" yaml:"userid,omitempty"` } // UserMapping is a static mapping of a single AWS User ARN to a @@ -65,7 +68,33 @@ type UserMapping struct { Username string `json:"username"` // Groups is a list of Kubernetes groups this role will authenticate as (e.g., `system:masters`) - Groups []string `json:"groups"` + Groups []string `json:"groups" yaml:"groups"` + + // UserId is the AWS PrincipalId of the user. (e.g., "ABCXSOTJDDV"). + UserId string `json:"userid,omitempty" yaml:"userid,omitempty"` +} + +// SSOARNMatcher contains fields used to match Role ARNs that +// are generated for AWS SSO sessions. These SSO Role ARNs +// follow this pattern: +// +// arn:aws:iam:::role/aws-reserved/sso.amazonaws.com//AWSReservedSSO__ +// +// These ARNs are canonicalized to look like: +// +// arn:aws:iam:::role/AWSReservedSSO__ +// +// This struct enables aws-iam-authenticator to match SSO generated Role ARNs with +// handling for their random string suffixes. +type SSOARNMatcher struct { + // PermissionSetName is the name of the SSO Permission Set that will be found + // after the "AWSReservedSSO_" string in the Role ARN. + // See: https://docs.aws.amazon.com/singlesignon/latest/userguide/permissionsets.html + PermissionSetName string `json:"permissionSetName" yaml:"permissionSetName"` + // AccountID is the AWS Account ID to match in the Role ARN + AccountID string `json:"accountID" yaml:"accountID"` + // Partition is the AWS partition to match in the Role ARN. Defaults to "aws" + Partition string `json:"partition,omitempty" yaml:"partition,omitempty"` } // Config specifies the configuration for a aws-iam-authenticator server @@ -144,4 +173,6 @@ type Config struct { EC2DescribeInstancesBurst int //Dynamic File Path for DynamicFile BackendMode DynamicFilePath string + //use UserId for mapping, IdentityArn is not used any more when DynamicFileUserIDStrict=true + DynamicFileUserIDStrict bool } diff --git a/pkg/mapper/configmap/mapper.go b/pkg/mapper/configmap/mapper.go index 6088bd34c..429a603ce 100644 --- a/pkg/mapper/configmap/mapper.go +++ b/pkg/mapper/configmap/mapper.go @@ -1,6 +1,7 @@ package configmap import ( + "sigs.k8s.io/aws-iam-authenticator/pkg/token" "strings" "sigs.k8s.io/aws-iam-authenticator/pkg/config" @@ -30,8 +31,8 @@ func (m *ConfigMapMapper) Start(stopCh <-chan struct{}) error { return nil } -func (m *ConfigMapMapper) Map(canonicalARN string) (*config.IdentityMapping, error) { - canonicalARN = strings.ToLower(canonicalARN) +func (m *ConfigMapMapper) Map(identity *token.Identity) (*config.IdentityMapping, error) { + canonicalARN := strings.ToLower(identity.CanonicalARN) rm, err := m.RoleMapping(canonicalARN) // TODO: Check for non Role/UserNotFound errors diff --git a/pkg/mapper/crd/mapper.go b/pkg/mapper/crd/mapper.go index 6b8dcb186..a80a3f9d8 100644 --- a/pkg/mapper/crd/mapper.go +++ b/pkg/mapper/crd/mapper.go @@ -2,6 +2,7 @@ package crd import ( "fmt" + "sigs.k8s.io/aws-iam-authenticator/pkg/token" "strings" "time" @@ -86,8 +87,8 @@ func (m *CRDMapper) Start(stopCh <-chan struct{}) error { return nil } -func (m *CRDMapper) Map(canonicalARN string) (*config.IdentityMapping, error) { - canonicalARN = strings.ToLower(canonicalARN) +func (m *CRDMapper) Map(identity *token.Identity) (*config.IdentityMapping, error) { + canonicalARN := strings.ToLower(identity.CanonicalARN) var iamidentity *iamauthenticatorv1alpha1.IAMIdentityMapping var ok bool diff --git a/pkg/mapper/dynamicfile/dynamicfile.go b/pkg/mapper/dynamicfile/dynamicfile.go index ad0148f26..a35e1250f 100644 --- a/pkg/mapper/dynamicfile/dynamicfile.go +++ b/pkg/mapper/dynamicfile/dynamicfile.go @@ -4,16 +4,15 @@ import ( "encoding/json" "errors" "fmt" - "os" - "strings" - "sync" - "time" - "github.com/fsnotify/fsnotify" "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/util/wait" + "os" "sigs.k8s.io/aws-iam-authenticator/pkg/arn" "sigs.k8s.io/aws-iam-authenticator/pkg/config" + "strings" + "sync" + "time" ) type DynamicFileMapStore struct { @@ -21,8 +20,9 @@ type DynamicFileMapStore struct { users map[string]config.UserMapping roles map[string]config.RoleMapping // Used as set. - awsAccounts map[string]interface{} - filename string + awsAccounts map[string]interface{} + filename string + userIDStrict bool } type DynamicFileData struct { @@ -66,7 +66,7 @@ func (m *DynamicFileMapStore) loadDynamicFile() error { } logrus.Infof("LoadDynamicFile: %v is available. loading", m.filename) // load the initial file content into memory - userMappings, roleMappings, awsAccounts, err := ParseMap(m.filename) + userMappings, roleMappings, awsAccounts, err := ParseMap(m) if err != nil { logrus.Errorf("LoadDynamicFile: There was an error parsing the dynamic file: %+v. Map is not updated. Please correct dynamic file", err) return err @@ -76,9 +76,10 @@ func (m *DynamicFileMapStore) loadDynamicFile() error { return nil } -func NewDynamicFileMapStore(filename string) (*DynamicFileMapStore, error) { +func NewDynamicFileMapStore(cfg config.Config) (*DynamicFileMapStore, error) { ms := DynamicFileMapStore{} - ms.filename = filename + ms.filename = cfg.DynamicFilePath + ms.userIDStrict = cfg.DynamicFileUserIDStrict return &ms, nil } @@ -127,11 +128,11 @@ func (m *DynamicFileMapStore) startLoadDynamicFile(stopCh <-chan struct{}) { }, time.Second, stopCh) } -func ParseMap(filename string) (userMappings []config.UserMapping, roleMappings []config.RoleMapping, awsAccounts []string, err error) { +func ParseMap(m *DynamicFileMapStore) (userMappings []config.UserMapping, roleMappings []config.RoleMapping, awsAccounts []string, err error) { errs := make([]error, 0) userMappings = make([]config.UserMapping, 0) roleMappings = make([]config.RoleMapping, 0) - + filename := m.filename dynamicContent, err := os.ReadFile(filename) if err != nil { logrus.Errorf("ParseMap: could not read from dynamic file") @@ -149,16 +150,24 @@ func ParseMap(filename string) (userMappings []config.UserMapping, roleMappings } for _, userMapping := range dynamicFileData.UserMappings { - if userMapping.UserARN == "" { - errs = append(errs, fmt.Errorf("Value for userarn must be supplied")) + key := userMapping.UserARN + if m.userIDStrict { + key = userMapping.UserId + } + if key == "" { + errs = append(errs, fmt.Errorf("Value for userarn or userid(if dynamicfileUserIDStrict = true) must be supplied")) } else { userMappings = append(userMappings, userMapping) } } for _, roleMapping := range dynamicFileData.RoleMappings { - if roleMapping.RoleARN == "" { - errs = append(errs, fmt.Errorf("Value for rolearn must be supplied")) + key := roleMapping.RoleARN + if m.userIDStrict { + key = roleMapping.UserId + } + if key == "" { + errs = append(errs, fmt.Errorf("Value for rolearn or userid(if dynamicfileUserIDStrict = true) must be supplied")) } else { roleMappings = append(roleMappings, roleMapping) } @@ -184,12 +193,18 @@ func (ms *DynamicFileMapStore) saveMap( ms.awsAccounts = make(map[string]interface{}) for _, user := range userMappings { - canonicalizedARN, _ := arn.Canonicalize(strings.ToLower(user.UserARN)) - ms.users[canonicalizedARN] = user + key, _ := arn.Canonicalize(strings.ToLower(user.UserARN)) + if ms.userIDStrict { + key = user.UserId + } + ms.users[key] = user } for _, role := range roleMappings { - canonicalizedARN, _ := arn.Canonicalize(strings.ToLower(role.RoleARN)) - ms.roles[canonicalizedARN] = role + key, _ := arn.Canonicalize(strings.ToLower(role.RoleARN)) + if ms.userIDStrict { + key = role.UserId + } + ms.roles[key] = role } for _, awsAccount := range awsAccounts { ms.awsAccounts[awsAccount] = nil diff --git a/pkg/mapper/dynamicfile/dynamicfile_test.go b/pkg/mapper/dynamicfile/dynamicfile_test.go index bb8c6c638..3691b20b6 100644 --- a/pkg/mapper/dynamicfile/dynamicfile_test.go +++ b/pkg/mapper/dynamicfile/dynamicfile_test.go @@ -22,7 +22,7 @@ func makeStore() DynamicFileMapStore { filename: "test.txt", } ms.users["arn:aws:iam::012345678912:user/matt"] = testUser - ms.roles["arn:aws:iam::012345678912:role/computer"] = testRole + ms.roles["UserId001"] = testRole ms.awsAccounts["123"] = nil return ms } @@ -48,7 +48,7 @@ func TestUserMapping(t *testing.T) { func TestRoleMapping(t *testing.T) { ms := makeStore() - role, err := ms.RoleMapping("arn:aws:iam::012345678912:role/computer") + role, err := ms.RoleMapping("UserId001") if err != nil { t.Errorf("Could not find user 'instance in map") } @@ -83,7 +83,8 @@ var origFileContent = ` "username": "kubernetes-admin", "groups": [ "system:masters" - ] + ], + "userid": "userid678" } ], "mapUsers": [ @@ -92,14 +93,16 @@ var origFileContent = ` "username": "alice", "groups": [ "system:masters" - ] + ], + "userid": "userid135" }, { "userarn": "arn:aws:iam::000000000002:user/Alice2", "username": "alice2", "groups": [ "system:masters" - ] + ], + "userid": "userid136" } ], "mapAccounts": [ @@ -117,7 +120,8 @@ var updatedFileContent = ` "username": "kubernetes-admin", "groups": [ "system:masters" - ] + ], + "userid": "userid12359" }, { "rolearn": "arn:aws:iam::000000000002:role/KubernetesNode", @@ -125,7 +129,8 @@ var updatedFileContent = ` "groups": [ "system:bootstrappers", "aws:instances" - ] + ], + "userid": "userid123" }, { "rolearn": "arn:aws:iam::000000000003:role/KubernetesNode", @@ -133,14 +138,16 @@ var updatedFileContent = ` "groups": [ "system:nodes", "system:bootstrappers" - ] + ], + "userid": "userid008" }, { "rolearn": "arn:aws:iam::000000000004:role/KubernetesAdmin", "username": "admin:{{SessionName}}", "groups": [ "system:masters" - ] + ], + "userid": "userid777" } ], "mapUsers": [ @@ -149,7 +156,8 @@ var updatedFileContent = ` "username": "alice", "groups": [ "system:masters" - ] + ], + "userid": "userid008" } ], "mapAccounts": [ @@ -159,12 +167,78 @@ var updatedFileContent = ` } ` +func TestUserIdStrict(t *testing.T) { + stopCh := make(chan struct{}) + defer close(stopCh) + + //When the file doesn't exist, expect mapping should be empty map + cfg := config.Config{ + DynamicFileUserIDStrict: true, + DynamicFilePath: "/tmp/test.txt", + } + ms, err := NewDynamicFileMapStore(cfg) + if err != nil { + t.Errorf("failed to create a DynamicFileMapper") + } + data := []byte(origFileContent) + err = os.WriteFile("/tmp/test.txt", data, 0600) + if err != nil { + t.Errorf("failed to create a local file /tmp/test.txt") + } + ms.startLoadDynamicFile(stopCh) + time.Sleep(1 * time.Second) + ms.mutex.RLock() + for key, _ := range ms.roles { + if key[0:6] != "userid" { + t.Errorf("failed to generate key for userIDStrict") + } + } + ms.mutex.RUnlock() + //clean test files + defer os.Remove("/tmp/test.txt") +} + +func TestWithoutUserIdStrict(t *testing.T) { + stopCh := make(chan struct{}) + defer close(stopCh) + + //When the file doesn't exist, expect mapping should be empty map + cfg := config.Config{ + DynamicFileUserIDStrict: false, + DynamicFilePath: "/tmp/test.txt", + } + ms, err := NewDynamicFileMapStore(cfg) + if err != nil { + t.Errorf("failed to create a DynamicFileMapper") + } + data := []byte(origFileContent) + err = os.WriteFile("/tmp/test.txt", data, 0600) + if err != nil { + t.Errorf("failed to create a local file /tmp/test.txt") + } + ms.startLoadDynamicFile(stopCh) + time.Sleep(1 * time.Second) + ms.mutex.RLock() + for key, _ := range ms.roles { + if key[0:3] != "arn" { + t.Errorf("failed to generate key for userIDStrict") + } + } + ms.mutex.RUnlock() + //clean test files + defer os.Remove("/tmp/test.txt") +} + func TestLoadDynamicFile(t *testing.T) { stopCh := make(chan struct{}) defer close(stopCh) //When the file doesn't exist, expect mapping should be empty map - ms, err := NewDynamicFileMapStore("/tmp/test.txt") + cfg := config.Config{ + DynamicFileUserIDStrict: true, + DynamicFilePath: "/tmp/test.txt", + } + ms, err := NewDynamicFileMapStore(cfg) if err != nil { t.Errorf("failed to create a DynamicFileMapper") } @@ -207,11 +281,15 @@ func TestLoadDynamicFile(t *testing.T) { expectedData := []byte(updatedFileContent) err = os.WriteFile("/tmp/expected.txt", expectedData, 0600) - expectedMapStore, err := NewDynamicFileMapStore("/tmp/expected.txt") + cfg = config.Config{ + DynamicFileUserIDStrict: true, + DynamicFilePath: "/tmp/expected.txt", + } + expectedMapStore, err := NewDynamicFileMapStore(cfg) if err != nil { t.Errorf("failed to create expected DynamicFileMapper") } - expectedUserMappings, expectedRoleMappings, expectedAwsAccounts, err := ParseMap(expectedMapStore.filename) + expectedUserMappings, expectedRoleMappings, expectedAwsAccounts, err := ParseMap(expectedMapStore) if err != nil { t.Errorf("failed to ParseMap expected DynamicFileMapper") } @@ -287,25 +365,30 @@ func TestParseMap(t *testing.T) { if err != nil { t.Errorf("failed to create a local file /tmp/test.txt") } - ms, err := NewDynamicFileMapStore("/tmp/test.txt") + cfg := config.Config{ + DynamicFileUserIDStrict: true, + DynamicFilePath: "/tmp/test.txt", + } + ms, err := NewDynamicFileMapStore(cfg) if err != nil { t.Errorf("failed to create a DynamicFileMapper") } - u, r, a, err := ParseMap(ms.filename) + u, r, a, err := ParseMap(ms) if err != nil { t.Fatal(err) } origUserMappings := []config.UserMapping{ - {UserARN: "arn:aws:iam::000000000000:user/Alice", Username: "alice", Groups: []string{"system:masters"}}, - {UserARN: "arn:aws:iam::000000000002:user/Alice2", Username: "alice2", Groups: []string{"system:masters"}}, + {UserARN: "arn:aws:iam::000000000000:user/Alice", Username: "alice", Groups: []string{"system:masters"}, UserId: "userid135"}, + {UserARN: "arn:aws:iam::000000000002:user/Alice2", Username: "alice2", Groups: []string{"system:masters"}, UserId: "userid136"}, } origRoleMappings := []config.RoleMapping{ { RoleARN: "arn:aws:iam::000000000098:role/KubernetesAdmin", Username: "kubernetes-admin", Groups: []string{"system:masters"}, + UserId: "userid678", }, } origAccounts := []string{"012345678901", "456789012345"} diff --git a/pkg/mapper/dynamicfile/mapper.go b/pkg/mapper/dynamicfile/mapper.go index ed2b14ff3..dec48b2ed 100644 --- a/pkg/mapper/dynamicfile/mapper.go +++ b/pkg/mapper/dynamicfile/mapper.go @@ -3,6 +3,7 @@ package dynamicfile import ( "sigs.k8s.io/aws-iam-authenticator/pkg/config" "sigs.k8s.io/aws-iam-authenticator/pkg/mapper" + "sigs.k8s.io/aws-iam-authenticator/pkg/token" "strings" ) @@ -13,7 +14,7 @@ type DynamicFileMapper struct { var _ mapper.Mapper = &DynamicFileMapper{} func NewDynamicFileMapper(cfg config.Config) (*DynamicFileMapper, error) { - ms, err := NewDynamicFileMapStore(cfg.DynamicFilePath) + ms, err := NewDynamicFileMapStore(cfg) if err != nil { return nil, err } @@ -29,10 +30,14 @@ func (m *DynamicFileMapper) Start(stopCh <-chan struct{}) error { return nil } -func (m *DynamicFileMapper) Map(canonicalARN string) (*config.IdentityMapping, error) { - canonicalARN = strings.ToLower(canonicalARN) +func (m *DynamicFileMapper) Map(identity *token.Identity) (*config.IdentityMapping, error) { + canonicalARN := strings.ToLower(identity.CanonicalARN) + key := canonicalARN + if m.userIDStrict { + key = identity.UserID + } - rm, err := m.RoleMapping(canonicalARN) + rm, err := m.RoleMapping(key) // TODO: Check for non Role/UserNotFound errors if err == nil { return &config.IdentityMapping{ @@ -42,7 +47,7 @@ func (m *DynamicFileMapper) Map(canonicalARN string) (*config.IdentityMapping, e }, nil } - um, err := m.UserMapping(canonicalARN) + um, err := m.UserMapping(key) if err == nil { return &config.IdentityMapping{ IdentityARN: canonicalARN, diff --git a/pkg/mapper/file/mapper.go b/pkg/mapper/file/mapper.go index e38c2eb30..240e46e70 100644 --- a/pkg/mapper/file/mapper.go +++ b/pkg/mapper/file/mapper.go @@ -7,6 +7,7 @@ import ( "sigs.k8s.io/aws-iam-authenticator/pkg/arn" "sigs.k8s.io/aws-iam-authenticator/pkg/config" "sigs.k8s.io/aws-iam-authenticator/pkg/mapper" + "sigs.k8s.io/aws-iam-authenticator/pkg/token" ) type FileMapper struct { @@ -64,8 +65,8 @@ func (m *FileMapper) Start(_ <-chan struct{}) error { return nil } -func (m *FileMapper) Map(canonicalARN string) (*config.IdentityMapping, error) { - canonicalARN = strings.ToLower(canonicalARN) +func (m *FileMapper) Map(identity *token.Identity) (*config.IdentityMapping, error) { + canonicalARN := strings.ToLower(identity.CanonicalARN) if roleMapping, exists := m.lowercaseRoleMap[canonicalARN]; exists { return &config.IdentityMapping{ diff --git a/pkg/mapper/mapper.go b/pkg/mapper/mapper.go index a9c18d806..0f4859867 100644 --- a/pkg/mapper/mapper.go +++ b/pkg/mapper/mapper.go @@ -3,6 +3,7 @@ package mapper import ( "errors" "fmt" + "sigs.k8s.io/aws-iam-authenticator/pkg/token" "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/util/sets" @@ -39,7 +40,7 @@ type Mapper interface { Name() string // Start must be non-blocking Start(stopCh <-chan struct{}) error - Map(canonicalARN string) (*config.IdentityMapping, error) + Map(identity *token.Identity) (*config.IdentityMapping, error) IsAccountAllowed(accountID string) bool } diff --git a/pkg/server/server.go b/pkg/server/server.go index c30bd228b..595d587d6 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -358,10 +358,8 @@ func (h *handler) authenticateEndpoint(w http.ResponseWriter, req *http.Request) func (h *handler) doMapping(identity *token.Identity) (string, []string, error) { var errs []error - canonicalARN := strings.ToLower(identity.CanonicalARN) - for _, m := range h.mappers { - mapping, err := m.Map(canonicalARN) + mapping, err := m.Map(identity) if err == nil { // Mapping found, try to render any templates like {{EC2PrivateDNSName}} username, groups, err := h.renderTemplates(*mapping, identity)