Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SSO Role suffix support #416

Merged
merged 9 commits into from
Jun 16, 2022
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
aws-iam-authenticator

/dist
/_output

Expand Down
49 changes: 45 additions & 4 deletions cmd/aws-iam-authenticator/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,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)
}

Expand Down Expand Up @@ -73,16 +73,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,
})
Expand Down Expand Up @@ -174,6 +210,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
logandavies181 marked this conversation as resolved.
Show resolved Hide resolved
ssoRoleConfig *config.SSOARNMatcher
)

func init() {
Expand All @@ -191,6 +231,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")
}
11 changes: 11 additions & 0 deletions cmd/aws-iam-authenticator/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -69,6 +70,7 @@ func init() {

featureGates.Add(config.DefaultFeatureGates)
featureGates.AddFlag(rootCmd.PersistentFlags())
viper.BindPFlag("feature-gates", rootCmd.PersistentFlags().Lookup("feature-gates"))
}

func initConfig() {
Expand Down Expand Up @@ -110,6 +112,15 @@ func getConfig() (config.Config, error) {
if err := viper.UnmarshalKey("server.mapAccounts", &cfg.AutoMappedAWSAccounts); err != nil {
logrus.WithError(err).Fatal("invalid server account mappings")
}
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 cfg.ClusterID == "" {
return cfg, errors.New("cluster ID cannot be empty")
Expand Down
53 changes: 53 additions & 0 deletions docs/sso_role_matcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# SSO Role Matcher

Maps configuration for an AWS SSO managed IAM Role to a Kubernetes username and groups.

## Feature state

Alpha
logandavies181 marked this conversation as resolved.
Show resolved Hide resolved

## Use case

Easy and robust configuration for AWS SSO managed roles, which currently have two main issues:

- Confusing configuration. To use an SSO role, a user needs to map the Role ARN of the SSO ROle, minus the path.
logandavies181 marked this conversation as resolved.
Show resolved Hide resolved

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: ...
```

- 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.

## 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.
logandavies181 marked this conversation as resolved.
Show resolved Hide resolved

## 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
expect ARN and applying a wildcard to the end.
logandavies181 marked this conversation as resolved.
Show resolved Hide resolved

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.
104 changes: 104 additions & 0 deletions pkg/arn/arnlike.go
Original file line number Diff line number Diff line change
@@ -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
logandavies181 marked this conversation as resolved.
Show resolved Hide resolved
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])
}
Loading