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..f234e61e4 100644 --- a/cmd/aws-iam-authenticator/root.go +++ b/cmd/aws-iam-authenticator/root.go @@ -131,6 +131,10 @@ func getConfig() (config.Config, error) { cfg.ReservedPrefixConfig[c.BackendMode] = c } } + if featureGates.Enabled(config.SSORoleMatch) { + logrus.Info("SSORoleMatch feature enabled") + config.SSORoleMatchEnabled = true + } if featureGates.Enabled(config.ConfiguredInitDirectories) { logrus.Info("ConfiguredInitDirectories feature enabled") } 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..52e0ab2b4 100644 --- a/pkg/config/features.go +++ b/pkg/config/features.go @@ -26,9 +26,17 @@ 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..0c33ab1f6 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{ 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/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..823f07853 100644 --- a/pkg/filecache/filecache.go +++ b/pkg/filecache/filecache.go @@ -251,8 +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) - err = nil + _, _ = fmt.Fprint(os.Stderr, "Unable to cache credential: credential doesn't support expiration\n") } return V2CredentialToV1Value(credential), err } 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..0e0411de7 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,39 @@ 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 + } + if m.RoleARN != "" { + _, canonicalizedARN, err := arn.Canonicalize(m.RoleARN) + if err != nil { + return nil, err + } + m.RoleARN = canonicalizedARN } - 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(strings.ToLower(m.UserARN)) + if err != nil { + return nil, fmt.Errorf("error canonicalizing ARN: %v", err) + } + key = canonicalizedARN } - fileMapper.lowercaseUserMap[canonicalizedARN] = m + fileMapper.userMap[key] = m } for _, m := range cfg.AutoMappedAWSAccounts { fileMapper.accountMap[m] = true @@ -55,9 +71,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, } } @@ -71,16 +87,16 @@ 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 { - 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.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..b1f2138dc --- /dev/null +++ b/pkg/mapper/file/mapper_test.go @@ -0,0 +1,151 @@ +package file + +import ( + "reflect" + "sigs.k8s.io/aws-iam-authenticator/pkg/token" + "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"}, + }, + { + // 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{ + { + 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"}, + }, + "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": { + 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" + identity := token.Identity{ + CanonicalARN: identityArn, + } + expected := &config.IdentityMapping{ + IdentityARN: identityArn, + Username: "shreyas", + Groups: []string{"system:masters"}, + } + actual, err := fm.Map(&identity) + 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" + identity = token.Identity{ + CanonicalARN: identityArn, + } + expected = &config.IdentityMapping{ + IdentityARN: identityArn, + Username: "cookie-cutter", + Groups: []string{"system:masters"}, + } + actual, err = fm.Map(&identity) + 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" + identity = token.Identity{ + CanonicalARN: identityArn, + } + expected = &config.IdentityMapping{ + IdentityARN: identityArn, + Username: "donald", + Groups: []string{"system:masters"}, + } + actual, err = fm.Map(&identity) + 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{