From 3d3a079712831b2e1a3696b7bf0e64cc4504ec92 Mon Sep 17 00:00:00 2001 From: logandavies181 <39316073+logandavies181@users.noreply.github.com> Date: Fri, 17 Jun 2022 08:00:52 +1200 Subject: [PATCH 1/7] Add SSO Role suffix support (#416) Add SSO Role suffix support. This change adds the ability to match AWS SSO roles without needing to know the random suffix. This solves the issue of teams that use AWS SSO roles and aws-iam-authenticator (including EKS users) from needing to keep their configurations up-to-date with the random suffixes that AWS SSO applies to roles it creates. This also has the side-effect of making SSO roles more intuitive to work with. --- cmd/aws-iam-authenticator/add.go | 49 +++++- cmd/aws-iam-authenticator/root.go | 11 +- docs/sso_role_matcher.md | 62 ++++++++ pkg/arn/arnlike.go | 104 +++++++++++++ pkg/arn/arnlike_test.go | 168 +++++++++++++++++++++ pkg/config/features.go | 6 + pkg/config/mapper.go | 121 ++++++++++++++- pkg/config/mapper_test.go | 159 +++++++++++++++++++ pkg/config/types.go | 35 ++++- pkg/mapper/configmap/client/client.go | 17 ++- pkg/mapper/configmap/client/client_test.go | 35 +++++ pkg/mapper/configmap/configmap.go | 52 +++++-- pkg/mapper/configmap/configmap_test.go | 74 +++++++-- pkg/mapper/file/mapper.go | 47 +++--- pkg/mapper/file/mapper_test.go | 130 ++++++++++++++++ pkg/server/server.go | 18 ++- 16 files changed, 1024 insertions(+), 64 deletions(-) create mode 100644 docs/sso_role_matcher.md create mode 100644 pkg/arn/arnlike.go create mode 100644 pkg/arn/arnlike_test.go create mode 100644 pkg/config/mapper_test.go create mode 100644 pkg/mapper/file/mapper_test.go diff --git a/cmd/aws-iam-authenticator/add.go b/cmd/aws-iam-authenticator/add.go index 979d2a2ea..ada11df7e 100644 --- a/cmd/aws-iam-authenticator/add.go +++ b/cmd/aws-iam-authenticator/add.go @@ -44,7 +44,7 @@ var addUserCmd = &cobra.Command{ Long: "NOTE: this does not currently support the CRD and file backends", Run: func(cmd *cobra.Command, args []string) { if userARN == "" || userName == "" || len(groups) == 0 { - fmt.Printf("invalid empty value in userARN %q, username %q, groups %q", userARN, userName, groups) + fmt.Printf("invalid empty value in userARN %q, username %q, groups %q\n", userARN, userName, groups) os.Exit(1) } @@ -75,16 +75,52 @@ var addRoleCmd = &cobra.Command{ Short: "add a role entity to an existing aws-auth configmap, not for CRD/file backends", Long: "NOTE: this does not currently support the CRD and file backends", Run: func(cmd *cobra.Command, args []string) { - if roleARN == "" || userName == "" || len(groups) == 0 { - fmt.Printf("invalid empty value in rolearn %q, username %q, groups %q", roleARN, userName, groups) + if (roleARN == "" && ssoRole == nil) || userName == "" || len(groups) == 0 { + fmt.Printf("invalid empty value in rolearn %q, username %q, groups %q\n", roleARN, userName, groups) os.Exit(1) } - checkPrompt(fmt.Sprintf("add rolearn %s, username %s, groups %s", roleARN, userName, groups)) + var arnOrSSORole string + switch { + case roleARN != "" && ssoRole != nil: + fmt.Printf("only one of --rolearn or --sso can be supplied\n") + os.Exit(1) + case roleARN != "": + arnOrSSORole = "rolearn" + case ssoRole != nil: + arnOrSSORole = "sso" + + for _, key := range []string{"permissionSetName", "accountID"} { + if _, ok := ssoRole[key]; !ok { + fmt.Printf("required key '%s' missing from --sso flag\n", key) + os.Exit(1) + } + } + + var ssoPartition string + if partition, ok := ssoRole["partition"]; !ok { + ssoPartition = "aws" + } else { + ssoPartition = partition + } + ssoRoleConfig.PermissionSetName = ssoRole["permissionSetName"] + ssoRoleConfig.AccountID = ssoRole["accountID"] + ssoRoleConfig.Partition = ssoPartition + + rm := config.RoleMapping{SSO: ssoRoleConfig} + err := rm.Validate() + if err != nil { + fmt.Printf("error validating --sso: %s\n", err) + os.Exit(1) + } + } + + checkPrompt(fmt.Sprintf("add %s %s, username %s, groups %s", arnOrSSORole, roleARN, userName, groups)) cli := createClient() cm, err := cli.AddRole(&config.RoleMapping{ RoleARN: roleARN, + SSO: ssoRoleConfig, Username: userName, Groups: groups, }) @@ -178,6 +214,10 @@ var ( userName string groups []string roleARN string + // ssoRole contains the settings for a config.SSOARNMatcher + // it expects the keys "permissionSetName", "accountID", and "partition" (optional) + ssoRole map[string]string + ssoRoleConfig *config.SSOARNMatcher ) func init() { @@ -195,6 +235,7 @@ func init() { addUserCmd.PersistentFlags().StringSliceVar(&groups, "groups", nil, "A new user groups") addRoleCmd.PersistentFlags().StringVar(&roleARN, "rolearn", "", "A new role ARN") + addRoleCmd.PersistentFlags().StringToStringVar(&ssoRole, "sso", nil, `Settings for a new SSO role. Expects "permissionSetName", "accountID", and "partition" (optional)`) addRoleCmd.PersistentFlags().StringVar(&userName, "username", "", "A new user name") addRoleCmd.PersistentFlags().StringSliceVar(&groups, "groups", nil, "A new role groups") } diff --git a/cmd/aws-iam-authenticator/root.go b/cmd/aws-iam-authenticator/root.go index 77b33f509..4c8321f13 100644 --- a/cmd/aws-iam-authenticator/root.go +++ b/cmd/aws-iam-authenticator/root.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "os" + "strings" "sigs.k8s.io/aws-iam-authenticator/pkg/config" "sigs.k8s.io/aws-iam-authenticator/pkg/mapper" @@ -131,10 +132,18 @@ func getConfig() (config.Config, error) { cfg.ReservedPrefixConfig[c.BackendMode] = c } } + if featureGateString := viper.GetString("feature-gates"); featureGateString != "" { + for _, fg := range strings.Split(featureGateString, ",") { + if strings.Contains(fg, string(config.SSORoleMatch)) && + strings.Contains(fg, "true") { + logrus.Info("SSORoleMatch feature enabled") + config.SSORoleMatchEnabled = true + } + } + } if featureGates.Enabled(config.ConfiguredInitDirectories) { logrus.Info("ConfiguredInitDirectories feature enabled") } - if cfg.ClusterID == "" { return cfg, errors.New("cluster ID cannot be empty") } diff --git a/docs/sso_role_matcher.md b/docs/sso_role_matcher.md new file mode 100644 index 000000000..39c854960 --- /dev/null +++ b/docs/sso_role_matcher.md @@ -0,0 +1,62 @@ +# SSO Role Matcher + +Maps configuration for an AWS SSO managed IAM Role to a Kubernetes username and groups. + +## Feature state + +Alpha + +## Use case + +Easy and robust configuration for AWS SSO managed roles, which currently have two main issues: + +Firstly - confusing configuration. To use an SSO role, a user needs to map the Role ARN of the SSO ROle, minus the path. + +For example: given a permission set `MyPermissionSet`, region `us-east-1` and account number `000000000000`; AWS SSO +creates a role: `arn:aws:iam::000000000000:role/aws-reserved/sso.amazonaws.com/us-east-1/AWSReservedSSO_MyPermissionSet_1234567890abcde`. + +To match this role, a user would need to create a mapRoles entry like: +``` + mapRoles: | + - rolearn: arn:aws:iam::000000000000:role/AWSReservedSSO_MyPermissionSet_1234567890abcde + username: ... + groups: ... +``` + +Secondly - brittle configuration. If AWS SSO recreates IAM Roles, they receive a different random suffix and all the users of that +role can no longer authenticate to Kubernetes. + +## New UX + +Users can create a mapRoles entry that will automatically match roles created by AWS SSO without needing to be updated +every time the roles are changed. + +Users will now create mapRoles entries like: +``` + mapRoles: | + - sso: + permissionSetName: MyPermissionSet + accountID: "000000000000" + username: ... + groups: ... +``` + +If the user is using the aws-us-govt or aws-cn partitions, they must specify the partition attribute in the `sso` structure. +``` + mapRoles: | + - sso: + permissionSetName: MyPermissionSet + accountID: "000000000000" + partition: "aws-us-govt" + username: ... + groups: ... +``` + +## Implementation + +config.RoleMapping will be extended with a nested structure containing the necessary information to construct a canonicalized +Role Arn. The random suffix will not need to be specified and will instead be matched for the user by constructing the +expected ARN and applying a wildcard to the end. + +Users are protected from non-AWS SSO created roles as the AWS API prevents roles being manually created with AWSReservedSSO +at the beginning of their names. diff --git a/pkg/arn/arnlike.go b/pkg/arn/arnlike.go new file mode 100644 index 000000000..c9e46cb52 --- /dev/null +++ b/pkg/arn/arnlike.go @@ -0,0 +1,104 @@ +package arn + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + arnDelimiter = ":" + arnSectionsExpected = 6 + arnPrefix = "arn:" + + // zero-indexed + sectionPartition = 1 + sectionService = 2 + sectionRegion = 3 + sectionAccountID = 4 + sectionResource = 5 + + // errors + invalidPrefix = "invalid prefix" + invalidSections = "not enough sections" +) + +// ArnLike takes an ARN and returns true if it is matched by the pattern. +// Each component of the ARN is matched individually as per +// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition_operators.html#Conditions_ARN +func ArnLike(arn, pattern string) (bool, error) { + // "parse" the input arn into sections + arnSections, err := parse(arn) + if err != nil { + return false, fmt.Errorf("Could not parse input arn: %v", err) + } + patternSections, err := parse(pattern) + if err != nil { + return false, fmt.Errorf("Could not parse ArnLike string: %v", err) + } + + // Tidy regexp special characters. Escape the ones not used in ArnLike. + // Replace multiple * with .* - we're assuming `\` is not allowed in ARNs + preparePatternSections(patternSections) + + for index := range arnSections { + patternGlob, err := regexp.Compile(patternSections[index]) + if err != nil { + return false, fmt.Errorf("Could not parse %s: %v", patternSections[index], err) + } + + if !patternGlob.MatchString(arnSections[index]) { + return false, nil + } + } + + return true, nil +} + +// parse is a copy of arn.Parse from the AWS SDK but represents the ARN as []string +func parse(input string) ([]string, error) { + if !strings.HasPrefix(input, arnPrefix) { + return nil, fmt.Errorf(invalidPrefix) + } + arnSections := strings.SplitN(input, arnDelimiter, arnSectionsExpected) + if len(arnSections) != arnSectionsExpected { + return nil, fmt.Errorf(invalidSections) + } + + return arnSections, nil +} + +// preparePatternSections goes through each section of the arnLike slice and escapes any meta characters, except for +// `*` and `?` which are replaced by `.*` and `.?` respectively. ^ and $ are added as we require an exact match +func preparePatternSections(arnLikeSlice []string) { + for index, section := range arnLikeSlice { + quotedString := quoteMeta(section) + arnLikeSlice[index] = `^` + quotedString + `$` + } +} + +// the below is based on regexp.QuoteMeta to escape metacharacters except for `?` and `*`, changing them to `*` and `.*` + +// quoteMeta returns a string that escapes all regular expression metacharacters +// inside the argument text; the returned string is a regular expression matching +// the literal text. +func quoteMeta(s string) string { + const specialChars = `\.+()|[]{}^$` + + var i int + b := make([]byte, 2*len(s)-i) + copy(b, s[:i]) + j := i + for ; i < len(s); i++ { + if strings.Contains(specialChars, s[i:i+1]) { + b[j] = '\\' + j++ + } else if s[i] == '*' || s[i] == '?' { + b[j] = '.' + j++ + } + b[j] = s[i] + j++ + } + return string(b[:j]) +} diff --git a/pkg/arn/arnlike_test.go b/pkg/arn/arnlike_test.go new file mode 100644 index 000000000..0b4cfc8b1 --- /dev/null +++ b/pkg/arn/arnlike_test.go @@ -0,0 +1,168 @@ +package arn + +import ( + "strings" + "testing" +) + +type arnLikeInput struct { + arn, pattern string +} + +type quoteMetaInput struct { + input, expected string +} + +func TestArnLikePostiveMatches(t *testing.T) { + inputs := []arnLikeInput{ + { + arn: `arn:aws:iam::000000000000:role/some-role`, + pattern: `arn:aws:iam::000000000000:role/some-role`, + }, + { + arn: `arn:aws:iam::000000000000:role/some-role`, + pattern: `arn:aws:iam::000000000000:*`, + }, + { + arn: `arn:aws:iam::000000000000:role/some-role`, + pattern: `arn:*:*:*:*:*`, + }, + { + arn: `arn:aws:iam::000000000000:role/some-role`, + pattern: `arn:aws:iam::000000000000:**`, + }, + { + arn: `arn:aws:iam::000000000000:role/some-role`, + pattern: `arn:aws:iam::000000000000:*role*`, + }, + { + arn: `arn:aws:iam::000000000000:role/some-role`, + pattern: `arn:aws:iam::000000000000:ro*`, + }, + { + arn: `arn:aws:iam::000000000000:role/some-role`, + pattern: `arn:aws:iam::000000000000:??????????????`, + }, + { + arn: `arn:aws:testservice::000000000000:some/wacky-new-[resource]{with}\metacharacters`, + pattern: `arn:aws:testservice::000000000000:some/wacky-new-[resource]{with}\metacharacters`, + }, + { + arn: `arn:aws:testservice::000000000000:some/wacky-new-[resource]{with}\metacharacters`, + pattern: `arn:aws:testservice::000000000000:some/wacky-new-[reso*`, + }, + } + + for _, v := range inputs { + ok, err := ArnLike(v.arn, v.pattern) + if err != nil { + t.Errorf("Expected no error for input arn: %s pattern: %s", v.arn, v.pattern) + } + + if !ok { + t.Errorf("Expected true for input arn: %s pattern: %s", v.arn, v.pattern) + } + } +} + +func TestArnLikeNetagiveMatches(t *testing.T) { + inputs := []arnLikeInput{ + { + arn: `arn:aws:iam::111111111111:role/some-role`, + pattern: `arn:aws:iam::000000000000:role/some-role`, + }, + { + arn: `arn:aws:testservice::000000000000:some/wacky:resource:with:colon:delims`, + pattern: `arn:aws:testservice::**:delims`, + }, + } + + for _, v := range inputs { + ok, err := ArnLike(v.arn, v.pattern) + if err != nil { + t.Errorf("Expected no error for input arn: %s pattern: %s", v.arn, v.pattern) + } + + if ok { + t.Errorf("Expected false for input arn: %s pattern: %s", v.arn, v.pattern) + } + } +} + +func TestIncompleteArnLikePattern(t *testing.T) { + incompleteArnLikePattern := "arn:*" + validArn := `arn:aws:iam::000000000000:role/some-role` + + ok, err := ArnLike(validArn, incompleteArnLikePattern) + if ok { + t.Errorf("Expected false result on error for input arn: %s, pattern: %s", incompleteArnLikePattern, validArn) + } + expectedErrorText := "Could not parse ArnLike string: not enough sections" + if !strings.EqualFold(expectedErrorText, err.Error()) { + t.Errorf("Did not receive expected error text. Expected: '%s', got: '%s'", expectedErrorText, err.Error()) + } +} + +func TestArnLikeInvalidArns(t *testing.T) { + invalidPrefixArn := `nar:aws:iam::000000000000:role/some-role` + invalidSectionsArn := `arn:aws:iam:000000000000:role/some-role` + validArn := `arn:aws:iam::000000000000:role/some-role` + + // invalid prefix + ok, err := ArnLike(invalidPrefixArn, validArn) + if ok { + t.Errorf("Expected false result on error for input arn: %s, pattern: %s", invalidPrefixArn, validArn) + } + + expectedErrorText := "Could not parse input arn: invalid prefix" + if !strings.EqualFold(expectedErrorText, err.Error()) { + t.Errorf("Did not receive expected error text. Expected: '%s', got: '%s'", expectedErrorText, err.Error()) + } + + // invalid sections + ok, err = ArnLike(invalidSectionsArn, validArn) + if ok { + t.Errorf("Expected false result on error for input arn: %s, pattern: %s", invalidSectionsArn, validArn) + } + + expectedErrorText = "Could not parse input arn: not enough sections" + if !strings.EqualFold(expectedErrorText, err.Error()) { + t.Errorf("Did not receive expected error text. Expected: '%s', got: '%s'", expectedErrorText, err.Error()) + } +} + +func TestQuoteMeta(t *testing.T) { + inputs := []quoteMetaInput{ + { + input: `**`, + expected: `.*.*`, + }, + { + input: `??`, + expected: `.?.?`, + }, + { + input: `abdcEFG`, + expected: `abdcEFG`, + }, + { + input: `abd.EFG`, + expected: `abd\.EFG`, + }, + { + input: `\.+()|[]{}^$`, + expected: `\\\.\+\(\)\|\[\]\{\}\^\$`, + }, + { + input: `\.+()|[]{}^$*?`, + expected: `\\\.\+\(\)\|\[\]\{\}\^\$.*.?`, + }, + } + + for _, v := range inputs { + output := quoteMeta(v.input) + if !strings.EqualFold(v.expected, output) { + t.Errorf("Did not get expected output from quoteMeta. Expected: '%s', got: '%s'", v.expected, output) + } + } +} diff --git a/pkg/config/features.go b/pkg/config/features.go index be559e070..ebedaf0c1 100644 --- a/pkg/config/features.go +++ b/pkg/config/features.go @@ -26,9 +26,15 @@ const ( ConfiguredInitDirectories featuregate.Feature = "ConfiguredInitDirectories" // IAMIdentityMappingCRD enables using CRDs to manage allowed users IAMIdentityMappingCRD featuregate.Feature = "IAMIdentityMappingCRD" + // SSORoleMatch enables matching roles managed by AWS SSO, with handling + // for their randomly generated suffixes + SSORoleMatch featuregate.Feature = "SSORoleMatch" ) +var SSORoleMatchEnabled bool + var DefaultFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ ConfiguredInitDirectories: {Default: false, PreRelease: featuregate.Alpha}, IAMIdentityMappingCRD: {Default: false, PreRelease: featuregate.Alpha}, + SSORoleMatch: {Default: false, PreRelease: featuregate.Alpha}, } diff --git a/pkg/config/mapper.go b/pkg/config/mapper.go index 9b9c0940c..4ace3e647 100644 --- a/pkg/config/mapper.go +++ b/pkg/config/mapper.go @@ -1,11 +1,106 @@ package config import ( + "fmt" + "regexp" "strings" + "sigs.k8s.io/aws-iam-authenticator/pkg/arn" "sigs.k8s.io/aws-iam-authenticator/pkg/token" + + "github.com/sirupsen/logrus" ) +// SSOArnLike returns a string that can be passed to arnlike.ArnLike to +// match canonicalized IAM Role ARNs against. Assumes Validate() has been called. +func (m *RoleMapping) SSOArnLike() string { + if m.SSO == nil { + return "" + } + + var partition string + if m.SSO.Partition == "" { + partition = "aws" + } + + return strings.ToLower(fmt.Sprintf("arn:%s:iam::%s:role/AWSReservedSSO_%s_*", partition, m.SSO.AccountID, m.SSO.PermissionSetName)) +} + +// Validate returns an error if the RoleMapping is not valid after being unmarshaled +func (m *RoleMapping) Validate() error { + if m == nil { + return fmt.Errorf("RoleMapping is nil") + } + + if m.RoleARN == "" && m.SSO == nil { + return fmt.Errorf("One of rolearn or SSO must be supplied") + } else if m.RoleARN != "" && m.SSO != nil { + return fmt.Errorf("Only one of rolearn or SSO can be supplied") + } + + if m.SSO != nil { + accountIDRegexp := regexp.MustCompile("^[0-9]{12}$") + if !accountIDRegexp.MatchString(m.SSO.AccountID) { + return fmt.Errorf("AccountID '%s' is not a valid AWS Account ID", m.SSO.AccountID) + } + + // https://docs.aws.amazon.com/singlesignon/latest/APIReference/API_PermissionSet.html + permissionSetNameRegexp := regexp.MustCompile(`^[\w+=,.@-]{1,32}$`) + if !permissionSetNameRegexp.MatchString(m.SSO.PermissionSetName) { + return fmt.Errorf("PermissionSetName '%s' is not a valid AWS SSO PermissionSet Name", m.SSO.PermissionSetName) + } + + switch m.SSO.Partition { + case "aws", "aws-cn", "aws-us-gov", "aws-iso", "aws-iso-b": + // valid + case "": + // treated as "aws" + default: + return fmt.Errorf("Partition '%s' is not a valid AWS partition", m.SSO.Partition) + } + + ssoArnLikeString := m.SSOArnLike() + ok, err := arn.ArnLike(ssoArnLikeString, "arn:*:iam:*:*:role/*") + if err != nil { + return fmt.Errorf("SSOArnLike '%s' is not valid: %v", ssoArnLikeString, err) + } else if !ok { + return fmt.Errorf("SSOArnLike '%s' did not match an ARN for a canonicalized IAM Role", ssoArnLikeString) + } + } + + return nil +} + +// Matches returns true if the supplied ARN or SSO settings matches +// this RoleMapping +func (m *RoleMapping) Matches(subject string) bool { + if m.RoleARN != "" { + return strings.ToLower(m.RoleARN) == strings.ToLower(subject) + } + + // Assume the caller has called Validate(), which parses m.RoleARNLike + // If subject is not parsable, then it cannot be a valid ARN anyway so + // we can ignore the error here + var ok bool + if SSORoleMatchEnabled { + var err error + ok, err = arn.ArnLike(subject, m.SSOArnLike()) + if err != nil { + logrus.Error("Could not parse subject ARN: ", err) + } + } + return ok +} + +// Key returns RoleARN or SSOArnLike(), whichever is not empty. +// Used to get a Key name for map[string]RoleMapping +func (m *RoleMapping) Key() string { + if m.RoleARN != "" { + return strings.ToLower(m.RoleARN) + } + return m.SSOArnLike() +} + // IdentityMapping converts the RoleMapping into a generic IdentityMapping object func (m *RoleMapping) IdentityMapping(identity *token.Identity) *IdentityMapping { return &IdentityMapping{ @@ -15,6 +110,30 @@ func (m *RoleMapping) IdentityMapping(identity *token.Identity) *IdentityMapping } } +// Validate returns an error if the UserMapping is not valid after being unmarshaled +func (m *UserMapping) Validate() error { + if m == nil { + return fmt.Errorf("UserMapping is nil") + } + + if m.UserARN == "" { + return fmt.Errorf("Value for userarn must be supplied") + } + + return nil +} + +// Matches returns true if the supplied ARN string matche this UserMapping +func (m *UserMapping) Matches(subject string) bool { + return strings.ToLower(m.UserARN) == strings.ToLower(subject) +} + +// Key returns UserARN. +// Used to get a Key name for map[string]UserMapping +func (m *UserMapping) Key() string { + return m.UserARN +} + // IdentityMapping converts the UserMapping into a generic IdentityMapping object func (m *UserMapping) IdentityMapping(identity *token.Identity) *IdentityMapping { return &IdentityMapping{ @@ -22,4 +141,4 @@ func (m *UserMapping) IdentityMapping(identity *token.Identity) *IdentityMapping Username: m.Username, Groups: m.Groups, } -} +} \ No newline at end of file diff --git a/pkg/config/mapper_test.go b/pkg/config/mapper_test.go new file mode 100644 index 000000000..2994e2fa3 --- /dev/null +++ b/pkg/config/mapper_test.go @@ -0,0 +1,159 @@ +package config + +import ( + "reflect" + "testing" +) + +func init() { + SSORoleMatchEnabled = true +} + +func TestSSORoleMapping(t *testing.T) { + rm := RoleMapping{ + SSO: &SSOARNMatcher{ + PermissionSetName: "ViewOnlyAccess", + AccountID: "012345678912", + }, + Username: "admin", + Groups: []string{"system:masters"}, + } + + expectedKey := "arn:aws:iam::012345678912:role/awsreservedsso_viewonlyaccess_*" + actualKey := rm.Key() + + if !reflect.DeepEqual(actualKey, expectedKey) { + t.Errorf("RoleMapping.Key() does not match expected value.\nActual: %v\nExpected: %v", actualKey, expectedKey) + } + + expectedMatch := "arn:aws:iam::012345678912:role/awsreservedsso_viewonlyaccess_abcdefg" + matches := rm.Matches(expectedMatch) + if !matches { + t.Errorf("RoleMapping %v did not match %s", rm, expectedMatch) + } + + unexpectedMatch := "arn:aws:iam::012345678912:role/awsreservedsso_billing_hijklmn" + matches = rm.Matches(unexpectedMatch) + if matches { + t.Errorf("RoleMapping %v unexpectedly matched %s", rm, unexpectedMatch) + } + + err := rm.Validate() + if err != nil { + t.Errorf("Received error %v validating RoleMapping %v", err, rm) + } + + invalidRoleMappings := []RoleMapping{ + { + RoleARN: "", + SSO: &SSOARNMatcher{ + Partition: "aws-nk", // invalid + AccountID: "012345678912", + PermissionSetName: "ViewOnlyAccess", + }, + }, + { + RoleARN: "", + SSO: &SSOARNMatcher{ + Partition: "aws", + AccountID: "0123456789", // too short + PermissionSetName: "ViewOnlyAccess", + }, + }, + { + RoleARN: "", + SSO: &SSOARNMatcher{ + Partition: "aws", + AccountID: "012345678912", + PermissionSetName: "ViewOnlyAccess*", // contains disallowed chars + }, + }, + } + for _, invalidRoleMapping := range invalidRoleMappings { + err = invalidRoleMapping.Validate() + if err == nil { + t.Errorf("Invalid RoleMapping %+v with SSO %+v did not raise error when validated", invalidRoleMapping, invalidRoleMapping.SSO) + } + } +} + +func TestRoleARNMapping(t *testing.T) { + rm := RoleMapping{ + RoleARN: "arn:aws:iam::012345678912:role/KubeAdmin", + Username: "admin", + Groups: []string{"system:masters"}, + } + + expectedKey := "arn:aws:iam::012345678912:role/kubeadmin" + actualKey := rm.Key() + + if !reflect.DeepEqual(actualKey, expectedKey) { + t.Errorf("RoleMapping.Key() does not match expected value.\nActual: %v\nExpected: %v", actualKey, expectedKey) + } + + expectedMatch := "arn:aws:iam::012345678912:role/KubeAdmin" + matches := rm.Matches(expectedMatch) + if !matches { + t.Errorf("RoleMapping %v did not match %s", rm, expectedMatch) + } + + unexpectedMatch := "arn:aws:iam::012345678912:role/notKubeAdmin" + matches = rm.Matches(unexpectedMatch) + if matches { + t.Errorf("RoleMapping %v unexpectedly matched %s", rm, unexpectedMatch) + } + + err := rm.Validate() + if err != nil { + t.Errorf("Received error %v validating RoleMapping %v", err, rm) + } + + invalidRoleMapping := RoleMapping{ + RoleARN: "", + SSO: nil, + } + err = invalidRoleMapping.Validate() + if err == nil { + t.Errorf("Invalid RoleMapping %v did not raise error when validated", invalidRoleMapping) + } +} + +func TestUserARNMapping(t *testing.T) { + um := UserMapping{ + UserARN: "arn:aws:iam::012345678912:user/Shanice", + Username: "Shanice", + Groups: []string{"system:masters"}, + } + + expectedKey := "arn:aws:iam::012345678912:user/Shanice" + actualKey := um.Key() + + if !reflect.DeepEqual(actualKey, expectedKey) { + t.Errorf("UserMapping.Key() does not match expected value.\nActual: %v\nExpected: %v", actualKey, expectedKey) + } + + expectedMatch := "arn:aws:iam::012345678912:user/shanice" + matches := um.Matches(expectedMatch) + if !matches { + t.Errorf("UserMapping %v did not match %s", um, expectedMatch) + } + + unexpectedMatch := "arn:aws:iam::012345678912:user/notShanice" + matches = um.Matches(unexpectedMatch) + if matches { + t.Errorf("UserMapping %v unexpectedly matched %s", um, unexpectedMatch) + } + + err := um.Validate() + if err != nil { + t.Errorf("Received error %v validating UserMapping %v", err, um) + } + + invalidUserMapping := UserMapping{ + UserARN: "", + } + err = invalidUserMapping.Validate() + if err == nil { + t.Errorf("Invalid UserMapping %v did not raise error when validated", invalidUserMapping) + } +} diff --git a/pkg/config/types.go b/pkg/config/types.go index 5d2a9818e..f470f5073 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -44,11 +44,15 @@ type IdentityMapping struct { // You can use plain values without parameters to have a more static mapping. type RoleMapping struct { // RoleARN is the AWS Resource Name of the role. (e.g., "arn:aws:iam::000000000000:role/Foo"). - RoleARN string `json:"rolearn"` + RoleARN string `json:"rolearn,omitempty" yaml:"rolearn,omitempty"` + + // SSO contains fields used to match Role ARNs that + // are generated for AWS SSO sessions. + SSO *SSOARNMatcher `json:"sso,omitempty" yaml:"sso,omitempty"` // Username is the username pattern that this instances assuming this // role will have in Kubernetes. - Username string `json:"username"` + Username string `json:"username" yaml:"username"` // Groups is a list of Kubernetes groups this role will authenticate // as (e.g., `system:masters`). Each group name can include placeholders. @@ -62,10 +66,10 @@ type RoleMapping struct { // Kubernetes username and a list of Kubernetes groups type UserMapping struct { // UserARN is the AWS Resource Name of the user. (e.g., "arn:aws:iam::000000000000:user/Test"). - UserARN string `json:"userarn"` + UserARN string `json:"userarn" yaml:"userarn"` // Username is the Kubernetes username this role will authenticate as (e.g., `mycorp:foo`) - Username string `json:"username"` + Username string `json:"username" yaml:"username"` // Groups is a list of Kubernetes groups this role will authenticate as (e.g., `system:masters`) Groups []string `json:"groups" yaml:"groups"` @@ -74,6 +78,29 @@ type UserMapping struct { 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 type Config struct { // PartitionID is the AWS partition tokens are valid in. See diff --git a/pkg/mapper/configmap/client/client.go b/pkg/mapper/configmap/client/client.go index 7cf1a90da..8ecb851e4 100644 --- a/pkg/mapper/configmap/client/client.go +++ b/pkg/mapper/configmap/client/client.go @@ -78,18 +78,27 @@ func (cli *client) add(role *config.RoleMapping, user *config.UserMapping) (cm * } if role != nil { + err = role.Validate() + if err != nil { + return fmt.Errorf("role is invalid: %v", err) + } + for _, r := range roleMappings { - if r.RoleARN == role.RoleARN { - return fmt.Errorf("cannot add duplicate role ARN %q", role.RoleARN) + if r.Key() == role.Key() { + return fmt.Errorf("cannot add duplicate role ARN %q", role.Key()) } } roleMappings = append(roleMappings, *role) } if user != nil { + err = user.Validate() + if err != nil { + return fmt.Errorf("user is invalid: %v", err) + } for _, r := range userMappings { - if r.UserARN == user.UserARN { - return fmt.Errorf("cannot add duplicate user ARN %q", user.UserARN) + if r.Key() == user.Key() { + return fmt.Errorf("cannot add duplicate user ARN %q", user.Key()) } } userMappings = append(userMappings, *user) diff --git a/pkg/mapper/configmap/client/client_test.go b/pkg/mapper/configmap/client/client_test.go index 09e35cba3..b633e4000 100644 --- a/pkg/mapper/configmap/client/client_test.go +++ b/pkg/mapper/configmap/client/client_test.go @@ -62,6 +62,41 @@ func TestAddRole(t *testing.T) { if _, err := cli.AddUser(&config.UserMapping{UserARN: "a"}); err == nil || !strings.Contains(err.Error(), `cannot add duplicate user ARN`) { t.Fatal(err) } + + cli = makeTestClient(t, + nil, + nil, + nil, + ) + newSSORole := config.RoleMapping{ + RoleARN: "", + SSO: &config.SSOARNMatcher{ + PermissionSetName: "ViewOnlyAccess", + AccountID: "012345678912", + }, + Username: "b", + Groups: []string{"b"}} + cm, err = cli.AddRole(&newSSORole) + if err != nil { + t.Fatal(err) + } + _, srm, _, err := configmap.ParseMap(cm.Data) + if err != nil { + t.Fatal(err) + } + updatedRole = srm[0] + if !reflect.DeepEqual(newSSORole, updatedRole) { + t.Fatalf("unexpected updated role %+v", updatedRole) + } + + cli = makeTestClient(t, + nil, + []config.RoleMapping{newSSORole}, + nil, + ) + if _, err := cli.AddRole(&newSSORole); err == nil || !strings.Contains(err.Error(), `cannot add duplicate role ARN`) { + t.Fatal(err) + } } func makeTestClient( diff --git a/pkg/mapper/configmap/configmap.go b/pkg/mapper/configmap/configmap.go index 783fe9b8b..0460b8695 100644 --- a/pkg/mapper/configmap/configmap.go +++ b/pkg/mapper/configmap/configmap.go @@ -117,6 +117,7 @@ func (err ErrParsingMap) Error() string { func ParseMap(m map[string]string) (userMappings []config.UserMapping, roleMappings []config.RoleMapping, awsAccounts []string, err error) { errs := make([]error, 0) + rawUserMappings := make([]config.UserMapping, 0) userMappings = make([]config.UserMapping, 0) if userData, ok := m["mapUsers"]; ok { if !isSkippable(userData) { @@ -124,14 +125,24 @@ func ParseMap(m map[string]string) (userMappings []config.UserMapping, roleMappi if err != nil { errs = append(errs, err) } else { - err = json.Unmarshal(userJson, &userMappings) + err = json.Unmarshal(userJson, &rawUserMappings) if err != nil { errs = append(errs, err) } + + for _, userMapping := range rawUserMappings { + err = userMapping.Validate() + if err != nil { + errs = append(errs, err) + } else { + userMappings = append(userMappings, userMapping) + } + } } } } + rawRoleMappings := make([]config.RoleMapping, 0) roleMappings = make([]config.RoleMapping, 0) if roleData, ok := m["mapRoles"]; ok { if !isSkippable(roleData) { @@ -139,10 +150,19 @@ func ParseMap(m map[string]string) (userMappings []config.UserMapping, roleMappi if err != nil { errs = append(errs, err) } else { - err = json.Unmarshal(roleJson, &roleMappings) + err = json.Unmarshal(roleJson, &rawRoleMappings) if err != nil { errs = append(errs, err) } + + for _, roleMapping := range rawRoleMappings { + err = roleMapping.Validate() + if err != nil { + errs = append(errs, err) + } else { + roleMappings = append(roleMappings, roleMapping) + } + } } } } @@ -199,7 +219,11 @@ func EncodeMap(userMappings []config.UserMapping, roleMappings []config.RoleMapp return m, nil } -func (ms *MapStore) saveMap(userMappings []config.UserMapping, roleMappings []config.RoleMapping, awsAccounts []string) { +func (ms *MapStore) saveMap( + userMappings []config.UserMapping, + roleMappings []config.RoleMapping, + awsAccounts []string) { + ms.mutex.Lock() defer ms.mutex.Unlock() ms.users = make(map[string]config.UserMapping) @@ -207,10 +231,10 @@ func (ms *MapStore) saveMap(userMappings []config.UserMapping, roleMappings []co ms.awsAccounts = make(map[string]interface{}) for _, user := range userMappings { - ms.users[strings.ToLower(user.UserARN)] = user + ms.users[user.Key()] = user } for _, role := range roleMappings { - ms.roles[strings.ToLower(role.RoleARN)] = role + ms.roles[role.Key()] = role } for _, awsAccount := range awsAccounts { ms.awsAccounts[awsAccount] = nil @@ -226,21 +250,23 @@ var RoleNotFound = errors.New("Role not found in configmap") func (ms *MapStore) UserMapping(arn string) (config.UserMapping, error) { ms.mutex.RLock() defer ms.mutex.RUnlock() - if user, ok := ms.users[arn]; !ok { - return config.UserMapping{}, UserNotFound - } else { - return user, nil + for _, user := range ms.users { + if user.Matches(arn) { + return user, nil + } } + return config.UserMapping{}, UserNotFound } func (ms *MapStore) RoleMapping(arn string) (config.RoleMapping, error) { ms.mutex.RLock() defer ms.mutex.RUnlock() - if role, ok := ms.roles[arn]; !ok { - return config.RoleMapping{}, RoleNotFound - } else { - return role, nil + for _, role := range ms.roles { + if role.Matches(arn) { + return role, nil + } } + return config.RoleMapping{}, RoleNotFound } func (ms *MapStore) AWSAccount(id string) bool { diff --git a/pkg/mapper/configmap/configmap_test.go b/pkg/mapper/configmap/configmap_test.go index 417db7448..9706907a3 100644 --- a/pkg/mapper/configmap/configmap_test.go +++ b/pkg/mapper/configmap/configmap_test.go @@ -17,8 +17,22 @@ import ( "sigs.k8s.io/aws-iam-authenticator/pkg/config" ) -var testUser = config.UserMapping{Username: "matlan", Groups: []string{"system:master", "dev"}} -var testRole = config.RoleMapping{Username: "computer", Groups: []string{"system:nodes"}} +func init() { + config.SSORoleMatchEnabled = true +} + +var ( + testUser = config.UserMapping{UserARN: "arn:aws:iam::012345678912:user/matt", Username: "matlan", Groups: []string{"system:master", "dev"}} + testRole = config.RoleMapping{RoleARN: "arn:aws:iam::012345678912:role/computer", Username: "computer", Groups: []string{"system:nodes"}} + testSSORole = config.RoleMapping{ + SSO: &config.SSOARNMatcher{ + PermissionSetName: "ViewOnlyAccess", + AccountID: "012345678912", + }, + Username: "television", + Groups: []string{"system:nodes"}, + } +) func makeStore() MapStore { ms := MapStore{ @@ -26,8 +40,9 @@ func makeStore() MapStore { roles: make(map[string]config.RoleMapping), awsAccounts: make(map[string]interface{}), } - ms.users["matt"] = testUser - ms.roles["instance"] = testRole + ms.users["arn:aws:iam::012345678912:user/matt"] = testUser + ms.roles["arn:aws:iam::012345678912:role/awsreservedsso_viewonlyaccess_*"] = testSSORole + ms.roles["arn:aws:iam::012345678912:role/comp*"] = testRole ms.awsAccounts["123"] = nil return ms } @@ -46,7 +61,7 @@ func makeStoreWClient() (MapStore, *fakeConfigMaps) { func TestUserMapping(t *testing.T) { ms := makeStore() - user, err := ms.UserMapping("matt") + user, err := ms.UserMapping("arn:aws:iam::012345678912:user/matt") if err != nil { t.Errorf("Could not find user 'matt' in map") } @@ -65,7 +80,7 @@ func TestUserMapping(t *testing.T) { func TestRoleMapping(t *testing.T) { ms := makeStore() - role, err := ms.RoleMapping("instance") + role, err := ms.RoleMapping("arn:aws:iam::012345678912:role/computer") if err != nil { t.Errorf("Could not find user 'instance in map") } @@ -82,6 +97,17 @@ func TestRoleMapping(t *testing.T) { } } +func TestSSORoleMapping(t *testing.T) { + ms := makeStore() + role, err := ms.RoleMapping("arn:aws:iam::012345678912:role/awsreservedsso_viewonlyaccess_123123123") + if err != nil { + t.Errorf("Could not find a match for role arn 'arn:aws:iam::012345678912:role/awsreservedsso_viewonlyaccess_123123123' in map") + } + if !reflect.DeepEqual(role, testSSORole) { + t.Errorf("Role arn 'arn:aws:iam::012345678912:role/awsreservedsso_viewonlyaccess_123123123' does not match expected value. (Acutal: %+v, Expected: %+v", role, testSSORole) + } +} + func TestAWSAccount(t *testing.T) { ms := makeStore() if !ms.AWSAccount("123") { @@ -102,7 +128,7 @@ var userMapping = ` - groups: - "system:master" - userarn: "arn:iam:NIC" + userarn: "arn:aws:iam::012345678912:user/NIC" username: nic ` @@ -118,7 +144,7 @@ var updatedUserMapping = ` groups: - "system:master" - "test" - userarn: "arn:iam:NIC" + userarn: "arn:aws:iam::012345678912:user/NIC" username: nic - userarn: "arn:iam:beswar" username: beswar @@ -162,7 +188,7 @@ func TestLoadConfigMap(t *testing.T) { ms.startLoadConfigMap(stopCh) defer close(stopCh) - time.Sleep(2 * time.Millisecond) + time.Sleep(2 * time.Second) meta := metav1.ObjectMeta{Name: "aws-auth"} data := make(map[string]string) @@ -172,7 +198,7 @@ func TestLoadConfigMap(t *testing.T) { watcher.Add(&v1.ConfigMap{ObjectMeta: meta, Data: data}) - time.Sleep(2 * time.Millisecond) + time.Sleep(2 * time.Second) if !ms.AWSAccount("123") { t.Errorf("AWS Account '123' not in allowed accounts") @@ -183,12 +209,12 @@ func TestLoadConfigMap(t *testing.T) { } expectedUser := config.UserMapping{ - UserARN: "arn:iam:NIC", + UserARN: "arn:aws:iam::012345678912:user/NIC", Username: "nic", Groups: []string{"system:master"}, } - user, err := ms.UserMapping("arn:iam:nic") + user, err := ms.UserMapping("arn:aws:iam::012345678912:user/NIC") if err != nil { t.Errorf("Expected to find user 'nic' but got error: %v", err) } @@ -214,7 +240,7 @@ func TestLoadConfigMap(t *testing.T) { } expectedUser.Groups = append(expectedUser.Groups, "test") - user, err = ms.UserMapping("arn:iam:nic") + user, err = ms.UserMapping("arn:aws:iam::012345678912:user/NIC") if !reflect.DeepEqual(user, expectedUser) { t.Errorf("Updated returned from mapping does not match expected user. (Actual: %+v, Expected: %+v", user, expectedUser) } @@ -244,6 +270,13 @@ func TestParseMap(t *testing.T) { groups: - system:bootstrappers - system:nodes +- sso: + permissionSetName: ViewOnlyAccess + accountID: "012345678912" + partition: aws-cn + username: user1 + groups: + - system:basic-users `, "mapUsers": `- userarn: arn:aws:iam::123456789101:user/Hello username: Hello @@ -260,7 +293,20 @@ func TestParseMap(t *testing.T) { {UserARN: "arn:aws:iam::123456789101:user/World", Username: "World", Groups: []string{"system:masters"}}, } roleMappings := []config.RoleMapping{ - {RoleARN: "arn:aws:iam::123456789101:role/test-NodeInstanceRole-1VWRHZ3GKZ1T4", Username: "system:node:{{EC2PrivateDNSName}}", Groups: []string{"system:bootstrappers", "system:nodes"}}, + { + RoleARN: "arn:aws:iam::123456789101:role/test-NodeInstanceRole-1VWRHZ3GKZ1T4", + Username: "system:node:{{EC2PrivateDNSName}}", + Groups: []string{"system:bootstrappers", "system:nodes"}, + }, + { + SSO: &config.SSOARNMatcher{ + PermissionSetName: "ViewOnlyAccess", + AccountID: "012345678912", + Partition: "aws-cn", + }, + Username: "user1", + Groups: []string{"system:basic-users"}, + }, } accounts := []string{} diff --git a/pkg/mapper/file/mapper.go b/pkg/mapper/file/mapper.go index ffe64ce12..3e82c3c73 100644 --- a/pkg/mapper/file/mapper.go +++ b/pkg/mapper/file/mapper.go @@ -4,16 +4,17 @@ import ( "fmt" "strings" + "sigs.k8s.io/aws-iam-authenticator/pkg/errutil" + "sigs.k8s.io/aws-iam-authenticator/pkg/token" + "sigs.k8s.io/aws-iam-authenticator/pkg/arn" "sigs.k8s.io/aws-iam-authenticator/pkg/config" - "sigs.k8s.io/aws-iam-authenticator/pkg/errutil" "sigs.k8s.io/aws-iam-authenticator/pkg/mapper" - "sigs.k8s.io/aws-iam-authenticator/pkg/token" ) type FileMapper struct { - lowercaseRoleMap map[string]config.RoleMapping - lowercaseUserMap map[string]config.UserMapping + roleMap map[string]config.RoleMapping + userMap map[string]config.UserMapping accountMap map[string]bool usernamePrefixReserveList []string } @@ -22,24 +23,34 @@ var _ mapper.Mapper = &FileMapper{} func NewFileMapper(cfg config.Config) (*FileMapper, error) { fileMapper := &FileMapper{ - lowercaseRoleMap: make(map[string]config.RoleMapping), - lowercaseUserMap: make(map[string]config.UserMapping), - accountMap: make(map[string]bool), + roleMap: make(map[string]config.RoleMapping), + userMap: make(map[string]config.UserMapping), + accountMap: make(map[string]bool), } for _, m := range cfg.RoleMappings { - _, canonicalizedARN, err := arn.Canonicalize(strings.ToLower(m.RoleARN)) + err := m.Validate() if err != nil { - return nil, fmt.Errorf("error canonicalizing ARN: %v", err) + return nil, err } - fileMapper.lowercaseRoleMap[canonicalizedARN] = m + fileMapper.roleMap[m.Key()] = m } for _, m := range cfg.UserMappings { - _, canonicalizedARN, err := arn.Canonicalize(strings.ToLower(m.UserARN)) + err := m.Validate() if err != nil { - return nil, fmt.Errorf("error canonicalizing ARN: %v", err) + return nil, err + } + var key string + if m.UserARN != "" { + _, canonicalizedARN, err := arn.Canonicalize(m.UserARN) + if err != nil { + return nil, fmt.Errorf("error canonicalizing ARN: %v", err) + } + key = canonicalizedARN + } else { + key = m.Key() } - fileMapper.lowercaseUserMap[canonicalizedARN] = m + fileMapper.userMap[key] = m } for _, m := range cfg.AutoMappedAWSAccounts { fileMapper.accountMap[m] = true @@ -55,9 +66,9 @@ func NewFileMapperWithMaps( lowercaseUserMap map[string]config.UserMapping, accountMap map[string]bool) *FileMapper { return &FileMapper{ - lowercaseRoleMap: lowercaseRoleMap, - lowercaseUserMap: lowercaseUserMap, - accountMap: accountMap, + roleMap: lowercaseRoleMap, + userMap: lowercaseUserMap, + accountMap: accountMap, } } @@ -72,7 +83,7 @@ func (m *FileMapper) Start(_ <-chan struct{}) error { func (m *FileMapper) Map(identity *token.Identity) (*config.IdentityMapping, error) { canonicalARN := strings.ToLower(identity.CanonicalARN) - if roleMapping, exists := m.lowercaseRoleMap[canonicalARN]; exists { + if roleMapping, exists := m.roleMap[canonicalARN]; exists { return &config.IdentityMapping{ IdentityARN: canonicalARN, Username: roleMapping.Username, @@ -80,7 +91,7 @@ func (m *FileMapper) Map(identity *token.Identity) (*config.IdentityMapping, err }, nil } - if userMapping, exists := m.lowercaseUserMap[canonicalARN]; exists { + if userMapping, exists := m.userMap[canonicalARN]; exists { return &config.IdentityMapping{ IdentityARN: canonicalARN, Username: userMapping.Username, diff --git a/pkg/mapper/file/mapper_test.go b/pkg/mapper/file/mapper_test.go new file mode 100644 index 000000000..5120d1fa1 --- /dev/null +++ b/pkg/mapper/file/mapper_test.go @@ -0,0 +1,130 @@ +package file + +import ( + "reflect" + "testing" + + "sigs.k8s.io/aws-iam-authenticator/pkg/config" +) + +func init() { + config.SSORoleMatchEnabled = true +} + +func newConfig() config.Config { + return config.Config{ + RoleMappings: []config.RoleMapping{ + { + RoleARN: "arn:aws:iam::012345678910:role/test-role", + Username: "shreyas", + Groups: []string{"system:masters"}, + }, + { + SSO: &config.SSOARNMatcher{ + PermissionSetName: "CookieCutterPermissions", + AccountID: "012345678910", + }, + Username: "cookie-cutter", + Groups: []string{"system:masters"}, + }, + }, + UserMappings: []config.UserMapping{ + { + UserARN: "arn:aws:iam::012345678910:user/donald", + Username: "donald", + Groups: []string{"system:masters"}, + }, + }, + AutoMappedAWSAccounts: []string{"000000000000"}, + } +} + +func TestNewFileMapper(t *testing.T) { + cfg := newConfig() + + expected := &FileMapper{ + roleMap: map[string]config.RoleMapping{ + "arn:aws:iam::012345678910:role/test-role": { + RoleARN: "arn:aws:iam::012345678910:role/test-role", + Username: "shreyas", + Groups: []string{"system:masters"}, + }, + "arn:aws:iam::012345678910:role/awsreservedsso_cookiecutterpermissions_*": { + SSO: &config.SSOARNMatcher{ + PermissionSetName: "CookieCutterPermissions", + AccountID: "012345678910", + }, + Username: "cookie-cutter", + Groups: []string{"system:masters"}, + }, + }, + userMap: map[string]config.UserMapping{ + "arn:aws:iam::012345678910:user/donald": { + UserARN: "arn:aws:iam::012345678910:user/donald", + Username: "donald", + Groups: []string{"system:masters"}, + }, + }, + accountMap: map[string]bool{ + "000000000000": true, + }, + } + + actual, err := NewFileMapper(cfg) + if err != nil { + t.Errorf("Could not build FileMapper from test config: %v", err) + } + + if !reflect.DeepEqual(actual, expected) { + t.Errorf("FileMapper does not match expected value.\nActual: %v\nExpected: %v", actual, expected) + } +} + +func TestMap(t *testing.T) { + fm, err := NewFileMapper(newConfig()) + if err != nil { + t.Errorf("Could not build FileMapper from test config: %v", err) + } + + identityArn := "arn:aws:iam::012345678910:role/test-role" + expected := &config.IdentityMapping{ + IdentityARN: identityArn, + Username: "shreyas", + Groups: []string{"system:masters"}, + } + actual, err := fm.Map(identityArn) + if err != nil { + t.Errorf("Could not map %s: %s", identityArn, err) + } + if !reflect.DeepEqual(actual, expected) { + t.Errorf("FileMapper.Map() does not match expected value for roleMapping:\nActual: %v\nExpected: %v", actual, expected) + } + + identityArn = "arn:aws:iam::012345678910:role/awsreservedsso_cookiecutterpermissions_123123123" + expected = &config.IdentityMapping{ + IdentityARN: identityArn, + Username: "cookie-cutter", + Groups: []string{"system:masters"}, + } + actual, err = fm.Map(identityArn) + if err != nil { + t.Errorf("Could not map %s: %s", identityArn, err) + } + if !reflect.DeepEqual(actual, expected) { + t.Errorf("FileMapper.Map() does not match expected value for roleArnLikeMapping:\nActual: %v\nExpected: %v", actual, expected) + } + + identityArn = "arn:aws:iam::012345678910:user/donald" + expected = &config.IdentityMapping{ + IdentityARN: identityArn, + Username: "donald", + Groups: []string{"system:masters"}, + } + actual, err = fm.Map(identityArn) + if err != nil { + t.Errorf("Could not map %s: %s", identityArn, err) + } + if !reflect.DeepEqual(actual, expected) { + t.Errorf("FileMapper.Map() does not match expected value for userMapping:\nActual: %v\nExpected: %v", actual, expected) + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 7646b8ce8..5781336eb 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -92,11 +92,19 @@ func New(cfg config.Config, stopCh <-chan struct{}) *Server { } for _, mapping := range c.RoleMappings { - logrus.WithFields(logrus.Fields{ - "role": mapping.RoleARN, - "username": mapping.Username, - "groups": mapping.Groups, - }).Infof("mapping IAM role") + if mapping.RoleARN != "" { + logrus.WithFields(logrus.Fields{ + "role": mapping.RoleARN, + "username": mapping.Username, + "groups": mapping.Groups, + }).Infof("mapping IAM role") + } else if mapping.SSO != nil { + logrus.WithFields(logrus.Fields{ + "sso": *mapping.SSO, + "username": mapping.Username, + "groups": mapping.Groups, + }).Infof("mapping IAM role") + } } for _, mapping := range c.UserMappings { logrus.WithFields(logrus.Fields{ From 673b441223f4a01e32187c025d497b1b2e5e7e93 Mon Sep 17 00:00:00 2001 From: logandavies181 Date: Thu, 3 Nov 2022 06:50:09 +0000 Subject: [PATCH 2/7] Support un-canonicalized ARNs in filemapper --- pkg/mapper/file/mapper.go | 7 +++++++ pkg/mapper/file/mapper_test.go | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/pkg/mapper/file/mapper.go b/pkg/mapper/file/mapper.go index 3e82c3c73..34ba1c7f3 100644 --- a/pkg/mapper/file/mapper.go +++ b/pkg/mapper/file/mapper.go @@ -33,6 +33,13 @@ func NewFileMapper(cfg config.Config) (*FileMapper, error) { if err != nil { return nil, err } + if m.RoleARN != "" { + canonicalizedARN, err := arn.Canonicalize(m.RoleARN) + if err != nil { + return nil, err + } + m.RoleARN = canonicalizedARN + } fileMapper.roleMap[m.Key()] = m } for _, m := range cfg.UserMappings { diff --git a/pkg/mapper/file/mapper_test.go b/pkg/mapper/file/mapper_test.go index 5120d1fa1..d896d23f2 100644 --- a/pkg/mapper/file/mapper_test.go +++ b/pkg/mapper/file/mapper_test.go @@ -27,6 +27,12 @@ func newConfig() config.Config { Username: "cookie-cutter", Groups: []string{"system:masters"}, }, + { + // test compatibility with eks + RoleARN: "arn:aws:sts::012345678910:assumed-role/test-assumed-role/session-name", + Username: "test", + Groups: []string{"system:masters"}, + }, }, UserMappings: []config.UserMapping{ { @@ -57,6 +63,11 @@ func TestNewFileMapper(t *testing.T) { Username: "cookie-cutter", Groups: []string{"system:masters"}, }, + "arn:aws:iam::012345678910:role/test-assumed-role": { + RoleARN: "arn:aws:iam::012345678910:role/test-assumed-role", + Username: "test", + Groups: []string{"system:masters"}, + }, }, userMap: map[string]config.UserMapping{ "arn:aws:iam::012345678910:user/donald": { From b2db144f8b6eb3e9c7d1bf43e53b3cf8c1f3b841 Mon Sep 17 00:00:00 2001 From: Richard Li Date: Wed, 3 Aug 2022 00:52:19 +0000 Subject: [PATCH 3/7] simplify featuregate flag parsing for SSORoleMatch --- cmd/aws-iam-authenticator/root.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/cmd/aws-iam-authenticator/root.go b/cmd/aws-iam-authenticator/root.go index 4c8321f13..f234e61e4 100644 --- a/cmd/aws-iam-authenticator/root.go +++ b/cmd/aws-iam-authenticator/root.go @@ -20,7 +20,6 @@ import ( "errors" "fmt" "os" - "strings" "sigs.k8s.io/aws-iam-authenticator/pkg/config" "sigs.k8s.io/aws-iam-authenticator/pkg/mapper" @@ -132,18 +131,14 @@ func getConfig() (config.Config, error) { cfg.ReservedPrefixConfig[c.BackendMode] = c } } - if featureGateString := viper.GetString("feature-gates"); featureGateString != "" { - for _, fg := range strings.Split(featureGateString, ",") { - if strings.Contains(fg, string(config.SSORoleMatch)) && - strings.Contains(fg, "true") { - logrus.Info("SSORoleMatch feature enabled") - config.SSORoleMatchEnabled = true - } - } + if featureGates.Enabled(config.SSORoleMatch) { + logrus.Info("SSORoleMatch feature enabled") + config.SSORoleMatchEnabled = true } if featureGates.Enabled(config.ConfiguredInitDirectories) { logrus.Info("ConfiguredInitDirectories feature enabled") } + if cfg.ClusterID == "" { return cfg, errors.New("cluster ID cannot be empty") } From 676ccce8011465f80b62f9e49799e21b7341e522 Mon Sep 17 00:00:00 2001 From: Nicole Wren Date: Tue, 3 Dec 2024 16:43:58 -0800 Subject: [PATCH 4/7] Fix credential expirability check Fixes a bug introduced during the refactor in e92213c081fdf30686b3bc962d9ee642ce57f245, which incorrectly inverted the previous credential expirability checking logic. This caused errors about being unable to cache otherwise cachable credentials. Also changes the error log around the previous expirability check site to no longer log the error that previously came from the `ExpiresAt()` check; since this is now the wrong `err`, it's always `nil`. Addresses #776 --- pkg/filecache/converter.go | 2 +- pkg/filecache/filecache.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/filecache/converter.go b/pkg/filecache/converter.go index ec2f16bde..6d7209ea0 100644 --- a/pkg/filecache/converter.go +++ b/pkg/filecache/converter.go @@ -27,7 +27,7 @@ func (p *v2) Retrieve(ctx context.Context) (aws.Credentials, error) { // Don't have account ID } - if expiration, err := p.creds.ExpiresAt(); err != nil { + if expiration, err := p.creds.ExpiresAt(); err == nil { resp.CanExpire = true resp.Expires = expiration } diff --git a/pkg/filecache/filecache.go b/pkg/filecache/filecache.go index 64092b9f4..b4163fc74 100644 --- a/pkg/filecache/filecache.go +++ b/pkg/filecache/filecache.go @@ -251,7 +251,7 @@ func (f *FileCacheProvider) RetrieveWithContext(ctx context.Context) (credential } } else { // credential doesn't support expiration time, so can't cache, but still return the credential - _, _ = fmt.Fprintf(os.Stderr, "Unable to cache credential: %v\n", err) + _, _ = fmt.Fprint(os.Stderr, "Unable to cache credential: credential doesn't support expiration\n") err = nil } return V2CredentialToV1Value(credential), err From adb560ed3c6ad769e1745d21c12e2c7c810bf594 Mon Sep 17 00:00:00 2001 From: Nicole Wren Date: Mon, 6 Jan 2025 19:10:25 -0800 Subject: [PATCH 5/7] Remove no-op err assignment --- pkg/filecache/filecache.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/filecache/filecache.go b/pkg/filecache/filecache.go index b4163fc74..823f07853 100644 --- a/pkg/filecache/filecache.go +++ b/pkg/filecache/filecache.go @@ -252,7 +252,6 @@ func (f *FileCacheProvider) RetrieveWithContext(ctx context.Context) (credential } else { // credential doesn't support expiration time, so can't cache, but still return the credential _, _ = fmt.Fprint(os.Stderr, "Unable to cache credential: credential doesn't support expiration\n") - err = nil } return V2CredentialToV1Value(credential), err } From f55fa7a842a2755b2fef6e80ddcdec58e333b9ae Mon Sep 17 00:00:00 2001 From: Sushanth T Date: Fri, 31 Jan 2025 02:00:52 +0000 Subject: [PATCH 6/7] Cherry-picked file changes from commit https://github.com/kubernetes-sigs/aws-iam-authenticator/pull/554/commits --- pkg/mapper/file/mapper_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/mapper/file/mapper_test.go b/pkg/mapper/file/mapper_test.go index d896d23f2..b1f2138dc 100644 --- a/pkg/mapper/file/mapper_test.go +++ b/pkg/mapper/file/mapper_test.go @@ -2,6 +2,7 @@ package file import ( "reflect" + "sigs.k8s.io/aws-iam-authenticator/pkg/token" "testing" "sigs.k8s.io/aws-iam-authenticator/pkg/config" @@ -98,12 +99,15 @@ func TestMap(t *testing.T) { } identityArn := "arn:aws:iam::012345678910:role/test-role" + identity := token.Identity{ + CanonicalARN: identityArn, + } expected := &config.IdentityMapping{ IdentityARN: identityArn, Username: "shreyas", Groups: []string{"system:masters"}, } - actual, err := fm.Map(identityArn) + actual, err := fm.Map(&identity) if err != nil { t.Errorf("Could not map %s: %s", identityArn, err) } @@ -112,12 +116,15 @@ func TestMap(t *testing.T) { } identityArn = "arn:aws:iam::012345678910:role/awsreservedsso_cookiecutterpermissions_123123123" + identity = token.Identity{ + CanonicalARN: identityArn, + } expected = &config.IdentityMapping{ IdentityARN: identityArn, Username: "cookie-cutter", Groups: []string{"system:masters"}, } - actual, err = fm.Map(identityArn) + actual, err = fm.Map(&identity) if err != nil { t.Errorf("Could not map %s: %s", identityArn, err) } @@ -126,12 +133,15 @@ func TestMap(t *testing.T) { } identityArn = "arn:aws:iam::012345678910:user/donald" + identity = token.Identity{ + CanonicalARN: identityArn, + } expected = &config.IdentityMapping{ IdentityARN: identityArn, Username: "donald", Groups: []string{"system:masters"}, } - actual, err = fm.Map(identityArn) + actual, err = fm.Map(&identity) if err != nil { t.Errorf("Could not map %s: %s", identityArn, err) } From 140ea068f3b0e95c41b541e81cfea8d68eaccfe2 Mon Sep 17 00:00:00 2001 From: Sushanth T Date: Fri, 31 Jan 2025 02:07:27 +0000 Subject: [PATCH 7/7] small fixes missed during cherrypicking --- pkg/config/features.go | 4 +++- pkg/config/mapper.go | 2 +- pkg/mapper/file/mapper.go | 22 ++++++++++------------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pkg/config/features.go b/pkg/config/features.go index ebedaf0c1..52e0ab2b4 100644 --- a/pkg/config/features.go +++ b/pkg/config/features.go @@ -31,7 +31,9 @@ const ( SSORoleMatch featuregate.Feature = "SSORoleMatch" ) -var SSORoleMatchEnabled bool +var ( + SSORoleMatchEnabled bool +) var DefaultFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ ConfiguredInitDirectories: {Default: false, PreRelease: featuregate.Alpha}, diff --git a/pkg/config/mapper.go b/pkg/config/mapper.go index 4ace3e647..0c33ab1f6 100644 --- a/pkg/config/mapper.go +++ b/pkg/config/mapper.go @@ -141,4 +141,4 @@ func (m *UserMapping) IdentityMapping(identity *token.Identity) *IdentityMapping Username: m.Username, Groups: m.Groups, } -} \ No newline at end of file +} diff --git a/pkg/mapper/file/mapper.go b/pkg/mapper/file/mapper.go index 34ba1c7f3..0e0411de7 100644 --- a/pkg/mapper/file/mapper.go +++ b/pkg/mapper/file/mapper.go @@ -34,7 +34,7 @@ func NewFileMapper(cfg config.Config) (*FileMapper, error) { return nil, err } if m.RoleARN != "" { - canonicalizedARN, err := arn.Canonicalize(m.RoleARN) + _, canonicalizedARN, err := arn.Canonicalize(m.RoleARN) if err != nil { return nil, err } @@ -49,13 +49,11 @@ func NewFileMapper(cfg config.Config) (*FileMapper, error) { } var key string if m.UserARN != "" { - _, canonicalizedARN, err := arn.Canonicalize(m.UserARN) + _, canonicalizedARN, err := arn.Canonicalize(strings.ToLower(m.UserARN)) if err != nil { return nil, fmt.Errorf("error canonicalizing ARN: %v", err) } key = canonicalizedARN - } else { - key = m.Key() } fileMapper.userMap[key] = m } @@ -89,15 +87,15 @@ func (m *FileMapper) Start(_ <-chan struct{}) error { func (m *FileMapper) Map(identity *token.Identity) (*config.IdentityMapping, error) { canonicalARN := strings.ToLower(identity.CanonicalARN) - - if roleMapping, exists := m.roleMap[canonicalARN]; exists { - return &config.IdentityMapping{ - IdentityARN: canonicalARN, - Username: roleMapping.Username, - Groups: roleMapping.Groups, - }, nil + for _, roleMapping := range m.roleMap { + if roleMapping.Matches(canonicalARN) { + return &config.IdentityMapping{ + IdentityARN: canonicalARN, + Username: roleMapping.Username, + Groups: roleMapping.Groups, + }, nil + } } - if userMapping, exists := m.userMap[canonicalARN]; exists { return &config.IdentityMapping{ IdentityARN: canonicalARN,