From 57cc2ed3554811dbfa4f33d23c58a7850148d504 Mon Sep 17 00:00:00 2001 From: Matheus Date: Tue, 19 Apr 2022 09:59:08 -0300 Subject: [PATCH] Allow requesting a join token with IAM method from the web api (#11339) Co-authored-by: Jim Bishopp Co-authored-by: Nic Klaassen --- api/types/provisioning.go | 2 +- lib/services/provisioning.go | 2 +- lib/web/apiserver_test.go | 34 ++- lib/web/join_tokens.go | 140 +++++++++-- lib/web/join_tokens_test.go | 346 +++++++++++++++++++++++++++ lib/web/scripts/node-join/install.sh | 15 +- 6 files changed, 510 insertions(+), 29 deletions(-) diff --git a/api/types/provisioning.go b/api/types/provisioning.go index a31c1be857c..5f6549bf229 100644 --- a/api/types/provisioning.go +++ b/api/types/provisioning.go @@ -87,7 +87,7 @@ func NewProvisionTokenFromSpec(token string, expires time.Time, spec ProvisionTo } // MustCreateProvisionToken returns a new valid provision token -// or panics, used in testes +// or panics, used in tests func MustCreateProvisionToken(token string, roles SystemRoles, expires time.Time) ProvisionToken { t, err := NewProvisionToken(token, roles, expires) if err != nil { diff --git a/lib/services/provisioning.go b/lib/services/provisioning.go index 3f035ab5cab..41e94171a98 100644 --- a/lib/services/provisioning.go +++ b/lib/services/provisioning.go @@ -45,7 +45,7 @@ type Provisioner interface { } // MustCreateProvisionToken returns a new valid provision token -// or panics, used in testes +// or panics, used in tests func MustCreateProvisionToken(token string, roles types.SystemRoles, expires time.Time) types.ProvisionToken { t, err := types.NewProvisionToken(token, roles, expires) if err != nil { diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index be252d63536..085aaa4df50 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -2008,9 +2008,11 @@ func (s *WebSuite) TestGetClusterDetails(c *C) { func TestTokenGeneration(t *testing.T) { tt := []struct { - name string - roles types.SystemRoles - shouldErr bool + name string + roles types.SystemRoles + shouldErr bool + joinMethod types.JoinMethod + allow []*types.TokenRule }{ { name: "single node role", @@ -2037,6 +2039,19 @@ func TestTokenGeneration(t *testing.T) { roles: types.SystemRoles{}, shouldErr: true, }, + { + name: "cannot request token with IAM join method without allow field", + roles: types.SystemRoles{types.RoleNode}, + joinMethod: types.JoinMethodIAM, + shouldErr: true, + }, + { + name: "can request token with IAM join method", + roles: types.SystemRoles{types.RoleNode}, + joinMethod: types.JoinMethodIAM, + allow: []*types.TokenRule{{AWSAccount: "1234"}}, + shouldErr: false, + }, } for _, tc := range tt { @@ -2047,8 +2062,10 @@ func TestTokenGeneration(t *testing.T) { pack := proxy.authPack(t, "test-user@example.com") endpoint := pack.clt.Endpoint("webapi", "token") - re, err := pack.clt.PostJSON(context.Background(), endpoint, createTokenRequest{ - Roles: tc.roles, + re, err := pack.clt.PostJSON(context.Background(), endpoint, types.ProvisionTokenSpecV2{ + Roles: tc.roles, + JoinMethod: tc.joinMethod, + Allow: tc.allow, }) if tc.shouldErr { @@ -2066,6 +2083,13 @@ func TestTokenGeneration(t *testing.T) { generatedToken, err := proxy.auth.Auth().GetToken(context.Background(), responseToken.ID) require.NoError(t, err) require.Equal(t, tc.roles, generatedToken.GetRoles()) + + expectedJoinMethod := tc.joinMethod + if tc.joinMethod == "" { + expectedJoinMethod = types.JoinMethodToken + } + // if no joinMethod is provided, expect token method + require.Equal(t, expectedJoinMethod, generatedToken.GetJoinMethod()) }) } } diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go index 328133e9fa8..5c4fef9d3fa 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -21,9 +21,12 @@ import ( "context" "encoding/hex" "fmt" + "hash/fnv" "net/http" "net/url" + "reflect" "regexp" + "sort" "strconv" "strings" "time" @@ -46,6 +49,8 @@ type nodeJoinToken struct { ID string `json:"id"` // Expiry is token expiration time. Expiry time.Time `json:"expiry,omitempty"` + // Method is the join method that the token supports + Method types.JoinMethod `json:"method"` } // scriptSettings is used to hold values which are passed into the function that @@ -55,28 +60,75 @@ type scriptSettings struct { appInstallMode bool appName string appURI string -} - -// createTokenRequest is the expected request body of -// the endpoint to create token -type createTokenRequest struct { - Roles types.SystemRoles `json:"roles"` + joinMethod string } func (h *Handler) createTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { - var req createTokenRequest + var req types.ProvisionTokenSpecV2 if err := httplib.ReadJSON(r, &req); err != nil { - log.WithError(err).Error("error reading body") return nil, trace.Wrap(err) } clt, err := ctx.GetClient() if err != nil { - log.WithError(err).Error("error getting client") return nil, trace.Wrap(err) } - return createJoinToken(r.Context(), clt, req.Roles) + var expires time.Time + var tokenName string + switch req.JoinMethod { + case types.JoinMethodIAM: + // to prevent generation of redundant IAM tokens + // we generate a deterministic name for them + tokenName, err = generateIAMTokenName(req.Allow) + if err != nil { + return nil, trace.Wrap(err) + } + // if a token with this name is found and it has indeed the same rule set, + // return it. Otherwise, go ahead and create it + t, err := clt.GetToken(r.Context(), tokenName) + if err != nil && !trace.IsNotFound(err) { + return nil, trace.Wrap(err) + } + + if err == nil { + // check if the token found has the right rules + if t.GetJoinMethod() != types.JoinMethodIAM || !isSameRuleSet(req.Allow, t.GetAllowRules()) { + return nil, trace.BadParameter("failed to create token: token with name %q already exists and does not have the expected allow rules", tokenName) + } + + return &nodeJoinToken{ + ID: t.GetName(), + Expiry: *t.GetMetadata().Expires, + Method: t.GetJoinMethod(), + }, nil + } + + // IAM tokens should 'never' expire + expires = time.Now().UTC().AddDate(1000, 0, 0) + default: + tokenName, err = utils.CryptoRandomHex(auth.TokenLenBytes) + if err != nil { + return nil, trace.Wrap(err) + } + expires = time.Now().UTC().Add(defaults.NodeJoinTokenTTL) + } + + provisionToken, err := types.NewProvisionTokenFromSpec(tokenName, expires, req) + if err != nil { + return nil, trace.Wrap(err) + } + + err = clt.UpsertToken(r.Context(), provisionToken) + if err != nil { + return nil, trace.Wrap(err) + } + + return &nodeJoinToken{ + ID: tokenName, + Expiry: expires, + Method: provisionToken.GetJoinMethod(), + }, nil } func (h *Handler) createNodeTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { @@ -99,6 +151,7 @@ func (h *Handler) getNodeJoinScriptHandle(w http.ResponseWriter, r *http.Request settings := scriptSettings{ token: params.ByName("token"), appInstallMode: false, + joinMethod: r.URL.Query().Get("method"), } script, err := getJoinScript(settings, h.GetProxyClient()) @@ -176,17 +229,20 @@ func createJoinToken(ctx context.Context, m nodeAPIGetter, roles types.SystemRol } func getJoinScript(settings scriptSettings, m nodeAPIGetter) (string, error) { - // This token does not need to be validated against the backend because it's not used to - // reveal any sensitive information. However, we still need to perform a simple input - // validation check by verifying that the token was auto-generated. - // Auto-generated tokens must be encoded and must have an expected length. - decodedToken, err := hex.DecodeString(settings.token) - if err != nil { - return "", trace.Wrap(err) - } + // Skip decoding validation for IAM tokens since they are generated with a different method + if settings.joinMethod != string(types.JoinMethodIAM) { + // This token does not need to be validated against the backend because it's not used to + // reveal any sensitive information. However, we still need to perform a simple input + // validation check by verifying that the token was auto-generated. + // Auto-generated tokens must be encoded and must have an expected length. + decodedToken, err := hex.DecodeString(settings.token) + if err != nil { + return "", trace.Wrap(err) + } - if len(decodedToken) != auth.TokenLenBytes { - return "", trace.BadParameter("invalid token length") + if len(decodedToken) != auth.TokenLenBytes { + return "", trace.BadParameter("invalid token length") + } } // Get hostname and port from proxy server address. @@ -237,6 +293,7 @@ func getJoinScript(settings scriptSettings, m nodeAPIGetter) (string, error) { "appInstallMode": strconv.FormatBool(settings.appInstallMode), "appName": settings.appName, "appURI": settings.appURI, + "joinMethod": settings.joinMethod, }) if err != nil { return "", trace.Wrap(err) @@ -245,6 +302,49 @@ func getJoinScript(settings scriptSettings, m nodeAPIGetter) (string, error) { return buf.String(), nil } +// generateIAMTokenName makes a deterministic name for a iam join token +// based on its rule set +func generateIAMTokenName(rules []*types.TokenRule) (string, error) { + // sort the rules by (account ID, arn) + // to make sure a set of rules will produce the same hash, + // no matter the order they are in the slice + orderedRules := make([]*types.TokenRule, len(rules)) + copy(orderedRules, rules) + sortRules(orderedRules) + + h := fnv.New32a() + for _, r := range orderedRules { + s := fmt.Sprintf("%s%s", r.AWSAccount, r.AWSARN) + _, err := h.Write([]byte(s)) + if err != nil { + return "", trace.Wrap(err) + } + } + + return fmt.Sprintf("teleport-ui-iam-%d", h.Sum32()), nil +} + +// sortRules sorts a slice of rules based on their AWS Account ID and ARN +func sortRules(rules []*types.TokenRule) { + sort.Slice(rules, func(i, j int) bool { + iAcct, jAcct := rules[i].AWSAccount, rules[j].AWSAccount + // if accountID is the same, sort based on arn + if iAcct == jAcct { + arn1, arn2 := rules[i].AWSARN, rules[j].AWSARN + return arn1 < arn2 + } + + return iAcct < jAcct + }) +} + +// isSameRuleSet check if r1 and r2 are the same rules, ignoring the order +func isSameRuleSet(r1 []*types.TokenRule, r2 []*types.TokenRule) bool { + sortRules(r1) + sortRules(r2) + return reflect.DeepEqual(r1, r2) +} + type nodeAPIGetter interface { // GenerateToken creates a special provisioning token for a new SSH server // that is valid for ttl period seconds. diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index 24ac93f0bc4..f23f825fc71 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -46,6 +46,237 @@ func TestCreateNodeJoinToken(t *testing.T) { require.Equal(t, "some-token-id", token.ID) } +func TestGenerateIAMTokenName(t *testing.T) { + rule1 := types.TokenRule{ + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:1", + } + + rule1Name := "teleport-ui-iam-2218897454" + + // make sure the hash algorithm don't change accidentally + hash1, err := generateIAMTokenName([]*types.TokenRule{&rule1}) + require.NoError(t, err) + require.Equal(t, rule1Name, hash1) + + rule2 := types.TokenRule{ + AWSAccount: "200000000000", + AWSARN: "arn:aws:iam:b", + } + + // make sure the order doesn't matter + hash1, err = generateIAMTokenName([]*types.TokenRule{&rule1, &rule2}) + require.NoError(t, err) + + hash2, err := generateIAMTokenName([]*types.TokenRule{&rule2, &rule1}) + require.NoError(t, err) + + require.Equal(t, hash1, hash2) + + // generate different hashes for different rules + hash1, err = generateIAMTokenName([]*types.TokenRule{&rule1}) + require.NoError(t, err) + + hash2, err = generateIAMTokenName([]*types.TokenRule{&rule2}) + require.NoError(t, err) + + require.NotEqual(t, hash1, hash2) +} + +func TestSortRules(t *testing.T) { + tt := []struct { + name string + rules []*types.TokenRule + expected []*types.TokenRule + }{ + { + name: "different account ID, no ARN", + rules: []*types.TokenRule{ + {AWSAccount: "200000000000"}, + {AWSAccount: "100000000000"}, + }, + expected: []*types.TokenRule{ + {AWSAccount: "100000000000"}, + {AWSAccount: "200000000000"}, + }, + }, + { + name: "different account ID, no ARN, already ordered", + rules: []*types.TokenRule{ + {AWSAccount: "100000000000"}, + {AWSAccount: "200000000000"}, + }, + expected: []*types.TokenRule{ + {AWSAccount: "100000000000"}, + {AWSAccount: "200000000000"}, + }, + }, + { + name: "different account ID, with ARN", + rules: []*types.TokenRule{ + { + AWSAccount: "200000000000", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + }, + expected: []*types.TokenRule{ + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "200000000000", + AWSARN: "arn:aws:iam:b", + }, + }, + }, + { + name: "different account ID, with ARN, already ordered", + rules: []*types.TokenRule{ + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "200000000000", + AWSARN: "arn:aws:iam:b", + }, + }, + expected: []*types.TokenRule{ + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "200000000000", + AWSARN: "arn:aws:iam:b", + }, + }, + }, + { + name: "same account ID, different ARN, already ordered", + rules: []*types.TokenRule{ + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:a", + }, + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + }, + expected: []*types.TokenRule{ + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:a", + }, + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + }, + }, + { + name: "same account ID, different ARN", + rules: []*types.TokenRule{ + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:a", + }, + }, + expected: []*types.TokenRule{ + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:a", + }, + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + }, + }, + { + name: "multiple account ID and ARNs", + rules: []*types.TokenRule{ + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "200000000001", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "200000000000", + AWSARN: "arn:aws:iam:a", + }, + { + AWSAccount: "200000000000", + AWSARN: "arn:aws:iam:b", + }, + + { + AWSAccount: "200000000001", + AWSARN: "arn:aws:iam:z", + }, + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:a", + }, + { + AWSAccount: "300000000000", + AWSARN: "arn:aws:iam:a", + }, + }, + expected: []*types.TokenRule{ + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:a", + }, + { + AWSAccount: "100000000000", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "200000000000", + AWSARN: "arn:aws:iam:a", + }, + { + AWSAccount: "200000000000", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "200000000001", + AWSARN: "arn:aws:iam:b", + }, + { + AWSAccount: "200000000001", + AWSARN: "arn:aws:iam:z", + }, + { + AWSAccount: "300000000000", + AWSARN: "arn:aws:iam:a", + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + sortRules(tc.rules) + require.Equal(t, tc.expected, tc.rules) + }) + } +} + func TestGetNodeJoinScript(t *testing.T) { m := &mockedNodeAPIGetter{} m.mockGetProxyServers = func() ([]types.Server, error) { @@ -94,6 +325,17 @@ func TestGetNodeJoinScript(t *testing.T) { require.Contains(t, script, "test-host") require.Contains(t, script, "12345678") require.Contains(t, script, "sha256:") + require.NotContains(t, script, "JOIN_METHOD=\"iam\"") + + // Test iam method script + iamToken := scriptSettings{ + token: "token length doesnt matter in this case", + joinMethod: string(types.JoinMethodIAM), + } + + script, err = getJoinScript(iamToken, m) + require.NoError(t, err) + require.Contains(t, script, "JOIN_METHOD=\"iam\"") } func TestGetAppJoinScript(t *testing.T) { @@ -272,6 +514,110 @@ func TestGetAppJoinScript(t *testing.T) { } } +func TestIsSameRuleSet(t *testing.T) { + tt := []struct { + name string + r1 []*types.TokenRule + r2 []*types.TokenRule + expected bool + }{ + { + name: "empty slice", + expected: true, + }, + { + name: "simple identical rules", + r1: []*types.TokenRule{ + { + AWSAccount: "123123123123", + }, + }, + r2: []*types.TokenRule{ + { + AWSAccount: "123123123123", + }, + }, + expected: true, + }, + { + name: "different rules", + r1: []*types.TokenRule{ + { + AWSAccount: "123123123123", + }, + }, + r2: []*types.TokenRule{ + { + AWSAccount: "111111111111", + }, + }, + expected: false, + }, + { + name: "same rules in different order", + r1: []*types.TokenRule{ + { + AWSAccount: "123123123123", + }, + { + AWSAccount: "222222222222", + }, + { + AWSAccount: "111111111111", + AWSARN: "arn:*", + }, + }, + r2: []*types.TokenRule{ + { + AWSAccount: "222222222222", + }, + { + AWSAccount: "111111111111", + AWSARN: "arn:*", + }, + { + AWSAccount: "123123123123", + }, + }, + expected: true, + }, + { + name: "almost the same rules", + r1: []*types.TokenRule{ + { + AWSAccount: "123123123123", + }, + { + AWSAccount: "222222222222", + }, + { + AWSAccount: "111111111111", + AWSARN: "arn:*", + }, + }, + r2: []*types.TokenRule{ + { + AWSAccount: "123123123123", + }, + { + AWSAccount: "222222222222", + }, + { + AWSAccount: "111111111111", + AWSARN: "arn:", + }, + }, + expected: false, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expected, isSameRuleSet(tc.r1, tc.r2)) + }) + } +} + type mockedNodeAPIGetter struct { mockGenerateToken func(ctx context.Context, req auth.GenerateTokenRequest) (string, error) mockGetProxyServers func() ([]types.Server, error) diff --git a/lib/web/scripts/node-join/install.sh b/lib/web/scripts/node-join/install.sh index 8ce627dc087..dba5e095adc 100755 --- a/lib/web/scripts/node-join/install.sh +++ b/lib/web/scripts/node-join/install.sh @@ -1,5 +1,4 @@ #!/bin/bash -# Any changes made to this script must also be made to e/lib/web/scripts/install_node.go set -euo pipefail SCRIPT_NAME="teleport-installer" @@ -40,6 +39,7 @@ TELEPORT_VERSION="{{.version}}" TARGET_HOSTNAME="{{.hostname}}" TARGET_PORT="{{.port}}" JOIN_TOKEN="{{.token}}" +JOIN_METHOD="{{.joinMethod}}" CA_PIN_HASHES="{{.caPins}}" ARG_CA_PIN_HASHES="" APP_INSTALL_MODE="{{.appInstallMode}}" @@ -431,10 +431,11 @@ EOF install_teleport_node_config() { log "Writing Teleport node service config to ${TELEPORT_CONFIG_PATH}" CA_PINS_CONFIG=$(get_yaml_list "ca_pin" "${CA_PIN_HASHES}" " ") + AUTH_CONFIG=$(get_node_auth_config) cat << EOF > ${TELEPORT_CONFIG_PATH} teleport: nodename: ${NODENAME} - auth_token: ${JOIN_TOKEN} + ${AUTH_CONFIG} ${CA_PINS_CONFIG} auth_servers: - ${TARGET_HOSTNAME}:${TARGET_PORT} @@ -449,6 +450,16 @@ proxy_service: enabled: no EOF } +# get the auth section of a node config +get_node_auth_config() { + if [[ ${JOIN_METHOD} == "iam" ]]; then + echo "join_params: + token_name: ${JOIN_TOKEN} + method: iam +"; + else + echo "auth_token: ${JOIN_TOKEN}"; fi +} # checks whether the given host is running MacOS is_macos_host() { if [[ ${OSTYPE} == "darwin"* ]]; then return 0; else return 1; fi } # checks whether teleport is already running on the host