diff --git a/agent/consul/authmethod/awsauth/aws.go b/agent/consul/authmethod/awsauth/aws.go index f3995cdc57..7c7758476c 100644 --- a/agent/consul/authmethod/awsauth/aws.go +++ b/agent/consul/authmethod/awsauth/aws.go @@ -4,9 +4,9 @@ import ( "context" "fmt" + iamauth "github.com/hashicorp/consul-awsauth" "github.com/hashicorp/consul/agent/consul/authmethod" "github.com/hashicorp/consul/agent/structs" - "github.com/hashicorp/consul/internal/iamauth" "github.com/hashicorp/go-hclog" ) diff --git a/agent/consul/authmethod/awsauth/aws_test.go b/agent/consul/authmethod/awsauth/aws_test.go index 3025275cf4..031cd035b7 100644 --- a/agent/consul/authmethod/awsauth/aws_test.go +++ b/agent/consul/authmethod/awsauth/aws_test.go @@ -8,10 +8,10 @@ import ( "testing" "github.com/aws/aws-sdk-go/aws/credentials" + iamauth "github.com/hashicorp/consul-awsauth" + "github.com/hashicorp/consul-awsauth/iamauthtest" "github.com/hashicorp/consul/agent/consul/authmethod" "github.com/hashicorp/consul/agent/structs" - "github.com/hashicorp/consul/internal/iamauth" - "github.com/hashicorp/consul/internal/iamauth/iamauthtest" "github.com/hashicorp/go-hclog" "github.com/stretchr/testify/require" ) diff --git a/command/login/aws.go b/command/login/aws.go index bae90c9439..c0d2212dc7 100644 --- a/command/login/aws.go +++ b/command/login/aws.go @@ -9,8 +9,8 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" + iamauth "github.com/hashicorp/consul-awsauth" "github.com/hashicorp/consul/agent/consul/authmethod/awsauth" - "github.com/hashicorp/consul/internal/iamauth" "github.com/hashicorp/go-hclog" ) diff --git a/command/login/login_test.go b/command/login/login_test.go index 7eba6a4037..6340d93f71 100644 --- a/command/login/login_test.go +++ b/command/login/login_test.go @@ -13,13 +13,13 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/square/go-jose.v2/jwt" + "github.com/hashicorp/consul-awsauth/iamauthtest" "github.com/hashicorp/consul/agent" "github.com/hashicorp/consul/agent/consul/authmethod/kubeauth" "github.com/hashicorp/consul/agent/consul/authmethod/testauth" "github.com/hashicorp/consul/api" "github.com/hashicorp/consul/command/acl" "github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest" - "github.com/hashicorp/consul/internal/iamauth/iamauthtest" "github.com/hashicorp/consul/sdk/testutil" "github.com/hashicorp/consul/testrpc" ) diff --git a/go.mod b/go.mod index 84e9d1d182..cb048763d6 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22 github.com/google/tcpproxy v0.0.0-20180808230851-dfa16c61dad2 github.com/grpc-ecosystem/go-grpc-middleware v1.0.0 + github.com/hashicorp/consul-awsauth v0.0.0-20220713182709-05ac1c5c2706 github.com/hashicorp/consul-net-rpc v0.0.0-20220307172752-3602954411b4 github.com/hashicorp/consul/api v1.13.1 github.com/hashicorp/consul/sdk v0.10.0 @@ -37,7 +38,6 @@ require ( github.com/hashicorp/go-memdb v1.3.2 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-raftchunking v0.6.2 - github.com/hashicorp/go-retryablehttp v0.6.7 github.com/hashicorp/go-sockaddr v1.0.2 github.com/hashicorp/go-syslog v1.0.0 github.com/hashicorp/go-uuid v1.0.2 @@ -133,6 +133,7 @@ require ( github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.0 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect + github.com/hashicorp/go-retryablehttp v0.6.7 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/mdns v1.0.4 // indirect github.com/hashicorp/raft-boltdb v0.0.0-20211202195631-7d34b9fb3f42 // indirect diff --git a/go.sum b/go.sum index 722d907847..5e859cc7a0 100644 --- a/go.sum +++ b/go.sum @@ -294,6 +294,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmg github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hashicorp/consul-awsauth v0.0.0-20220713182709-05ac1c5c2706 h1:1ZEjnveDe20yFa6lSkfdQZm5BR/b271n0MsB5R2L3us= +github.com/hashicorp/consul-awsauth v0.0.0-20220713182709-05ac1c5c2706/go.mod h1:1Cs8FlmD1BfSQXJGcFLSV5FuIx1AbJP+EJGdxosoS2g= github.com/hashicorp/consul-net-rpc v0.0.0-20220307172752-3602954411b4 h1:Com/5n/omNSBusX11zdyIYtidiqewLIanchbm//McZA= github.com/hashicorp/consul-net-rpc v0.0.0-20220307172752-3602954411b4/go.mod h1:vWEAHAeAqfOwB3pSgHMQpIu8VH1jL+Ltg54Tw0wt/NI= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= diff --git a/internal/iamauth/README.md b/internal/iamauth/README.md deleted file mode 100644 index a9880a3559..0000000000 --- a/internal/iamauth/README.md +++ /dev/null @@ -1,2 +0,0 @@ -This is an internal package to house the AWS IAM auth method utilities for potential -future extraction from Consul. diff --git a/internal/iamauth/auth.go b/internal/iamauth/auth.go deleted file mode 100644 index aaf6bc6579..0000000000 --- a/internal/iamauth/auth.go +++ /dev/null @@ -1,311 +0,0 @@ -package iamauth - -import ( - "context" - "encoding/xml" - "fmt" - "io/ioutil" - "net/http" - "regexp" - "strings" - "time" - - "github.com/hashicorp/consul/internal/iamauth/responses" - "github.com/hashicorp/consul/lib" - "github.com/hashicorp/consul/lib/stringslice" - "github.com/hashicorp/go-cleanhttp" - "github.com/hashicorp/go-hclog" - "github.com/hashicorp/go-retryablehttp" -) - -const ( - // Retry configuration - retryWaitMin = 500 * time.Millisecond - retryWaitMax = 30 * time.Second -) - -type Authenticator struct { - config *Config - logger hclog.Logger -} - -type IdentityDetails struct { - EntityName string - EntityId string - AccountId string - - EntityPath string - EntityTags map[string]string -} - -func NewAuthenticator(config *Config, logger hclog.Logger) (*Authenticator, error) { - if err := config.Validate(); err != nil { - return nil, err - } - return &Authenticator{ - config: config, - logger: logger, - }, nil -} - -// ValidateLogin determines if the identity in the loginToken is permitted to login. -// If so, it returns details about the identity. Otherwise, an error is returned. -func (a *Authenticator) ValidateLogin(ctx context.Context, loginToken string) (*IdentityDetails, error) { - token, err := NewBearerToken(loginToken, a.config) - if err != nil { - return nil, err - } - - req, err := token.GetCallerIdentityRequest() - if err != nil { - return nil, err - } - - if a.config.ServerIDHeaderValue != "" { - err := validateHeaderValue(req.Header, a.config.ServerIDHeaderName, a.config.ServerIDHeaderValue) - if err != nil { - return nil, err - } - } - - callerIdentity, err := a.submitCallerIdentityRequest(ctx, req) - if err != nil { - return nil, err - } - a.logger.Debug("iamauth login attempt", "arn", callerIdentity.Arn) - - entity, err := responses.ParseArn(callerIdentity.Arn) - if err != nil { - return nil, err - } - - identityDetails := &IdentityDetails{ - EntityName: entity.FriendlyName, - // This could either be a "userID:SessionID" (in the case of an assumed role) or just a "userID" - // (in the case of an IAM user). - EntityId: strings.Split(callerIdentity.UserId, ":")[0], - AccountId: callerIdentity.Account, - } - clientArn := entity.CanonicalArn() - - // Fetch the IAM Role or IAM User, if configured. - // This requires the token to contain a signed iam:GetRole or iam:GetUser request. - if a.config.EnableIAMEntityDetails { - iamReq, err := token.GetEntityRequest() - if err != nil { - return nil, err - } - - if a.config.ServerIDHeaderValue != "" { - err := validateHeaderValue(iamReq.Header, a.config.ServerIDHeaderName, a.config.ServerIDHeaderValue) - if err != nil { - return nil, err - } - } - - iamEntityDetails, err := a.submitGetIAMEntityRequest(ctx, iamReq, token.entityRequestType) - if err != nil { - return nil, err - } - - // Only the CallerIdentity response is a guarantee of the client's identity. - // The role/user details must have a unique id match to the CallerIdentity before use. - if iamEntityDetails.EntityId() != identityDetails.EntityId { - return nil, fmt.Errorf("unique id mismatch in login token") - } - - // Use the full ARN with path from the Role/User details - clientArn = iamEntityDetails.EntityArn() - identityDetails.EntityPath = iamEntityDetails.EntityPath() - identityDetails.EntityTags = iamEntityDetails.EntityTags() - } - - if err := a.validateIdentity(clientArn); err != nil { - return nil, err - } - return identityDetails, nil -} - -// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1321-L1361 -func (a *Authenticator) validateIdentity(clientArn string) error { - if stringslice.Contains(a.config.BoundIAMPrincipalARNs, clientArn) { - // Matches one of BoundIAMPrincipalARNs, so it is trusted - return nil - } - if a.config.EnableIAMEntityDetails { - for _, principalArn := range a.config.BoundIAMPrincipalARNs { - if strings.HasSuffix(principalArn, "*") && lib.GlobbedStringsMatch(principalArn, clientArn) { - // Wildcard match, so it is trusted - return nil - } - } - } - return fmt.Errorf("IAM principal %s is not trusted", clientArn) -} - -func (a *Authenticator) submitCallerIdentityRequest(ctx context.Context, req *http.Request) (*responses.GetCallerIdentityResult, error) { - responseBody, err := a.submitRequest(ctx, req) - if err != nil { - return nil, err - } - callerIdentityResponse, err := parseGetCallerIdentityResponse(responseBody) - if err != nil { - return nil, fmt.Errorf("error parsing STS response") - } - - if n := len(callerIdentityResponse.GetCallerIdentityResult); n != 1 { - return nil, fmt.Errorf("received %d identities in STS response but expected 1", n) - } - return &callerIdentityResponse.GetCallerIdentityResult[0], nil -} - -func (a *Authenticator) submitGetIAMEntityRequest(ctx context.Context, req *http.Request, reqType string) (responses.IAMEntity, error) { - responseBody, err := a.submitRequest(ctx, req) - if err != nil { - return nil, err - } - iamResponse, err := parseGetIAMEntityResponse(responseBody, reqType) - if err != nil { - return nil, fmt.Errorf("error parsing IAM response: %s", err) - } - return iamResponse, nil - -} - -// https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1636 -func (a *Authenticator) submitRequest(ctx context.Context, req *http.Request) (string, error) { - retryableReq, err := retryablehttp.FromRequest(req) - if err != nil { - return "", err - } - retryableReq = retryableReq.WithContext(ctx) - client := cleanhttp.DefaultClient() - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - retryingClient := &retryablehttp.Client{ - HTTPClient: client, - RetryWaitMin: retryWaitMin, - RetryWaitMax: retryWaitMax, - RetryMax: a.config.MaxRetries, - CheckRetry: retryablehttp.DefaultRetryPolicy, - Backoff: retryablehttp.DefaultBackoff, - } - - response, err := retryingClient.Do(retryableReq) - if err != nil { - return "", fmt.Errorf("error making request: %w", err) - } - if response != nil { - defer response.Body.Close() - } - // Validate that the response type is XML - if ct := response.Header.Get("Content-Type"); ct != "text/xml" { - return "", fmt.Errorf("response body is invalid") - } - - // we check for status code afterwards to also print out response body - responseBody, err := ioutil.ReadAll(response.Body) - if err != nil { - return "", err - } - if response.StatusCode != 200 { - return "", fmt.Errorf("received error code %d: %s", response.StatusCode, string(responseBody)) - } - return string(responseBody), nil - -} - -// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1625-L1634 -func parseGetCallerIdentityResponse(response string) (responses.GetCallerIdentityResponse, error) { - result := responses.GetCallerIdentityResponse{} - response = strings.TrimSpace(response) - if !strings.HasPrefix(response, " 2 { - return fmt.Errorf("found multiple SignedHeaders components") - } - signedHeaders := string(matches[1]) - return ensureHeaderIsSigned(signedHeaders, headerName) - } - // NOTE: If we support GET requests, then we need to parse the X-Amz-SignedHeaders - // argument out of the query string and search in there for the header value - return fmt.Errorf("missing Authorization header") -} - -func ensureHeaderIsSigned(signedHeaders, headerToSign string) error { - // Not doing a constant time compare here, the values aren't secret - for _, header := range strings.Split(signedHeaders, ";") { - if header == strings.ToLower(headerToSign) { - return nil - } - } - return fmt.Errorf("header wasn't signed") -} diff --git a/internal/iamauth/auth_test.go b/internal/iamauth/auth_test.go deleted file mode 100644 index 909b64509f..0000000000 --- a/internal/iamauth/auth_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package iamauth - -import ( - "context" - "encoding/json" - "testing" - - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/hashicorp/consul/internal/iamauth/iamauthtest" - "github.com/hashicorp/consul/internal/iamauth/responses" - "github.com/hashicorp/consul/internal/iamauth/responsestest" - "github.com/hashicorp/go-hclog" - "github.com/stretchr/testify/require" -) - -func TestValidateLogin(t *testing.T) { - f := iamauthtest.MakeFixture() - - var ( - serverForRoleMismatchedIds = &iamauthtest.Server{ - GetCallerIdentityResponse: f.ServerForRole.GetCallerIdentityResponse, - GetRoleResponse: responsestest.MakeGetRoleResponse(f.RoleARN, "AAAAsomenonmatchingid", responses.Tags{}), - } - serverForUserMismatchedIds = &iamauthtest.Server{ - GetCallerIdentityResponse: f.ServerForUser.GetCallerIdentityResponse, - GetUserResponse: responsestest.MakeGetUserResponse(f.UserARN, "AAAAsomenonmatchingid", responses.Tags{}), - } - ) - - cases := map[string]struct { - config *Config - server *iamauthtest.Server - expIdent *IdentityDetails - expError string - }{ - "no bound principals": { - expError: "not trusted", - server: f.ServerForRole, - config: &Config{}, - }, - "no matching principal": { - expError: "not trusted", - server: f.ServerForUser, - config: &Config{ - BoundIAMPrincipalARNs: []string{ - "arn:aws:iam::1234567890:user/some-other-role", - "arn:aws:iam::1234567890:user/some-other-user", - }, - }, - }, - "mismatched server id header": { - expError: `expected "some-non-matching-value" but got "server.id.example.com"`, - server: f.ServerForRole, - config: &Config{ - BoundIAMPrincipalARNs: []string{f.CanonicalRoleARN}, - ServerIDHeaderValue: "some-non-matching-value", - ServerIDHeaderName: "X-Test-ServerID", - }, - }, - "role unique id mismatch": { - expError: "unique id mismatch in login token", - // The RoleId in the GetRole response must match the UserId in the GetCallerIdentity response - // during login. If not, the RoleId cannot be used. - server: serverForRoleMismatchedIds, - config: &Config{ - BoundIAMPrincipalARNs: []string{f.RoleARN}, - EnableIAMEntityDetails: true, - }, - }, - "user unique id mismatch": { - expError: "unique id mismatch in login token", - server: serverForUserMismatchedIds, - config: &Config{ - BoundIAMPrincipalARNs: []string{f.UserARN}, - EnableIAMEntityDetails: true, - }, - }, - } - logger := hclog.New(nil) - for name, c := range cases { - t.Run(name, func(t *testing.T) { - fakeAws := iamauthtest.NewTestServer(t, c.server) - - c.config.STSEndpoint = fakeAws.URL + "/sts" - c.config.IAMEndpoint = fakeAws.URL + "/iam" - setTestHeaderNames(c.config) - - // This bypasses NewAuthenticator, which bypasses config.Validate(). - auth := &Authenticator{config: c.config, logger: logger} - - loginInput := &LoginInput{ - Creds: credentials.NewStaticCredentials("fake", "fake", ""), - IncludeIAMEntity: c.config.EnableIAMEntityDetails, - STSEndpoint: c.config.STSEndpoint, - STSRegion: "fake-region", - Logger: logger, - ServerIDHeaderValue: "server.id.example.com", - } - setLoginInputHeaderNames(loginInput) - loginData, err := GenerateLoginData(loginInput) - require.NoError(t, err) - loginBytes, err := json.Marshal(loginData) - require.NoError(t, err) - - ident, err := auth.ValidateLogin(context.Background(), string(loginBytes)) - if c.expError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), c.expError) - require.Nil(t, ident) - } else { - require.NoError(t, err) - require.Equal(t, c.expIdent, ident) - } - }) - } -} - -func setLoginInputHeaderNames(in *LoginInput) { - in.ServerIDHeaderName = "X-Test-ServerID" - in.GetEntityMethodHeader = "X-Test-Method" - in.GetEntityURLHeader = "X-Test-URL" - in.GetEntityHeadersHeader = "X-Test-Headers" - in.GetEntityBodyHeader = "X-Test-Body" -} diff --git a/internal/iamauth/config.go b/internal/iamauth/config.go deleted file mode 100644 index d3c722c552..0000000000 --- a/internal/iamauth/config.go +++ /dev/null @@ -1,80 +0,0 @@ -package iamauth - -import ( - "fmt" - "strings" - - awsArn "github.com/aws/aws-sdk-go/aws/arn" -) - -type Config struct { - BoundIAMPrincipalARNs []string - EnableIAMEntityDetails bool - IAMEntityTags []string - ServerIDHeaderValue string - MaxRetries int - IAMEndpoint string - STSEndpoint string - AllowedSTSHeaderValues []string - - // Customizable header names - ServerIDHeaderName string - GetEntityMethodHeader string - GetEntityURLHeader string - GetEntityHeadersHeader string - GetEntityBodyHeader string -} - -func (c *Config) Validate() error { - if len(c.BoundIAMPrincipalARNs) == 0 { - return fmt.Errorf("BoundIAMPrincipalARNs is required and must have at least 1 entry") - } - - for _, arn := range c.BoundIAMPrincipalARNs { - if n := strings.Count(arn, "*"); n > 0 { - if !c.EnableIAMEntityDetails { - return fmt.Errorf("Must set EnableIAMEntityDetails=true to use wildcards in BoundIAMPrincipalARNs") - } - if n != 1 || !strings.HasSuffix(arn, "*") { - return fmt.Errorf("Only one wildcard is allowed at the end of the bound IAM principal ARN") - } - } - - if parsed, err := awsArn.Parse(arn); err != nil { - return fmt.Errorf("Invalid principal ARN: %q", arn) - } else if parsed.Service != "iam" && parsed.Service != "sts" { - return fmt.Errorf("Invalid principal ARN: %q", arn) - } - } - - if len(c.IAMEntityTags) > 0 && !c.EnableIAMEntityDetails { - return fmt.Errorf("Must set EnableIAMEntityDetails=true to use IAMUserTags") - } - - // If server id header checking is enabled, we need the header name. - if c.ServerIDHeaderValue != "" && c.ServerIDHeaderName == "" { - return fmt.Errorf("Must set ServerIDHeaderName to use a server ID value") - } - - if c.EnableIAMEntityDetails && (c.GetEntityBodyHeader == "" || - c.GetEntityHeadersHeader == "" || - c.GetEntityMethodHeader == "" || - c.GetEntityURLHeader == "") { - return fmt.Errorf("Must set all of GetEntityMethodHeader, GetEntityURLHeader, " + - "GetEntityHeadersHeader, and GetEntityBodyHeader when EnableIAMEntityDetails=true") - } - - if c.STSEndpoint != "" { - if _, err := parseUrl(c.STSEndpoint); err != nil { - return fmt.Errorf("STSEndpoint is invalid: %s", err) - } - } - - if c.IAMEndpoint != "" { - if _, err := parseUrl(c.IAMEndpoint); err != nil { - return fmt.Errorf("IAMEndpoint is invalid: %s", err) - } - } - - return nil -} diff --git a/internal/iamauth/config_test.go b/internal/iamauth/config_test.go deleted file mode 100644 index d23dc992ae..0000000000 --- a/internal/iamauth/config_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package iamauth - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestConfigValidate(t *testing.T) { - principalArn := "arn:aws:iam::000000000000:role/my-role" - - cases := map[string]struct { - expError string - configs []Config - - includeHeaderNames bool - }{ - "bound iam principals are required": { - expError: "BoundIAMPrincipalARNs is required and must have at least 1 entry", - configs: []Config{ - {BoundIAMPrincipalARNs: nil}, - {BoundIAMPrincipalARNs: []string{}}, - }, - }, - "entity tags require entity details": { - expError: "Must set EnableIAMEntityDetails=true to use IAMUserTags", - configs: []Config{ - { - BoundIAMPrincipalARNs: []string{principalArn}, - EnableIAMEntityDetails: false, - IAMEntityTags: []string{"some-tag"}, - }, - }, - }, - "entity details require all entity header names": { - expError: "Must set all of GetEntityMethodHeader, GetEntityURLHeader, " + - "GetEntityHeadersHeader, and GetEntityBodyHeader when EnableIAMEntityDetails=true", - configs: []Config{ - { - BoundIAMPrincipalARNs: []string{principalArn}, - EnableIAMEntityDetails: true, - }, - { - BoundIAMPrincipalARNs: []string{principalArn}, - EnableIAMEntityDetails: true, - GetEntityBodyHeader: "X-Test-Header", - }, - { - BoundIAMPrincipalARNs: []string{principalArn}, - EnableIAMEntityDetails: true, - GetEntityHeadersHeader: "X-Test-Header", - }, - { - BoundIAMPrincipalARNs: []string{principalArn}, - EnableIAMEntityDetails: true, - GetEntityURLHeader: "X-Test-Header", - }, - { - BoundIAMPrincipalARNs: []string{principalArn}, - EnableIAMEntityDetails: true, - GetEntityMethodHeader: "X-Test-Header", - }, - }, - }, - "wildcard principals require entity details": { - expError: "Must set EnableIAMEntityDetails=true to use wildcards in BoundIAMPrincipalARNs", - configs: []Config{ - {BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*"}}, - {BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/path/*"}}, - }, - }, - "only one wildcard suffix is allowed": { - expError: "Only one wildcard is allowed at the end of the bound IAM principal ARN", - configs: []Config{ - { - BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/**"}, - EnableIAMEntityDetails: true, - }, - { - BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*/*"}, - EnableIAMEntityDetails: true, - }, - { - BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*/path"}, - EnableIAMEntityDetails: true, - }, - { - BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*/path/*"}, - EnableIAMEntityDetails: true, - }, - }, - }, - "invalid principal arns are disallowed": { - expError: fmt.Sprintf("Invalid principal ARN"), - configs: []Config{ - {BoundIAMPrincipalARNs: []string{""}}, - {BoundIAMPrincipalARNs: []string{" "}}, - {BoundIAMPrincipalARNs: []string{"*"}, EnableIAMEntityDetails: true}, - {BoundIAMPrincipalARNs: []string{"arn:aws:iam:role/my-role"}}, - }, - }, - "valid principal arns are allowed": { - includeHeaderNames: true, - configs: []Config{ - {BoundIAMPrincipalARNs: []string{"arn:aws:sts::000000000000:assumed-role/my-role/some-session-name"}}, - {BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:user/my-user"}}, - {BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/my-role"}}, - {BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:*"}, EnableIAMEntityDetails: true}, - {BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*"}, EnableIAMEntityDetails: true}, - {BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/path/*"}, EnableIAMEntityDetails: true}, - {BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:user/*"}, EnableIAMEntityDetails: true}, - {BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:user/path/*"}, EnableIAMEntityDetails: true}, - }, - }, - "server id header value requires service id header name": { - expError: "Must set ServerIDHeaderName to use a server ID value", - configs: []Config{ - { - BoundIAMPrincipalARNs: []string{principalArn}, - ServerIDHeaderValue: "consul.test.example.com", - }, - }, - }, - } - - for name, c := range cases { - t.Run(name, func(t *testing.T) { - for _, conf := range c.configs { - if c.includeHeaderNames { - setTestHeaderNames(&conf) - } - err := conf.Validate() - if c.expError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), c.expError) - } else { - require.NoError(t, err) - } - } - }) - } -} - -func setTestHeaderNames(conf *Config) { - conf.GetEntityMethodHeader = "X-Test-Method" - conf.GetEntityURLHeader = "X-Test-URL" - conf.GetEntityHeadersHeader = "X-Test-Headers" - conf.GetEntityBodyHeader = "X-Test-Body" -} diff --git a/internal/iamauth/iamauthtest/testing.go b/internal/iamauth/iamauthtest/testing.go deleted file mode 100644 index b2e1fb37c4..0000000000 --- a/internal/iamauth/iamauthtest/testing.go +++ /dev/null @@ -1,187 +0,0 @@ -package iamauthtest - -import ( - "encoding/xml" - "fmt" - "io" - "net/http" - "net/http/httptest" - "sort" - "strings" - "testing" - - "github.com/hashicorp/consul/internal/iamauth/responses" - "github.com/hashicorp/consul/internal/iamauth/responsestest" -) - -// NewTestServer returns a fake AWS API server for local tests: -// It supports the following paths: -// /sts returns STS API responses -// /iam returns IAM API responses -func NewTestServer(t *testing.T, s *Server) *httptest.Server { - server := httptest.NewUnstartedServer(s) - t.Cleanup(server.Close) - server.Start() - return server -} - -// Server contains configuration for the fake AWS API server. -type Server struct { - GetCallerIdentityResponse responses.GetCallerIdentityResponse - GetRoleResponse responses.GetRoleResponse - GetUserResponse responses.GetUserResponse -} - -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - writeError(w, http.StatusBadRequest, r) - return - } - - switch { - case strings.HasPrefix(r.URL.Path, "/sts"): - writeXML(w, s.GetCallerIdentityResponse) - case strings.HasPrefix(r.URL.Path, "/iam"): - if bodyBytes, err := io.ReadAll(r.Body); err == nil { - body := string(bodyBytes) - switch { - case strings.Contains(body, "Action=GetRole"): - writeXML(w, s.GetRoleResponse) - return - case strings.Contains(body, "Action=GetUser"): - writeXML(w, s.GetUserResponse) - return - } - } - writeError(w, http.StatusBadRequest, r) - default: - writeError(w, http.StatusNotFound, r) - } -} - -func writeXML(w http.ResponseWriter, val interface{}) { - str, err := xml.MarshalIndent(val, "", " ") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprint(w, err.Error()) - return - } - w.Header().Add("Content-Type", "text/xml") - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, string(str)) -} - -func writeError(w http.ResponseWriter, code int, r *http.Request) { - w.WriteHeader(code) - msg := fmt.Sprintf("%s %s", r.Method, r.URL) - fmt.Fprintf(w, ` - - Fake AWS Server Error: %s - -`, msg) -} - -type Fixture struct { - AssumedRoleARN string - CanonicalRoleARN string - RoleARN string - RoleARNWildcard string - RoleName string - RolePath string - RoleTags map[string]string - - EntityID string - EntityIDWithSession string - AccountID string - - UserARN string - UserARNWildcard string - UserName string - UserPath string - UserTags map[string]string - - ServerForRole *Server - ServerForUser *Server -} - -func MakeFixture() Fixture { - f := Fixture{ - AssumedRoleARN: "arn:aws:sts::1234567890:assumed-role/my-role/some-session", - CanonicalRoleARN: "arn:aws:iam::1234567890:role/my-role", - RoleARN: "arn:aws:iam::1234567890:role/some/path/my-role", - RoleARNWildcard: "arn:aws:iam::1234567890:role/some/path/*", - RoleName: "my-role", - RolePath: "some/path", - RoleTags: map[string]string{ - "service-name": "my-service", - "env": "my-env", - }, - - EntityID: "AAAsomeuniqueid", - EntityIDWithSession: "AAAsomeuniqueid:some-session", - AccountID: "1234567890", - - UserARN: "arn:aws:iam::1234567890:user/my-user", - UserARNWildcard: "arn:aws:iam::1234567890:user/*", - UserName: "my-user", - UserPath: "", - UserTags: map[string]string{"user-group": "my-group"}, - } - - f.ServerForRole = &Server{ - GetCallerIdentityResponse: responsestest.MakeGetCallerIdentityResponse( - f.AssumedRoleARN, f.EntityIDWithSession, f.AccountID, - ), - GetRoleResponse: responsestest.MakeGetRoleResponse( - f.RoleARN, f.EntityID, toTags(f.RoleTags), - ), - } - - f.ServerForUser = &Server{ - GetCallerIdentityResponse: responsestest.MakeGetCallerIdentityResponse( - f.UserARN, f.EntityID, f.AccountID, - ), - GetUserResponse: responsestest.MakeGetUserResponse( - f.UserARN, f.EntityID, toTags(f.UserTags), - ), - } - - return f -} - -func (f *Fixture) RoleTagKeys() []string { return keys(f.RoleTags) } -func (f *Fixture) UserTagKeys() []string { return keys(f.UserTags) } -func (f *Fixture) RoleTagValues() []string { return values(f.RoleTags) } -func (f *Fixture) UserTagValues() []string { return values(f.UserTags) } - -// toTags converts the map to a slice of responses.Tag -func toTags(tags map[string]string) responses.Tags { - members := []responses.TagMember{} - for k, v := range tags { - members = append(members, responses.TagMember{ - Key: k, - Value: v, - }) - } - return responses.Tags{Members: members} - -} - -// keys returns the keys in sorted order -func keys(tags map[string]string) []string { - result := []string{} - for k := range tags { - result = append(result, k) - } - sort.Strings(result) - return result -} - -// values returns values in tags, ordered by sorted keys -func values(tags map[string]string) []string { - result := []string{} - for _, k := range keys(tags) { // ensures sorted by key - result = append(result, tags[k]) - } - return result -} diff --git a/internal/iamauth/responses/arn.go b/internal/iamauth/responses/arn.go deleted file mode 100644 index ea5e541d30..0000000000 --- a/internal/iamauth/responses/arn.go +++ /dev/null @@ -1,94 +0,0 @@ -package responses - -import ( - "fmt" - "strings" -) - -// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1722-L1744 -type ParsedArn struct { - Partition string - AccountNumber string - Type string - Path string - FriendlyName string - SessionInfo string -} - -// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1482-L1530 -// However, instance profiles are not support in Consul. -func ParseArn(iamArn string) (*ParsedArn, error) { - // iamArn should look like one of the following: - // 1. arn:aws:iam:::/ - // 2. arn:aws:sts:::assumed-role// - // if we get something like 2, then we want to transform that back to what - // most people would expect, which is arn:aws:iam:::role/ - var entity ParsedArn - fullParts := strings.Split(iamArn, ":") - if len(fullParts) != 6 { - return nil, fmt.Errorf("unrecognized arn: contains %d colon-separated parts, expected 6", len(fullParts)) - } - if fullParts[0] != "arn" { - return nil, fmt.Errorf("unrecognized arn: does not begin with \"arn:\"") - } - // normally aws, but could be aws-cn or aws-us-gov - entity.Partition = fullParts[1] - if entity.Partition == "" { - return nil, fmt.Errorf("unrecognized arn: %q is missing the partition", iamArn) - } - if fullParts[2] != "iam" && fullParts[2] != "sts" { - return nil, fmt.Errorf("unrecognized service: %v, not one of iam or sts", fullParts[2]) - } - // fullParts[3] is the region, which doesn't matter for AWS IAM entities - entity.AccountNumber = fullParts[4] - if entity.AccountNumber == "" { - return nil, fmt.Errorf("unrecognized arn: %q is missing the account number", iamArn) - } - // fullParts[5] would now be something like user/ or assumed-role// - parts := strings.Split(fullParts[5], "/") - if len(parts) < 2 { - return nil, fmt.Errorf("unrecognized arn: %q contains fewer than 2 slash-separated parts", fullParts[5]) - } - entity.Type = parts[0] - entity.Path = strings.Join(parts[1:len(parts)-1], "/") - entity.FriendlyName = parts[len(parts)-1] - // now, entity.FriendlyName should either be or - switch entity.Type { - case "assumed-role": - // Check for three parts for assumed role ARNs - if len(parts) < 3 { - return nil, fmt.Errorf("unrecognized arn: %q contains fewer than 3 slash-separated parts", fullParts[5]) - } - // Assumed roles don't have paths and have a slightly different format - // parts[2] is - entity.Path = "" - entity.FriendlyName = parts[1] - entity.SessionInfo = parts[2] - case "user": - case "role": - // case "instance-profile": - default: - return nil, fmt.Errorf("unrecognized principal type: %q", entity.Type) - } - - if entity.FriendlyName == "" { - return nil, fmt.Errorf("unrecognized arn: %q is missing the resource name", iamArn) - } - - return &entity, nil -} - -// CanonicalArn returns the canonical ARN for referring to an IAM entity -func (p *ParsedArn) CanonicalArn() string { - entityType := p.Type - // canonicalize "assumed-role" into "role" - if entityType == "assumed-role" { - entityType = "role" - } - // Annoyingly, the assumed-role entity type doesn't have the Path of the role which was assumed - // So, we "canonicalize" it by just completely dropping the path. The other option would be to - // make an AWS API call to look up the role by FriendlyName, which introduces more complexity to - // code and test, and it also breaks backwards compatibility in an area where we would really want - // it - return fmt.Sprintf("arn:%s:iam::%s:%s/%s", p.Partition, p.AccountNumber, entityType, p.FriendlyName) -} diff --git a/internal/iamauth/responses/responses.go b/internal/iamauth/responses/responses.go deleted file mode 100644 index ed57ca97b2..0000000000 --- a/internal/iamauth/responses/responses.go +++ /dev/null @@ -1,96 +0,0 @@ -package responses - -import "encoding/xml" - -type GetCallerIdentityResponse struct { - XMLName xml.Name `xml:"GetCallerIdentityResponse"` - GetCallerIdentityResult []GetCallerIdentityResult `xml:"GetCallerIdentityResult"` - ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"` -} - -type GetCallerIdentityResult struct { - Arn string `xml:"Arn"` - UserId string `xml:"UserId"` - Account string `xml:"Account"` -} - -type ResponseMetadata struct { - RequestId string `xml:"RequestId"` -} - -// IAMEntity is an interface for getting details from an IAM Role or User. -type IAMEntity interface { - EntityPath() string - EntityArn() string - EntityName() string - EntityId() string - EntityTags() map[string]string -} - -var _ IAMEntity = (*Role)(nil) -var _ IAMEntity = (*User)(nil) - -type GetRoleResponse struct { - XMLName xml.Name `xml:"GetRoleResponse"` - GetRoleResult []GetRoleResult `xml:"GetRoleResult"` - ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"` -} - -type GetRoleResult struct { - Role Role `xml:"Role"` -} - -type Role struct { - Arn string `xml:"Arn"` - Path string `xml:"Path"` - RoleId string `xml:"RoleId"` - RoleName string `xml:"RoleName"` - Tags Tags `xml:"Tags"` -} - -func (r *Role) EntityPath() string { return r.Path } -func (r *Role) EntityArn() string { return r.Arn } -func (r *Role) EntityName() string { return r.RoleName } -func (r *Role) EntityId() string { return r.RoleId } -func (r *Role) EntityTags() map[string]string { return tagsToMap(r.Tags) } - -type GetUserResponse struct { - XMLName xml.Name `xml:"GetUserResponse"` - GetUserResult []GetUserResult `xml:"GetUserResult"` - ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"` -} - -type GetUserResult struct { - User User `xml:"User"` -} - -type User struct { - Arn string `xml:"Arn"` - Path string `xml:"Path"` - UserId string `xml:"UserId"` - UserName string `xml:"UserName"` - Tags Tags `xml:"Tags"` -} - -func (u *User) EntityPath() string { return u.Path } -func (u *User) EntityArn() string { return u.Arn } -func (u *User) EntityName() string { return u.UserName } -func (u *User) EntityId() string { return u.UserId } -func (u *User) EntityTags() map[string]string { return tagsToMap(u.Tags) } - -type Tags struct { - Members []TagMember `xml:"member"` -} - -type TagMember struct { - Key string `xml:"Key"` - Value string `xml:"Value"` -} - -func tagsToMap(tags Tags) map[string]string { - result := map[string]string{} - for _, tag := range tags.Members { - result[tag.Key] = tag.Value - } - return result -} diff --git a/internal/iamauth/responses/responses_test.go b/internal/iamauth/responses/responses_test.go deleted file mode 100644 index a641be45ad..0000000000 --- a/internal/iamauth/responses/responses_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package responses - -import ( - "encoding/xml" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestParseArn(t *testing.T) { - cases := map[string]struct { - arn string - expArn *ParsedArn - }{ - "assumed-role": { - arn: "arn:aws:sts::000000000000:assumed-role/my-role/session-name", - expArn: &ParsedArn{ - Partition: "aws", - AccountNumber: "000000000000", - Type: "assumed-role", - Path: "", - FriendlyName: "my-role", - SessionInfo: "session-name", - }, - }, - "role": { - arn: "arn:aws:iam::000000000000:role/my-role", - expArn: &ParsedArn{ - Partition: "aws", - AccountNumber: "000000000000", - Type: "role", - Path: "", - FriendlyName: "my-role", - SessionInfo: "", - }, - }, - "user": { - arn: "arn:aws:iam::000000000000:user/my-user", - expArn: &ParsedArn{ - Partition: "aws", - AccountNumber: "000000000000", - Type: "user", - Path: "", - FriendlyName: "my-user", - SessionInfo: "", - }, - }, - "role with path": { - arn: "arn:aws:iam::000000000000:role/path/my-role", - expArn: &ParsedArn{ - Partition: "aws", - AccountNumber: "000000000000", - Type: "role", - Path: "path", - FriendlyName: "my-role", - SessionInfo: "", - }, - }, - "role with path 2": { - arn: "arn:aws:iam::000000000000:role/path/to/my-role", - expArn: &ParsedArn{ - Partition: "aws", - AccountNumber: "000000000000", - Type: "role", - Path: "path/to", - FriendlyName: "my-role", - SessionInfo: "", - }, - }, - "role with path 3": { - arn: "arn:aws:iam::000000000000:role/some/path/to/my-role", - expArn: &ParsedArn{ - Partition: "aws", - AccountNumber: "000000000000", - Type: "role", - Path: "some/path/to", - FriendlyName: "my-role", - SessionInfo: "", - }, - }, - "user with path": { - arn: "arn:aws:iam::000000000000:user/path/my-user", - expArn: &ParsedArn{ - Partition: "aws", - AccountNumber: "000000000000", - Type: "user", - Path: "path", - FriendlyName: "my-user", - SessionInfo: "", - }, - }, - - // Invalid cases - "empty string": {arn: ""}, - "wildcard": {arn: "*"}, - "missing prefix": {arn: ":aws:sts::000000000000:assumed-role/my-role/session-name"}, - "missing partition": {arn: "arn::sts::000000000000:assumed-role/my-role/session-name"}, - "missing service": {arn: "arn:aws:::000000000000:assumed-role/my-role/session-name"}, - "missing separator": {arn: "arn:aws:sts:000000000000:assumed-role/my-role/session-name"}, - "missing account id": {arn: "arn:aws:sts:::assumed-role/my-role/session-name"}, - "missing resource": {arn: "arn:aws:sts::000000000000:"}, - "assumed-role missing parts": {arn: "arn:aws:sts::000000000000:assumed-role/my-role"}, - "role missing parts": {arn: "arn:aws:sts::000000000000:role"}, - "role missing parts 2": {arn: "arn:aws:sts::000000000000:role/"}, - "user missing parts": {arn: "arn:aws:sts::000000000000:user"}, - "user missing parts 2": {arn: "arn:aws:sts::000000000000:user/"}, - "unsupported service": {arn: "arn:aws:ecs:us-east-1:000000000000:task/my-task/00000000000000000000000000000000"}, - } - - for name, c := range cases { - t.Run(name, func(t *testing.T) { - parsed, err := ParseArn(c.arn) - if c.expArn != nil { - require.NoError(t, err) - require.Equal(t, c.expArn, parsed) - } else { - require.Error(t, err) - require.Nil(t, parsed) - } - }) - } -} - -func TestCanonicalArn(t *testing.T) { - cases := map[string]struct { - arn string - expArn string - }{ - "assumed-role arn": { - arn: "arn:aws:sts::000000000000:assumed-role/my-role/session-name", - expArn: "arn:aws:iam::000000000000:role/my-role", - }, - "role arn": { - arn: "arn:aws:iam::000000000000:role/my-role", - expArn: "arn:aws:iam::000000000000:role/my-role", - }, - "role arn with path": { - arn: "arn:aws:iam::000000000000:role/path/to/my-role", - expArn: "arn:aws:iam::000000000000:role/my-role", - }, - "user arn": { - arn: "arn:aws:iam::000000000000:user/my-user", - expArn: "arn:aws:iam::000000000000:user/my-user", - }, - "user arn with path": { - arn: "arn:aws:iam::000000000000:user/path/to/my-user", - expArn: "arn:aws:iam::000000000000:user/my-user", - }, - } - - for name, c := range cases { - t.Run(name, func(t *testing.T) { - parsed, err := ParseArn(c.arn) - require.NoError(t, err) - require.Equal(t, c.expArn, parsed.CanonicalArn()) - }) - } -} - -func TestUnmarshalXML(t *testing.T) { - t.Run("user xml", func(t *testing.T) { - var resp GetUserResponse - err := xml.Unmarshal([]byte(rawUserXML), &resp) - require.NoError(t, err) - require.Equal(t, expectedParsedUserXML, resp) - }) - t.Run("role xml", func(t *testing.T) { - var resp GetRoleResponse - err := xml.Unmarshal([]byte(rawRoleXML), &resp) - require.NoError(t, err) - require.Equal(t, expectedParsedRoleXML, resp) - }) -} - -var ( - rawUserXML = ` - - - / - arn:aws:iam::000000000000:user/my-user - my-user - AIDAexampleuserid - 2021-01-01T00:01:02Z - - - some-value - some-tag - - - another-value - another-tag - - - third-value - third-tag - - - - - - 11815b96-cb16-4d33-b2cf-0042fa4db4cd - -` - - expectedParsedUserXML = GetUserResponse{ - XMLName: xml.Name{ - Space: "https://iam.amazonaws.com/doc/2010-05-08/", - Local: "GetUserResponse", - }, - GetUserResult: []GetUserResult{ - { - User: User{ - Arn: "arn:aws:iam::000000000000:user/my-user", - Path: "/", - UserId: "AIDAexampleuserid", - UserName: "my-user", - Tags: Tags{ - Members: []TagMember{ - {Key: "some-tag", Value: "some-value"}, - {Key: "another-tag", Value: "another-value"}, - {Key: "third-tag", Value: "third-value"}, - }, - }, - }, - }, - }, - ResponseMetadata: []ResponseMetadata{ - {RequestId: "11815b96-cb16-4d33-b2cf-0042fa4db4cd"}, - }, - } - - rawRoleXML = ` - - - / - some-json-document-that-we-ignore - 43200 - AROAsomeuniqueid - - 2022-01-01T01:02:03Z - us-east-1 - - my-role - arn:aws:iam::000000000000:role/my-role - 2020-01-01T00:00:01Z - - - some-value - some-key - - - another-value - another-key - - - a-third-value - third-key - - - - - - a9866067-c0e5-4b5e-86ba-429c1151e2fb - -` - - expectedParsedRoleXML = GetRoleResponse{ - XMLName: xml.Name{ - Space: "https://iam.amazonaws.com/doc/2010-05-08/", - Local: "GetRoleResponse", - }, - GetRoleResult: []GetRoleResult{ - { - Role: Role{ - Arn: "arn:aws:iam::000000000000:role/my-role", - Path: "/", - RoleId: "AROAsomeuniqueid", - RoleName: "my-role", - Tags: Tags{ - Members: []TagMember{ - {Key: "some-key", Value: "some-value"}, - {Key: "another-key", Value: "another-value"}, - {Key: "third-key", Value: "a-third-value"}, - }, - }, - }, - }, - }, - ResponseMetadata: []ResponseMetadata{ - {RequestId: "a9866067-c0e5-4b5e-86ba-429c1151e2fb"}, - }, - } -) diff --git a/internal/iamauth/responsestest/testing.go b/internal/iamauth/responsestest/testing.go deleted file mode 100644 index 7daec0517c..0000000000 --- a/internal/iamauth/responsestest/testing.go +++ /dev/null @@ -1,81 +0,0 @@ -package responsestest - -import ( - "strings" - - "github.com/hashicorp/consul/internal/iamauth/responses" -) - -func MakeGetCallerIdentityResponse(arn, userId, accountId string) responses.GetCallerIdentityResponse { - // Sanity check the UserId for unit tests. - parsed := parseArn(arn) - switch parsed.Type { - case "assumed-role": - if !strings.Contains(userId, ":") { - panic("UserId for assumed-role in GetCallerIdentity response must be ':'") - } - default: - if strings.Contains(userId, ":") { - panic("UserId in GetCallerIdentity must not contain ':'") - } - } - - return responses.GetCallerIdentityResponse{ - GetCallerIdentityResult: []responses.GetCallerIdentityResult{ - { - Arn: arn, - UserId: userId, - Account: accountId, - }, - }, - } -} - -func MakeGetRoleResponse(arn, id string, tags responses.Tags) responses.GetRoleResponse { - if strings.Contains(id, ":") { - panic("RoleId in GetRole response must not contain ':'") - } - parsed := parseArn(arn) - return responses.GetRoleResponse{ - GetRoleResult: []responses.GetRoleResult{ - { - Role: responses.Role{ - Arn: arn, - Path: parsed.Path, - RoleId: id, - RoleName: parsed.FriendlyName, - Tags: tags, - }, - }, - }, - } -} - -func MakeGetUserResponse(arn, id string, tags responses.Tags) responses.GetUserResponse { - if strings.Contains(id, ":") { - panic("UserId in GetUser resposne must not contain ':'") - } - parsed := parseArn(arn) - return responses.GetUserResponse{ - GetUserResult: []responses.GetUserResult{ - { - User: responses.User{ - Arn: arn, - Path: parsed.Path, - UserId: id, - UserName: parsed.FriendlyName, - Tags: tags, - }, - }, - }, - } -} - -func parseArn(arn string) *responses.ParsedArn { - parsed, err := responses.ParseArn(arn) - if err != nil { - // For testing, just fail immediately. - panic(err) - } - return parsed -} diff --git a/internal/iamauth/token.go b/internal/iamauth/token.go deleted file mode 100644 index 10422ca6c5..0000000000 --- a/internal/iamauth/token.go +++ /dev/null @@ -1,403 +0,0 @@ -package iamauth - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/textproto" - "net/url" - "strings" - - "github.com/hashicorp/consul/lib/stringslice" -) - -const ( - amzHeaderPrefix = "X-Amz-" -) - -var defaultAllowedSTSRequestHeaders = []string{ - "X-Amz-Algorithm", - "X-Amz-Content-Sha256", - "X-Amz-Credential", - "X-Amz-Date", - "X-Amz-Security-Token", - "X-Amz-Signature", - "X-Amz-SignedHeaders", -} - -// BearerToken is a login "token" for an IAM auth method. It is a signed -// sts:GetCallerIdentity request in JSON format. Optionally, it can include a -// signed embedded iam:GetRole or iam:GetUser request in the headers. -type BearerToken struct { - config *Config - - getCallerIdentityMethod string - getCallerIdentityURL string - getCallerIdentityHeader http.Header - getCallerIdentityBody string - - getIAMEntityMethod string - getIAMEntityURL string - getIAMEntityHeader http.Header - getIAMEntityBody string - - entityRequestType string - parsedCallerIdentityURL *url.URL - parsedIAMEntityURL *url.URL -} - -var _ json.Unmarshaler = (*BearerToken)(nil) - -func NewBearerToken(loginToken string, config *Config) (*BearerToken, error) { - token := &BearerToken{config: config} - if err := json.Unmarshal([]byte(loginToken), &token); err != nil { - return nil, fmt.Errorf("invalid token: %s", err) - } - - if err := token.validate(); err != nil { - return nil, err - } - - if config.EnableIAMEntityDetails { - method, err := token.getHeader(token.config.GetEntityMethodHeader) - if err != nil { - return nil, err - } - - rawUrl, err := token.getHeader(token.config.GetEntityURLHeader) - if err != nil { - return nil, err - } - - headerJson, err := token.getHeader(token.config.GetEntityHeadersHeader) - if err != nil { - return nil, err - } - - var header http.Header - if err := json.Unmarshal([]byte(headerJson), &header); err != nil { - return nil, err - } - - body, err := token.getHeader(token.config.GetEntityBodyHeader) - if err != nil { - return nil, err - } - - parsedUrl, err := parseUrl(rawUrl) - if err != nil { - return nil, err - } - - token.getIAMEntityMethod = method - token.getIAMEntityBody = body - token.getIAMEntityURL = rawUrl - token.getIAMEntityHeader = header - token.parsedIAMEntityURL = parsedUrl - - if err := token.validateIAMHostname(); err != nil { - return nil, err - } - - reqType, err := token.validateIAMEntityBody() - if err != nil { - return nil, err - } - token.entityRequestType = reqType - } - return token, nil -} - -// https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1178 -func (t *BearerToken) validate() error { - if t.getCallerIdentityMethod != "POST" { - return fmt.Errorf("iam_http_request_method must be POST") - } - if err := t.validateSTSHostname(); err != nil { - return err - } - if err := t.validateGetCallerIdentityBody(); err != nil { - return err - } - if err := t.validateAllowedSTSHeaderValues(); err != nil { - return err - } - return nil -} - -// validateSTSHostname checks the CallerIdentityURL in the BearerToken -// either matches the admin configured STSEndpoint or, if STSEndpoint is not set, -// that the URL matches a known Amazon AWS hostname for the STS service, one of: -// -// sts.amazonaws.com -// sts.*.amazonaws.com -// sts-fips.amazonaws.com -// sts-fips.*.amazonaws.com -// -// See https://docs.aws.amazon.com/general/latest/gr/sts.html -func (t *BearerToken) validateSTSHostname() error { - if t.config.STSEndpoint != "" { - // If an STS endpoint is configured, we (elsewhere) send the request to that endpoint. - return nil - } - if t.parsedCallerIdentityURL == nil { - return fmt.Errorf("invalid GetCallerIdentity URL: %v", t.getCallerIdentityURL) - } - - // Otherwise, validate the hostname looks like a known STS endpoint. - host := t.parsedCallerIdentityURL.Hostname() - if strings.HasSuffix(host, ".amazonaws.com") && - (strings.HasPrefix(host, "sts.") || strings.HasPrefix(host, "sts-fips.")) { - return nil - } - return fmt.Errorf("invalid STS hostname: %q", host) -} - -// validateIAMHostname checks the IAMEntityURL in the BearerToken -// either matches the admin configured IAMEndpoint or, if IAMEndpoint is not set, -// that the URL matches a known Amazon AWS hostname for the IAM service, one of: -// -// iam.amazonaws.com -// iam.*.amazonaws.com -// iam-fips.amazonaws.com -// iam-fips.*.amazonaws.com -// -// See https://docs.aws.amazon.com/general/latest/gr/iam-service.html -func (t *BearerToken) validateIAMHostname() error { - if t.config.IAMEndpoint != "" { - // If an IAM endpoint is configured, we (elsewhere) send the request to that endpoint. - return nil - } - if t.parsedIAMEntityURL == nil { - return fmt.Errorf("invalid IAM URL: %v", t.getIAMEntityURL) - } - - // Otherwise, validate the hostname looks like a known IAM endpoint. - host := t.parsedIAMEntityURL.Hostname() - if strings.HasSuffix(host, ".amazonaws.com") && - (strings.HasPrefix(host, "iam.") || strings.HasPrefix(host, "iam-fips.")) { - return nil - } - return fmt.Errorf("invalid IAM hostname: %q", host) -} - -// https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1439 -func (t *BearerToken) validateGetCallerIdentityBody() error { - allowedValues := url.Values{ - "Action": []string{"GetCallerIdentity"}, - // Will assume for now that future versions don't change - // the semantics - "Version": nil, // any value is allowed - } - if _, err := parseRequestBody(t.getCallerIdentityBody, allowedValues); err != nil { - return fmt.Errorf("iam_request_body error: %s", err) - } - - return nil -} - -func (t *BearerToken) validateIAMEntityBody() (string, error) { - allowedValues := url.Values{ - "Action": []string{"GetRole", "GetUser"}, - "RoleName": nil, // any value is allowed - "UserName": nil, - "Version": nil, - } - body, err := parseRequestBody(t.getIAMEntityBody, allowedValues) - if err != nil { - return "", fmt.Errorf("iam_request_headers[%s] error: %s", t.config.GetEntityBodyHeader, err) - } - - // Disallow GetRole+UserName and GetUser+RoleName. - action := body["Action"][0] - _, hasRoleName := body["RoleName"] - _, hasUserName := body["UserName"] - if action == "GetUser" && hasUserName && !hasRoleName { - return action, nil - } else if action == "GetRole" && hasRoleName && !hasUserName { - return action, nil - } - return "", fmt.Errorf("iam_request_headers[%q] error: invalid request body %q", t.config.GetEntityBodyHeader, t.getIAMEntityBody) -} - -// parseRequestBody parses the AWS STS or IAM request body, such as 'Action=GetRole&RoleName=my-role'. -// It returns the parsed values, or an error if there are unexpected fields based on allowedValues. -// -// A key-value pair in the body is allowed if: -// - It is a single value (i.e. no bodies like 'Action=1&Action=2') -// - allowedValues[key] is an empty slice or nil (any value is allowed for the key) -// - allowedValues[key] is non-empty and contains the exact value -// This always requires an 'Action' field is present and non-empty. -func parseRequestBody(body string, allowedValues url.Values) (url.Values, error) { - qs, err := url.ParseQuery(body) - if err != nil { - return nil, err - } - - // Action field is always required. - if _, ok := qs["Action"]; !ok || len(qs["Action"]) == 0 || qs["Action"][0] == "" { - return nil, fmt.Errorf(`missing field "Action"`) - } - - // Ensure the body does not have extra fields and each - // field in the body matches the allowed values. - for k, v := range qs { - exp, ok := allowedValues[k] - if k != "Action" && !ok { - return nil, fmt.Errorf("unexpected field %q", k) - } - - if len(exp) == 0 { - // empty indicates any value is okay - continue - } else if len(v) != 1 || !stringslice.Contains(exp, v[0]) { - return nil, fmt.Errorf("unexpected value %s=%v", k, v) - } - } - - return qs, nil -} - -// https://github.com/hashicorp/vault/blob/861454e0ed1390d67ddaf1a53c1798e5e291728c/builtin/credential/aws/path_config_client.go#L349 -func (t *BearerToken) validateAllowedSTSHeaderValues() error { - for k := range t.getCallerIdentityHeader { - h := textproto.CanonicalMIMEHeaderKey(k) - if strings.HasPrefix(h, amzHeaderPrefix) && - !stringslice.Contains(defaultAllowedSTSRequestHeaders, h) && - !stringslice.Contains(t.config.AllowedSTSHeaderValues, h) { - return fmt.Errorf("invalid request header: %s", h) - } - } - return nil -} - -// UnmarshalJSON unmarshals the bearer token details which contains an HTTP -// request (a signed sts:GetCallerIdentity request). -func (t *BearerToken) UnmarshalJSON(data []byte) error { - var rawData struct { - Method string `json:"iam_http_request_method"` - UrlBase64 string `json:"iam_request_url"` - HeadersBase64 string `json:"iam_request_headers"` - BodyBase64 string `json:"iam_request_body"` - } - - if err := json.Unmarshal(data, &rawData); err != nil { - return err - } - - rawUrl, err := base64.StdEncoding.DecodeString(rawData.UrlBase64) - if err != nil { - return err - } - - headersJson, err := base64.StdEncoding.DecodeString(rawData.HeadersBase64) - if err != nil { - return err - } - - var headers http.Header - // This is a JSON-string in JSON - if err := json.Unmarshal(headersJson, &headers); err != nil { - return err - } - - body, err := base64.StdEncoding.DecodeString(rawData.BodyBase64) - if err != nil { - return err - } - - t.getCallerIdentityMethod = rawData.Method - t.getCallerIdentityBody = string(body) - t.getCallerIdentityHeader = headers - t.getCallerIdentityURL = string(rawUrl) - - parsedUrl, err := parseUrl(t.getCallerIdentityURL) - if err != nil { - return err - } - t.parsedCallerIdentityURL = parsedUrl - return nil -} - -func parseUrl(s string) (*url.URL, error) { - u, err := url.Parse(s) - if err != nil { - return nil, err - } - // url.Parse doesn't error on empty string - if u == nil || u.Scheme == "" || u.Host == "" { - return nil, fmt.Errorf("url is invalid: %q", s) - } - return u, nil -} - -// GetCallerIdentityRequest returns the sts:GetCallerIdentity request decoded -// from the bearer token. -func (t *BearerToken) GetCallerIdentityRequest() (*http.Request, error) { - // NOTE: We need to ensure we're calling STS, instead of acting as an unintended network proxy - // We validate up-front that t.getCallerIdentityURL is a known AWS STS hostname. - // Otherwise, we send to the admin-configured STSEndpoint. - endpoint := t.getCallerIdentityURL - if t.config.STSEndpoint != "" { - endpoint = t.config.STSEndpoint - } - - return buildHttpRequest( - t.getCallerIdentityMethod, - endpoint, - t.parsedCallerIdentityURL, - t.getCallerIdentityBody, - t.getCallerIdentityHeader, - ) -} - -// GetEntityRequest returns the iam:GetUser or iam:GetRole request from the request details, -// if present, embedded in the headers of the sts:GetCallerIdentity request. -func (t *BearerToken) GetEntityRequest() (*http.Request, error) { - endpoint := t.getIAMEntityURL - if t.config.IAMEndpoint != "" { - endpoint = t.config.IAMEndpoint - } - - return buildHttpRequest( - t.getIAMEntityMethod, - endpoint, - t.parsedIAMEntityURL, - t.getIAMEntityBody, - t.getIAMEntityHeader, - ) -} - -// getHeader returns the header from s.GetCallerIdentityHeader, or an error if -// the header is not found or is not a single value. -func (t *BearerToken) getHeader(name string) (string, error) { - values := t.getCallerIdentityHeader.Values(name) - if len(values) == 0 { - return "", fmt.Errorf("missing header %q", name) - } - if len(values) != 1 { - return "", fmt.Errorf("invalid value for header %q (expected 1 item)", name) - } - return values[0], nil -} - -// buildHttpRequest returns an HTTP request from the given details. -// This supports sending to a custom endpoint, but always preserves the -// Host header and URI path, which are signed and cannot be modified. -// There's a deeper explanation of this in the Vault source code. -// https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1569 -func buildHttpRequest(method, endpoint string, parsedUrl *url.URL, body string, headers http.Header) (*http.Request, error) { - targetUrl := fmt.Sprintf("%s%s", endpoint, parsedUrl.RequestURI()) - request, err := http.NewRequest(method, targetUrl, strings.NewReader(body)) - if err != nil { - return nil, err - } - request.Host = parsedUrl.Host - for k, vals := range headers { - for _, val := range vals { - request.Header.Add(k, val) - } - } - return request, nil -} diff --git a/internal/iamauth/token_test.go b/internal/iamauth/token_test.go deleted file mode 100644 index 42f81151dd..0000000000 --- a/internal/iamauth/token_test.go +++ /dev/null @@ -1,483 +0,0 @@ -package iamauth - -import ( - "net/http" - "net/url" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewBearerToken(t *testing.T) { - cases := map[string]struct { - tokenStr string - config Config - expToken BearerToken - expError string - }{ - "valid token": { - tokenStr: validBearerTokenJson, - expToken: validBearerTokenParsed, - }, - "valid token with role": { - tokenStr: validBearerTokenWithRoleJson, - config: Config{ - EnableIAMEntityDetails: true, - GetEntityMethodHeader: "X-Consul-IAM-GetEntity-Method", - GetEntityURLHeader: "X-Consul-IAM-GetEntity-URL", - GetEntityHeadersHeader: "X-Consul-IAM-GetEntity-Headers", - GetEntityBodyHeader: "X-Consul-IAM-GetEntity-Body", - STSEndpoint: validBearerTokenParsed.getCallerIdentityURL, - }, - expToken: validBearerTokenWithRoleParsed, - }, - - "empty json": { - tokenStr: `{}`, - expError: "unexpected end of JSON input", - }, - "missing iam_request_method field": { - tokenStr: tokenJsonMissingMethodField, - expError: "iam_http_request_method must be POST", - }, - "missing iam_request_url field": { - tokenStr: tokenJsonMissingUrlField, - expError: "url is invalid", - }, - "missing iam_request_headers field": { - tokenStr: tokenJsonMissingHeadersField, - expError: "unexpected end of JSON input", - }, - "missing iam_request_body field": { - tokenStr: tokenJsonMissingBodyField, - expError: "iam_request_body error", - }, - "invalid json": { - tokenStr: `{`, - expError: "unexpected end of JSON input", - }, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - token, err := NewBearerToken(c.tokenStr, &c.config) - t.Logf("token = %+v", token) - if c.expError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), c.expError) - require.Nil(t, token) - } else { - require.NoError(t, err) - c.expToken.config = &c.config - require.Equal(t, &c.expToken, token) - } - }) - } -} - -func TestParseRequestBody(t *testing.T) { - cases := map[string]struct { - body string - allowedValues url.Values - expValues url.Values - expError string - }{ - "one allowed field": { - body: "Action=GetCallerIdentity&Version=1234", - allowedValues: url.Values{"Version": []string{"1234"}}, - expValues: url.Values{ - "Action": []string{"GetCallerIdentity"}, - "Version": []string{"1234"}, - }, - }, - "many allowed fields": { - body: "Action=GetRole&RoleName=my-role&Version=1234", - allowedValues: url.Values{ - "Action": []string{"GetUser", "GetRole"}, - "UserName": nil, - "RoleName": nil, - "Version": nil, - }, - expValues: url.Values{ - "Action": []string{"GetRole"}, - "RoleName": []string{"my-role"}, - "Version": []string{"1234"}, - }, - }, - "action only": { - body: "Action=GetRole", - allowedValues: nil, - expValues: url.Values{"Action": []string{"GetRole"}}, - }, - - "empty body": { - expValues: url.Values{}, - expError: `missing field "Action"`, - }, - "disallowed field": { - body: "Action=GetRole&Version=1234&Extra=Abc", - allowedValues: url.Values{"Action": nil, "Version": nil}, - expError: `unexpected field "Extra"`, - }, - "mismatched action": { - body: "Action=GetRole", - allowedValues: url.Values{"Action": []string{"GetUser"}}, - expError: `unexpected value Action=[GetRole]`, - }, - "mismatched field": { - body: "Action=GetRole&Extra=1234", - allowedValues: url.Values{"Action": nil, "Extra": []string{"abc"}}, - expError: `unexpected value Extra=[1234]`, - }, - "multi-valued field": { - body: "Action=GetRole&Action=GetUser", - allowedValues: url.Values{"Action": []string{"GetRole", "GetUser"}}, - // only one value is allowed. - expError: `unexpected value Action=[GetRole GetUser]`, - }, - "empty action": { - body: "Action=", - allowedValues: nil, - expError: `missing field "Action"`, - }, - "missing action": { - body: "Version=1234", - allowedValues: url.Values{"Action": []string{"GetRole"}}, - expError: `missing field "Action"`, - }, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - values, err := parseRequestBody(c.body, c.allowedValues) - if c.expError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), c.expError) - require.Nil(t, values) - } else { - require.NoError(t, err) - require.Equal(t, c.expValues, values) - } - }) - } -} - -func TestValidateGetCallerIdentityBody(t *testing.T) { - cases := map[string]struct { - body string - expError string - }{ - "valid": {"Action=GetCallerIdentity&Version=1234", ""}, - "valid 2": {"Action=GetCallerIdentity", ""}, - "empty action": { - "Action=", - `iam_request_body error: missing field "Action"`, - }, - "invalid action": { - "Action=GetRole", - `iam_request_body error: unexpected value Action=[GetRole]`, - }, - "missing action": { - "Version=1234", - `iam_request_body error: missing field "Action"`, - }, - "empty": { - "", - `iam_request_body error: missing field "Action"`, - }, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - token := &BearerToken{getCallerIdentityBody: c.body} - err := token.validateGetCallerIdentityBody() - if c.expError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), c.expError) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestValidateIAMEntityBody(t *testing.T) { - cases := map[string]struct { - body string - expReqType string - expError string - }{ - "valid role": { - body: "Action=GetRole&RoleName=my-role&Version=1234", - expReqType: "GetRole", - }, - "valid role without version": { - body: "Action=GetRole&RoleName=my-role", - expReqType: "GetRole", - }, - "valid user": { - body: "Action=GetUser&UserName=my-role&Version=1234", - expReqType: "GetUser", - }, - "valid user without version": { - body: "Action=GetUser&UserName=my-role", - expReqType: "GetUser", - }, - - "invalid action": { - body: "Action=GetCallerIdentity", - expError: `unexpected value Action=[GetCallerIdentity]`, - }, - "role missing action": { - body: "RoleName=my-role&Version=1234", - expError: `missing field "Action"`, - }, - "user missing action": { - body: "UserName=my-role&Version=1234", - expError: `missing field "Action"`, - }, - "empty": { - body: "", - expError: `missing field "Action"`, - }, - "empty action": { - body: "Action=", - expError: `missing field "Action"`, - }, - "role with user name": { - body: "Action=GetRole&UserName=my-role&Version=1234", - expError: `invalid request body`, - }, - "user with role name": { - body: "Action=GetUser&RoleName=my-role&Version=1234", - expError: `invalid request body`, - }, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - token := &BearerToken{ - config: &Config{}, - getIAMEntityBody: c.body, - } - reqType, err := token.validateIAMEntityBody() - if c.expError != "" { - require.Error(t, err) - require.Contains(t, err.Error(), c.expError) - require.Equal(t, "", reqType) - } else { - require.NoError(t, err) - require.Equal(t, c.expReqType, reqType) - } - }) - } -} - -func TestValidateSTSHostname(t *testing.T) { - cases := []struct { - url string - ok bool - }{ - // https://docs.aws.amazon.com/general/latest/gr/sts.html - {"sts.us-east-2.amazonaws.com", true}, - {"sts-fips.us-east-2.amazonaws.com", true}, - {"sts.us-east-1.amazonaws.com", true}, - {"sts-fips.us-east-1.amazonaws.com", true}, - {"sts.us-west-1.amazonaws.com", true}, - {"sts-fips.us-west-1.amazonaws.com", true}, - {"sts.us-west-2.amazonaws.com", true}, - {"sts-fips.us-west-2.amazonaws.com", true}, - {"sts.af-south-1.amazonaws.com", true}, - {"sts.ap-east-1.amazonaws.com", true}, - {"sts.ap-southeast-3.amazonaws.com", true}, - {"sts.ap-south-1.amazonaws.com", true}, - {"sts.ap-northeast-3.amazonaws.com", true}, - {"sts.ap-northeast-2.amazonaws.com", true}, - {"sts.ap-southeast-1.amazonaws.com", true}, - {"sts.ap-southeast-2.amazonaws.com", true}, - {"sts.ap-northeast-1.amazonaws.com", true}, - {"sts.ca-central-1.amazonaws.com", true}, - {"sts.eu-central-1.amazonaws.com", true}, - {"sts.eu-west-1.amazonaws.com", true}, - {"sts.eu-west-2.amazonaws.com", true}, - {"sts.eu-south-1.amazonaws.com", true}, - {"sts.eu-west-3.amazonaws.com", true}, - {"sts.eu-north-1.amazonaws.com", true}, - {"sts.me-south-1.amazonaws.com", true}, - {"sts.sa-east-1.amazonaws.com", true}, - {"sts.us-gov-east-1.amazonaws.com", true}, - {"sts.us-gov-west-1.amazonaws.com", true}, - - // prefix must be either 'sts.' or 'sts-fips.' - {".amazonaws.com", false}, - {"iam.amazonaws.com", false}, - {"other.amazonaws.com", false}, - // suffix must be '.amazonaws.com' and not some other domain - {"stsamazonaws.com", false}, - {"sts-fipsamazonaws.com", false}, - {"sts.stsamazonaws.com", false}, - {"sts.notamazonaws.com", false}, - {"sts-fips.stsamazonaws.com", false}, - {"sts-fips.notamazonaws.com", false}, - {"sts.amazonaws.com.spoof", false}, - {"sts.amazonaws.spoof.com", false}, - {"xyz.sts.amazonaws.com", false}, - } - for _, c := range cases { - t.Run(c.url, func(t *testing.T) { - url := "https://" + c.url - parsedUrl, err := parseUrl(url) - require.NoError(t, err) - - token := &BearerToken{ - config: &Config{}, - getCallerIdentityURL: url, - parsedCallerIdentityURL: parsedUrl, - } - err = token.validateSTSHostname() - if c.ok { - require.NoError(t, err) - } else { - require.Error(t, err) - } - }) - } -} - -func TestValidateIAMHostname(t *testing.T) { - cases := []struct { - url string - ok bool - }{ - // https://docs.aws.amazon.com/general/latest/gr/iam-service.html - {"iam.amazonaws.com", true}, - {"iam-fips.amazonaws.com", true}, - {"iam.us-gov.amazonaws.com", true}, - {"iam-fips.us-gov.amazonaws.com", true}, - - // prefix must be either 'iam.' or 'aim-fips.' - {".amazonaws.com", false}, - {"sts.amazonaws.com", false}, - {"other.amazonaws.com", false}, - // suffix must be '.amazonaws.com' and not some other domain - {"iamamazonaws.com", false}, - {"iam-fipsamazonaws.com", false}, - {"iam.iamamazonaws.com", false}, - {"iam.notamazonaws.com", false}, - {"iam-fips.iamamazonaws.com", false}, - {"iam-fips.notamazonaws.com", false}, - {"iam.amazonaws.com.spoof", false}, - {"iam.amazonaws.spoof.com", false}, - {"xyz.iam.amazonaws.com", false}, - } - for _, c := range cases { - t.Run(c.url, func(t *testing.T) { - url := "https://" + c.url - parsedUrl, err := parseUrl(url) - require.NoError(t, err) - - token := &BearerToken{ - config: &Config{}, - getCallerIdentityURL: url, - parsedIAMEntityURL: parsedUrl, - } - err = token.validateIAMHostname() - if c.ok { - require.NoError(t, err) - } else { - require.Error(t, err) - } - }) - } -} - -var ( - validBearerTokenJson = `{ - "iam_http_request_method":"POST", - "iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==", - "iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLzIwMjIwMzIyL3VzLWVhc3QtMS9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1hbXotc2VjdXJpdHktdG9rZW4sIFNpZ25hdHVyZT1lZmMzMjBiOTcyZDA3YjM4YjY1ZWIyNDI1NjgwNWUwMzE0OWRhNTg2ZDgwNGY4YzYzNjRjZTk4ZGViZTA4MGIxIl0sIkNvbnRlbnQtTGVuZ3RoIjpbIjQzIl0sIkNvbnRlbnQtVHlwZSI6WyJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQ7IGNoYXJzZXQ9dXRmLTgiXSwiVXNlci1BZ2VudCI6WyJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KSJdLCJYLUFtei1EYXRlIjpbIjIwMjIwMzIyVDIxMTEwM1oiXSwiWC1BbXotU2VjdXJpdHktVG9rZW4iOlsiZmFrZSJdfQ==", - "iam_request_url":"aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8=" -}` - - validBearerTokenParsed = BearerToken{ - getCallerIdentityMethod: "POST", - getCallerIdentityURL: "https://sts.amazonaws.com/", - getCallerIdentityHeader: http.Header{ - "Authorization": []string{"AWS4-HMAC-SHA256 Credential=fake/20220322/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-security-token, Signature=efc320b972d07b38b65eb24256805e03149da586d804f8c6364ce98debe080b1"}, - "Content-Length": []string{"43"}, - "Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}, - "User-Agent": []string{"aws-sdk-go/1.42.34 (go1.17.5; darwin; amd64)"}, - "X-Amz-Date": []string{"20220322T211103Z"}, - "X-Amz-Security-Token": []string{"fake"}, - }, - getCallerIdentityBody: "Action=GetCallerIdentity&Version=2011-06-15", - parsedCallerIdentityURL: &url.URL{ - Scheme: "https", - Host: "sts.amazonaws.com", - Path: "/", - }, - } - - validBearerTokenWithRoleJson = `{"iam_http_request_method":"POST","iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==","iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLWtleS1pZC8yMDIyMDMyMi9mYWtlLXJlZ2lvbi9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1jb25zdWwtaWFtLWdldGVudGl0eS1ib2R5O3gtY29uc3VsLWlhbS1nZXRlbnRpdHktaGVhZGVyczt4LWNvbnN1bC1pYW0tZ2V0ZW50aXR5LW1ldGhvZDt4LWNvbnN1bC1pYW0tZ2V0ZW50aXR5LXVybCwgU2lnbmF0dXJlPTU2MWFjMzFiNWFkMDFjMTI0YzU0YzE2OGY3NmVhNmJmZDY0NWI4ZWM1MzQ1ZjgzNTc3MjljOWFhMGI0NzEzMzciXSwiQ29udGVudC1MZW5ndGgiOlsiNDMiXSwiQ29udGVudC1UeXBlIjpbImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZDsgY2hhcnNldD11dGYtOCJdLCJVc2VyLUFnZW50IjpbImF3cy1zZGstZ28vMS40Mi4zNCAoZ28xLjE3LjU7IGRhcndpbjsgYW1kNjQpIl0sIlgtQW16LURhdGUiOlsiMjAyMjAzMjJUMjI1NzQyWiJdLCJYLUNvbnN1bC1JYW0tR2V0ZW50aXR5LUJvZHkiOlsiQWN0aW9uPUdldFJvbGVcdTAwMjZSb2xlTmFtZT1teS1yb2xlXHUwMDI2VmVyc2lvbj0yMDEwLTA1LTA4Il0sIlgtQ29uc3VsLUlhbS1HZXRlbnRpdHktSGVhZGVycyI6WyJ7XCJBdXRob3JpemF0aW9uXCI6W1wiQVdTNC1ITUFDLVNIQTI1NiBDcmVkZW50aWFsPWZha2Uta2V5LWlkLzIwMjIwMzIyL3VzLWVhc3QtMS9pYW0vYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGUsIFNpZ25hdHVyZT1hYTJhMTlkMGEzMDVkNzRiYmQwMDk3NzZiY2E4ODBlNTNjZmE5OTFlNDgzZTQwMzk0NzE4MWE0MWNjNDgyOTQwXCJdLFwiQ29udGVudC1MZW5ndGhcIjpbXCI1MFwiXSxcIkNvbnRlbnQtVHlwZVwiOltcImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZDsgY2hhcnNldD11dGYtOFwiXSxcIlVzZXItQWdlbnRcIjpbXCJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KVwiXSxcIlgtQW16LURhdGVcIjpbXCIyMDIyMDMyMlQyMjU3NDJaXCJdfSJdLCJYLUNvbnN1bC1JYW0tR2V0ZW50aXR5LU1ldGhvZCI6WyJQT1NUIl0sIlgtQ29uc3VsLUlhbS1HZXRlbnRpdHktVXJsIjpbImh0dHBzOi8vaWFtLmFtYXpvbmF3cy5jb20vIl19","iam_request_url":"aHR0cDovLzEyNy4wLjAuMTo2MzY5Ni9zdHMv"}` - - validBearerTokenWithRoleParsed = BearerToken{ - getCallerIdentityMethod: "POST", - getCallerIdentityURL: "http://127.0.0.1:63696/sts/", - getCallerIdentityHeader: http.Header{ - "Authorization": []string{"AWS4-HMAC-SHA256 Credential=fake-key-id/20220322/fake-region/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-consul-iam-getentity-body;x-consul-iam-getentity-headers;x-consul-iam-getentity-method;x-consul-iam-getentity-url, Signature=561ac31b5ad01c124c54c168f76ea6bfd645b8ec5345f8357729c9aa0b471337"}, - "Content-Length": []string{"43"}, - "Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}, - "User-Agent": []string{"aws-sdk-go/1.42.34 (go1.17.5; darwin; amd64)"}, - "X-Amz-Date": []string{"20220322T225742Z"}, - "X-Consul-Iam-Getentity-Body": []string{"Action=GetRole&RoleName=my-role&Version=2010-05-08"}, - "X-Consul-Iam-Getentity-Headers": []string{`{"Authorization":["AWS4-HMAC-SHA256 Credential=fake-key-id/20220322/us-east-1/iam/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date, Signature=aa2a19d0a305d74bbd009776bca880e53cfa991e483e403947181a41cc482940"],"Content-Length":["50"],"Content-Type":["application/x-www-form-urlencoded; charset=utf-8"],"User-Agent":["aws-sdk-go/1.42.34 (go1.17.5; darwin; amd64)"],"X-Amz-Date":["20220322T225742Z"]}`}, - "X-Consul-Iam-Getentity-Method": []string{"POST"}, - "X-Consul-Iam-Getentity-Url": []string{"https://iam.amazonaws.com/"}, - }, - getCallerIdentityBody: "Action=GetCallerIdentity&Version=2011-06-15", - - // Fields parsed from headers above - getIAMEntityMethod: "POST", - getIAMEntityURL: "https://iam.amazonaws.com/", - getIAMEntityHeader: http.Header{ - "Authorization": []string{"AWS4-HMAC-SHA256 Credential=fake-key-id/20220322/us-east-1/iam/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date, Signature=aa2a19d0a305d74bbd009776bca880e53cfa991e483e403947181a41cc482940"}, - "Content-Length": []string{"50"}, - "Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}, - "User-Agent": []string{"aws-sdk-go/1.42.34 (go1.17.5; darwin; amd64)"}, - "X-Amz-Date": []string{"20220322T225742Z"}, - }, - getIAMEntityBody: "Action=GetRole&RoleName=my-role&Version=2010-05-08", - entityRequestType: "GetRole", - - parsedCallerIdentityURL: &url.URL{ - Scheme: "http", - Host: "127.0.0.1:63696", - Path: "/sts/", - }, - parsedIAMEntityURL: &url.URL{ - Scheme: "https", - Host: "iam.amazonaws.com", - Path: "/", - }, - } - - tokenJsonMissingMethodField = `{ - "iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==", - "iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLzIwMjIwMzIyL3VzLWVhc3QtMS9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1hbXotc2VjdXJpdHktdG9rZW4sIFNpZ25hdHVyZT1lZmMzMjBiOTcyZDA3YjM4YjY1ZWIyNDI1NjgwNWUwMzE0OWRhNTg2ZDgwNGY4YzYzNjRjZTk4ZGViZTA4MGIxIl0sIkNvbnRlbnQtTGVuZ3RoIjpbIjQzIl0sIkNvbnRlbnQtVHlwZSI6WyJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQ7IGNoYXJzZXQ9dXRmLTgiXSwiVXNlci1BZ2VudCI6WyJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KSJdLCJYLUFtei1EYXRlIjpbIjIwMjIwMzIyVDIxMTEwM1oiXSwiWC1BbXotU2VjdXJpdHktVG9rZW4iOlsiZmFrZSJdfQ==", - "iam_request_url":"aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8=" -}` - - tokenJsonMissingBodyField = `{ - "iam_http_request_method":"POST", - "iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLzIwMjIwMzIyL3VzLWVhc3QtMS9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1hbXotc2VjdXJpdHktdG9rZW4sIFNpZ25hdHVyZT1lZmMzMjBiOTcyZDA3YjM4YjY1ZWIyNDI1NjgwNWUwMzE0OWRhNTg2ZDgwNGY4YzYzNjRjZTk4ZGViZTA4MGIxIl0sIkNvbnRlbnQtTGVuZ3RoIjpbIjQzIl0sIkNvbnRlbnQtVHlwZSI6WyJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQ7IGNoYXJzZXQ9dXRmLTgiXSwiVXNlci1BZ2VudCI6WyJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KSJdLCJYLUFtei1EYXRlIjpbIjIwMjIwMzIyVDIxMTEwM1oiXSwiWC1BbXotU2VjdXJpdHktVG9rZW4iOlsiZmFrZSJdfQ==", - "iam_request_url":"aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8=" -}` - - tokenJsonMissingHeadersField = `{ - "iam_http_request_method":"POST", - "iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==", - "iam_request_url":"aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8=" -}` - - tokenJsonMissingUrlField = `{ - "iam_http_request_method":"POST", - "iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==", - "iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLzIwMjIwMzIyL3VzLWVhc3QtMS9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1hbXotc2VjdXJpdHktdG9rZW4sIFNpZ25hdHVyZT1lZmMzMjBiOTcyZDA3YjM4YjY1ZWIyNDI1NjgwNWUwMzE0OWRhNTg2ZDgwNGY4YzYzNjRjZTk4ZGViZTA4MGIxIl0sIkNvbnRlbnQtTGVuZ3RoIjpbIjQzIl0sIkNvbnRlbnQtVHlwZSI6WyJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQ7IGNoYXJzZXQ9dXRmLTgiXSwiVXNlci1BZ2VudCI6WyJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KSJdLCJYLUFtei1EYXRlIjpbIjIwMjIwMzIyVDIxMTEwM1oiXSwiWC1BbXotU2VjdXJpdHktVG9rZW4iOlsiZmFrZSJdfQ==" -}` -) diff --git a/internal/iamauth/util.go b/internal/iamauth/util.go deleted file mode 100644 index b92270cfdc..0000000000 --- a/internal/iamauth/util.go +++ /dev/null @@ -1,143 +0,0 @@ -package iamauth - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "io/ioutil" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/endpoints" - "github.com/aws/aws-sdk-go/aws/request" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/iam" - "github.com/aws/aws-sdk-go/service/sts" - "github.com/hashicorp/consul/internal/iamauth/responses" - "github.com/hashicorp/go-hclog" -) - -type LoginInput struct { - Creds *credentials.Credentials - IncludeIAMEntity bool - STSEndpoint string - STSRegion string - - Logger hclog.Logger - - ServerIDHeaderValue string - // Customizable header names - ServerIDHeaderName string - GetEntityMethodHeader string - GetEntityURLHeader string - GetEntityHeadersHeader string - GetEntityBodyHeader string -} - -// GenerateLoginData populates the necessary data to send for the bearer token. -// https://github.com/hashicorp/go-secure-stdlib/blob/main/awsutil/generate_credentials.go#L232-L301 -func GenerateLoginData(in *LoginInput) (map[string]interface{}, error) { - cfg := aws.Config{ - Credentials: in.Creds, - // These are empty strings by default (i.e. not enabled) - Region: aws.String(in.STSRegion), - Endpoint: aws.String(in.STSEndpoint), - STSRegionalEndpoint: endpoints.RegionalSTSEndpoint, - } - - stsSession, err := session.NewSessionWithOptions(session.Options{Config: cfg}) - if err != nil { - return nil, err - } - - svc := sts.New(stsSession) - stsRequest, _ := svc.GetCallerIdentityRequest(nil) - - // Include the iam:GetRole or iam:GetUser request in headers. - if in.IncludeIAMEntity { - entityRequest, err := formatSignedEntityRequest(svc, in) - if err != nil { - return nil, err - } - - headersJson, err := json.Marshal(entityRequest.HTTPRequest.Header) - if err != nil { - return nil, err - } - requestBody, err := ioutil.ReadAll(entityRequest.HTTPRequest.Body) - if err != nil { - return nil, err - } - - stsRequest.HTTPRequest.Header.Add(in.GetEntityMethodHeader, entityRequest.HTTPRequest.Method) - stsRequest.HTTPRequest.Header.Add(in.GetEntityURLHeader, entityRequest.HTTPRequest.URL.String()) - stsRequest.HTTPRequest.Header.Add(in.GetEntityHeadersHeader, string(headersJson)) - stsRequest.HTTPRequest.Header.Add(in.GetEntityBodyHeader, string(requestBody)) - } - - // Inject the required auth header value, if supplied, and then sign the request including that header - if in.ServerIDHeaderValue != "" { - stsRequest.HTTPRequest.Header.Add(in.ServerIDHeaderName, in.ServerIDHeaderValue) - } - - stsRequest.Sign() - - // Now extract out the relevant parts of the request - headersJson, err := json.Marshal(stsRequest.HTTPRequest.Header) - if err != nil { - return nil, err - } - requestBody, err := ioutil.ReadAll(stsRequest.HTTPRequest.Body) - if err != nil { - return nil, err - } - - return map[string]interface{}{ - "iam_http_request_method": stsRequest.HTTPRequest.Method, - "iam_request_url": base64.StdEncoding.EncodeToString([]byte(stsRequest.HTTPRequest.URL.String())), - "iam_request_headers": base64.StdEncoding.EncodeToString(headersJson), - "iam_request_body": base64.StdEncoding.EncodeToString(requestBody), - }, nil -} - -func formatSignedEntityRequest(svc *sts.STS, in *LoginInput) (*request.Request, error) { - // We need to retrieve the IAM user or role for the iam:GetRole or iam:GetUser request. - // GetCallerIdentity returns this and requires no permissions. - resp, err := svc.GetCallerIdentity(nil) - if err != nil { - return nil, err - } - - arn, err := responses.ParseArn(*resp.Arn) - if err != nil { - return nil, err - } - - iamSession, err := session.NewSessionWithOptions(session.Options{ - Config: aws.Config{ - Credentials: svc.Config.Credentials, - }, - }) - if err != nil { - return nil, err - } - iamSvc := iam.New(iamSession) - - var req *request.Request - switch arn.Type { - case "role", "assumed-role": - req, _ = iamSvc.GetRoleRequest(&iam.GetRoleInput{RoleName: &arn.FriendlyName}) - case "user": - req, _ = iamSvc.GetUserRequest(&iam.GetUserInput{UserName: &arn.FriendlyName}) - default: - return nil, fmt.Errorf("entity %s is not an IAM role or IAM user", arn.Type) - } - - // Inject the required auth header value, if supplied, and then sign the request including that header - if in.ServerIDHeaderValue != "" { - req.HTTPRequest.Header.Add(in.ServerIDHeaderName, in.ServerIDHeaderValue) - } - - req.Sign() - return req, nil -} diff --git a/lib/glob.go b/lib/glob.go deleted file mode 100644 index 969e3ab25c..0000000000 --- a/lib/glob.go +++ /dev/null @@ -1,24 +0,0 @@ -package lib - -import "strings" - -// GlobbedStringsMatch compares item to val with support for a leading and/or -// trailing wildcard '*' in item. -func GlobbedStringsMatch(item, val string) bool { - if len(item) < 2 { - return val == item - } - - hasPrefix := strings.HasPrefix(item, "*") - hasSuffix := strings.HasSuffix(item, "*") - - if hasPrefix && hasSuffix { - return strings.Contains(val, item[1:len(item)-1]) - } else if hasPrefix { - return strings.HasSuffix(val, item[1:]) - } else if hasSuffix { - return strings.HasPrefix(val, item[:len(item)-1]) - } - - return val == item -} diff --git a/lib/glob_test.go b/lib/glob_test.go deleted file mode 100644 index 6c29f5ef19..0000000000 --- a/lib/glob_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package lib - -import "testing" - -func TestGlobbedStringsMatch(t *testing.T) { - tests := []struct { - item string - val string - expect bool - }{ - {"", "", true}, - {"*", "*", true}, - {"**", "**", true}, - {"*t", "t", true}, - {"*t", "test", true}, - {"t*", "test", true}, - {"*test", "test", true}, - {"*test", "a test", true}, - {"test", "a test", false}, - {"*test", "tests", false}, - {"test*", "test", true}, - {"test*", "testsss", true}, - {"test**", "testsss", false}, - {"test**", "test*", true}, - {"**test", "*test", true}, - {"TEST", "test", false}, - {"test", "test", true}, - } - - for _, tt := range tests { - actual := GlobbedStringsMatch(tt.item, tt.val) - - if actual != tt.expect { - t.Fatalf("Bad testcase %#v, expected %t, got %t", tt, tt.expect, actual) - } - } -}