diff --git a/buildscripts/minio-upgrade.sh b/buildscripts/minio-upgrade.sh index 9904e7889..ed34dde5c 100644 --- a/buildscripts/minio-upgrade.sh +++ b/buildscripts/minio-upgrade.sh @@ -43,6 +43,9 @@ add_alias() { echo "...waiting... for 5secs" && sleep 5 done done + + echo "Sleeping for nginx" + sleep 20 } __init__() { diff --git a/cmd/auth-handler.go b/cmd/auth-handler.go index 4b971975c..d6b7ba884 100644 --- a/cmd/auth-handler.go +++ b/cmd/auth-handler.go @@ -245,9 +245,10 @@ func getClaimsFromToken(token string) (map[string]interface{}, error) { // Session token must have a policy, reject requests without policy // claim. - _, pokOpenID := claims.MapClaims[iamPolicyClaimNameOpenID()] + _, pokOpenIDClaimName := claims.MapClaims[iamPolicyClaimNameOpenID()] + _, pokOpenIDRoleArn := claims.MapClaims[roleArnClaim] _, pokSA := claims.MapClaims[iamPolicyClaimNameSA()] - if !pokOpenID && !pokSA { + if !pokOpenIDClaimName && !pokOpenIDRoleArn && !pokSA { return nil, errAuthentication } diff --git a/cmd/config-current.go b/cmd/config-current.go index 00cc18b0d..b1b59ec71 100644 --- a/cmd/config-current.go +++ b/cmd/config-current.go @@ -327,7 +327,7 @@ func validateConfig(s config.Config) error { } } if _, err := openid.LookupConfig(s[config.IdentityOpenIDSubSys][config.Default], - NewGatewayHTTPTransport(), xhttp.DrainBody); err != nil { + NewGatewayHTTPTransport(), xhttp.DrainBody, globalSite.Region); err != nil { return err } @@ -513,7 +513,7 @@ func lookupConfigs(s config.Config, objAPI ObjectLayer) { } globalOpenIDConfig, err = openid.LookupConfig(s[config.IdentityOpenIDSubSys][config.Default], - NewGatewayHTTPTransport(), xhttp.DrainBody) + NewGatewayHTTPTransport(), xhttp.DrainBody, globalSite.Region) if err != nil { logger.LogIf(ctx, fmt.Errorf("Unable to initialize OpenID: %w", err)) } diff --git a/cmd/iam.go b/cmd/iam.go index b2a575db7..47847e525 100644 --- a/cmd/iam.go +++ b/cmd/iam.go @@ -33,7 +33,9 @@ import ( humanize "github.com/dustin/go-humanize" "github.com/minio/madmin-go" "github.com/minio/minio-go/v7/pkg/set" + "github.com/minio/minio/internal/arn" "github.com/minio/minio/internal/auth" + "github.com/minio/minio/internal/color" "github.com/minio/minio/internal/logger" iampolicy "github.com/minio/pkg/iam/policy" etcd "go.etcd.io/etcd/client/v3" @@ -66,6 +68,8 @@ type IAMSys struct { usersSysType UsersSysType + rolesMap map[arn.ARN]string + // Persistence layer for IAM subsystem store *IAMStoreSys @@ -215,6 +219,7 @@ func (sys *IAMSys) Init(ctx context.Context, objAPI ObjectLayer, etcdClient *etc r := rand.New(rand.NewSource(time.Now().UnixNano())) + // Migrate storage format if needed. for { // let one of the server acquire the lock, if not let them timeout. // which shall be retried again by this loop. @@ -263,6 +268,7 @@ func (sys *IAMSys) Init(ctx context.Context, objAPI ObjectLayer, etcdClient *etc break } + // Load IAM data from storage. for { if err := sys.Load(retryCtx); err != nil { if configRetriableErrors(err) { @@ -308,7 +314,40 @@ func (sys *IAMSys) Init(ctx context.Context, objAPI ObjectLayer, etcdClient *etc }() } + // Start watching changes to storage. go sys.watch(ctx) + + // Load RoleARN + if roleARN, rolePolicy, enabled := globalOpenIDConfig.GetRoleInfo(); enabled { + numPolicies := len(strings.Split(rolePolicy, ",")) + validPolicies, _ := sys.store.FilterPolicies(rolePolicy, "") + numValidPolicies := len(strings.Split(validPolicies, ",")) + if numPolicies != numValidPolicies { + logger.LogIf(ctx, fmt.Errorf("Some specified role policies (%s) were not defined - role based policies will not be enabled.", rolePolicy)) + return + } + sys.rolesMap = map[arn.ARN]string{ + roleARN: rolePolicy, + } + } + + sys.printIAMRoles() +} + +// Prints IAM role ARNs. +func (sys *IAMSys) printIAMRoles() { + arns := sys.GetRoleARNs() + + if len(arns) == 0 { + return + } + + msgs := make([]string, 0, len(arns)) + for _, arn := range arns { + msgs = append(msgs, color.Bold(arn)) + } + + logStartupMessage(fmt.Sprintf("%s %s", color.Blue("IAM Roles:"), strings.Join(msgs, " "))) } // HasWatcher - returns if the IAM system has a watcher to be notified of @@ -389,6 +428,28 @@ func (sys *IAMSys) loadWatchedEvent(ctx context.Context, event iamWatchEvent) (e return err } +// GetRoleARNs - returns a list of enabled role ARNs. +func (sys *IAMSys) GetRoleARNs() []string { + var res []string + for arn := range sys.rolesMap { + res = append(res, arn.String()) + } + return res +} + +// GetRolePolicy - returns policies associated with a role ARN. +func (sys *IAMSys) GetRolePolicy(arnStr string) (string, error) { + arn, err := arn.Parse(arnStr) + if err != nil { + return "", fmt.Errorf("RoleARN parse err: %v", err) + } + rolePolicy, ok := sys.rolesMap[arn] + if !ok { + return "", fmt.Errorf("RoleARN %s is not defined.", arnStr) + } + return rolePolicy, nil +} + // DeletePolicy - deletes a canned policy from backend or etcd. func (sys *IAMSys) DeletePolicy(ctx context.Context, policyName string) error { if !sys.Initialized() { @@ -1029,7 +1090,7 @@ func (sys *IAMSys) IsAllowedServiceAccount(args iampolicy.Args, parentUser strin return false } - // Check policy for this service account. + // Check policy for parent user of service account. svcPolicies, err := sys.PolicyDBGet(parentUser, false, args.Groups...) if err != nil { logger.LogIf(GlobalContext, err) @@ -1037,9 +1098,20 @@ func (sys *IAMSys) IsAllowedServiceAccount(args iampolicy.Args, parentUser strin } if len(svcPolicies) == 0 { - // If parent user has no policies, look in OpenID claims in case it exists. - policySet, ok := iampolicy.GetPoliciesFromClaims(args.Claims, iamPolicyClaimNameOpenID()) - if ok { + // If parent user has no policies, check for OpenID + // claims/RoleARN in case it exists. + roleArn := args.GetRoleArn() + if roleArn != "" { + arn, err := arn.Parse(roleArn) + if err != nil { + logger.LogIf(GlobalContext, fmt.Errorf("error parsing role ARN %s: %v", roleArn, err)) + return false + } + svcPolicies = newMappedPolicy(sys.rolesMap[arn]).toSlice() + } else { + // If there is no roleArn claim, check the OpenID + // provider's policy claim. + policySet, _ := iampolicy.GetPoliciesFromClaims(args.Claims, iamPolicyClaimNameOpenID()) svcPolicies = policySet.ToSlice() } if len(svcPolicies) == 0 { @@ -1156,35 +1228,49 @@ func (sys *IAMSys) IsAllowedSTS(args iampolicy.Args, parentUser string) bool { return sys.IsAllowedLDAPSTS(args, parentUser) } - policies, ok := args.GetPolicies(iamPolicyClaimNameOpenID()) - if !ok { - // When claims are set, it should have a policy claim field. - return false + var policies []string + roleArn := args.GetRoleArn() + if roleArn != "" { + arn, err := arn.Parse(roleArn) + if err != nil { + logger.LogIf(GlobalContext, fmt.Errorf("error parsing role ARN %s: %v", roleArn, err)) + return false + } + policies = newMappedPolicy(sys.rolesMap[arn]).toSlice() + } else { + // If roleArn is not used, we fall back to using policy claim + // from JWT. + policySet, ok := args.GetPolicies(iamPolicyClaimNameOpenID()) + if !ok { + // When claims are set, it should have a policy claim field. + return false + } + // When claims are set, it should have policies as claim. + if policySet.IsEmpty() { + // No policy, no access! + return false + } + + // If policy is available for given user, check the policy. + mp, ok := sys.store.GetMappedPolicy(args.AccountName, false) + if !ok { + // No policy set for the user that we can find, no access! + return false + } + + if !policySet.Equals(mp.policySet()) { + // When claims has a policy, it should match the + // policy of args.AccountName which server remembers. + // if not reject such requests. + return false + } + + policies = policySet.ToSlice() } - // When claims are set, it should have policies as claim. - if policies.IsEmpty() { - // No policy, no access! - return false - } - - // If policy is available for given user, check the policy. - mp, ok := sys.store.GetMappedPolicy(args.AccountName, false) - if !ok { - // No policy set for the user that we can find, no access! - return false - } - - if !policies.Equals(mp.policySet()) { - // When claims has a policy, it should match the - // policy of args.AccountName which server remembers. - // if not reject such requests. - return false - } - - combinedPolicy, err := sys.store.GetPolicy(strings.Join(policies.ToSlice(), ",")) + combinedPolicy, err := sys.store.GetPolicy(strings.Join(policies, ",")) if err == errNoSuchPolicy { - for pname := range policies { + for _, pname := range policies { _, err := sys.store.GetPolicy(pname) if err == errNoSuchPolicy { // all policies presented in the claim should exist diff --git a/cmd/sts-handlers.go b/cmd/sts-handlers.go index 468778f05..72b0dcae0 100644 --- a/cmd/sts-handlers.go +++ b/cmd/sts-handlers.go @@ -45,6 +45,7 @@ const ( stsAction = "Action" stsPolicy = "Policy" stsToken = "Token" + stsRoleArn = "RoleArn" stsWebIdentityToken = "WebIdentityToken" stsWebIdentityAccessToken = "WebIdentityAccessToken" // only valid if UserInfo is enabled. stsDurationSeconds = "DurationSeconds" @@ -73,6 +74,9 @@ const ( // LDAP claim keys ldapUser = "ldapUser" ldapUserN = "ldapUsername" + + // Role Claim key + roleArnClaim = "roleArn" ) func parseOpenIDParentUser(parentUser string) (userID string, err error) { @@ -399,45 +403,42 @@ func (sts *stsAPIHandlers) AssumeRoleWithSSO(w http.ResponseWriter, r *http.Requ } } - var subFromToken string - if v, ok := m[subClaim]; ok { - subFromToken, _ = v.(string) - } - - if subFromToken == "" { - writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue, - errors.New("STS JWT Token has `sub` claim missing, `sub` claim is mandatory")) - return - } - - var issFromToken string - if v, ok := m[issClaim]; ok { - issFromToken, _ = v.(string) - } - - // JWT has requested a custom claim with policy value set. - // This is a MinIO STS API specific value, this value should - // be set and configured on your identity provider as part of - // JWT custom claims. var policyName string - policySet, ok := iampolicy.GetPoliciesFromClaims(m, iamPolicyClaimNameOpenID()) - policies := strings.Join(policySet.ToSlice(), ",") - if ok { - policyName = globalIAMSys.CurrentPolicies(policies) - } - - if globalPolicyOPA == nil { - if !ok { + roleArn := r.Form.Get(stsRoleArn) + if roleArn != "" { + _, err := globalIAMSys.GetRolePolicy(roleArn) + if err != nil { writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue, - fmt.Errorf("%s claim missing from the JWT token, credentials will not be generated", iamPolicyClaimNameOpenID())) - return - } else if policyName == "" { - writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue, - fmt.Errorf("None of the given policies (`%s`) are defined, credentials will not be generated", policies)) + fmt.Errorf("Error processing %s parameter: %v", stsRoleArn, err)) return } + // If roleArn is used, we set it as a claim, and use the + // associated policy when credentials are used. + m[roleArnClaim] = roleArn + } else { + // JWT has requested a custom claim with policy value set. + // This is a MinIO STS API specific value, this value should + // be set and configured on your identity provider as part of + // JWT custom claims. + policySet, ok := iampolicy.GetPoliciesFromClaims(m, iamPolicyClaimNameOpenID()) + policies := strings.Join(policySet.ToSlice(), ",") + if ok { + policyName = globalIAMSys.CurrentPolicies(policies) + } + + if globalPolicyOPA == nil { + if !ok { + writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue, + fmt.Errorf("%s claim missing from the JWT token, credentials will not be generated", iamPolicyClaimNameOpenID())) + return + } else if policyName == "" { + writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue, + fmt.Errorf("None of the given policies (`%s`) are defined, credentials will not be generated", policies)) + return + } + } + m[iamPolicyClaimNameOpenID()] = policyName } - m[iamPolicyClaimNameOpenID()] = policyName sessionPolicyStr := r.Form.Get(stsPolicy) // https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html @@ -476,6 +477,22 @@ func (sts *stsAPIHandlers) AssumeRoleWithSSO(w http.ResponseWriter, r *http.Requ // this is to ensure that ParentUser doesn't change and we get to use // parentUser as per the requirements for service accounts for OpenID // based logins. + var subFromToken string + if v, ok := m[subClaim]; ok { + subFromToken, _ = v.(string) + } + + if subFromToken == "" { + writeSTSErrorResponse(ctx, w, true, ErrSTSInvalidParameterValue, + errors.New("STS JWT Token has `sub` claim missing, `sub` claim is mandatory")) + return + } + + var issFromToken string + if v, ok := m[issClaim]; ok { + issFromToken, _ = v.(string) + } + cred.ParentUser = "openid:" + subFromToken + ":" + issFromToken // Set the newly generated credentials. diff --git a/cmd/sts-handlers_test.go b/cmd/sts-handlers_test.go index ece374933..203b593d3 100644 --- a/cmd/sts-handlers_test.go +++ b/cmd/sts-handlers_test.go @@ -741,7 +741,7 @@ const ( // SetUpOpenID - expects to setup an OpenID test server using the test OpenID // container and canned data from https://github.com/minio/minio-ldap-testing -func (s *TestSuiteIAM) SetUpOpenID(c *check, serverAddr string) { +func (s *TestSuiteIAM) SetUpOpenID(c *check, serverAddr string, rolePolicy string) { ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout) defer cancel() @@ -750,10 +750,14 @@ func (s *TestSuiteIAM) SetUpOpenID(c *check, serverAddr string) { fmt.Sprintf("config_url=%s/.well-known/openid-configuration", serverAddr), "client_id=minio-client-app", "client_secret=minio-client-app-secret", - "claim_name=groups", "scopes=openid,groups", "redirect_uri=http://127.0.0.1:10000/oauth_callback", } + if rolePolicy != "" { + configCmds = append(configCmds, fmt.Sprintf("role_policy=%s", rolePolicy)) + } else { + configCmds = append(configCmds, "claim_name=groups") + } _, err := s.adm.SetConfigKV(ctx, strings.Join(configCmds, " ")) if err != nil { c.Fatalf("unable to setup OpenID for tests: %v", err) @@ -797,7 +801,7 @@ func TestIAMWithOpenIDServerSuite(t *testing.T) { } suite.SetUpSuite(c) - suite.SetUpOpenID(c, openIDServer) + suite.SetUpOpenID(c, openIDServer, "") suite.TestOpenIDSTS(c) suite.TestOpenIDServiceAcc(c) suite.TearDownSuite(c) @@ -805,3 +809,163 @@ func TestIAMWithOpenIDServerSuite(t *testing.T) { ) } } + +func TestIAMWithOpenIDWithRolePolicyServerSuite(t *testing.T) { + baseTestCases := []TestSuiteCommon{ + // Init and run test on FS backend with signature v4. + {serverType: "FS", signer: signerV4}, + // Init and run test on FS backend, with tls enabled. + {serverType: "FS", signer: signerV4, secure: true}, + // Init and run test on Erasure backend. + {serverType: "Erasure", signer: signerV4}, + // Init and run test on ErasureSet backend. + {serverType: "ErasureSet", signer: signerV4}, + } + testCases := []*TestSuiteIAM{} + for _, bt := range baseTestCases { + testCases = append(testCases, + newTestSuiteIAM(bt, false), + newTestSuiteIAM(bt, true), + ) + } + for i, testCase := range testCases { + etcdStr := "" + if testCase.withEtcdBackend { + etcdStr = " (with etcd backend)" + } + t.Run( + fmt.Sprintf("Test: %d, ServerType: %s%s", i+1, testCase.serverType, etcdStr), + func(t *testing.T) { + c := &check{t, testCase.serverType} + suite := testCase + + openIDServer := os.Getenv(EnvTestOpenIDServer) + if openIDServer == "" { + c.Skip("Skipping OpenID test as no OpenID server is provided.") + } + + suite.SetUpSuite(c) + suite.SetUpOpenID(c, openIDServer, "readwrite") + suite.TestOpenIDSTSWithRolePolicy(c) + suite.TestOpenIDServiceAccWithRolePolicy(c) + suite.TearDownSuite(c) + }, + ) + } +} + +const ( + testRoleARN = "arn:minio:iam:::role/127.0.0.1_minio-cl" +) + +func (s *TestSuiteIAM) TestOpenIDSTSWithRolePolicy(c *check) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + bucket := getRandomBucketName() + err := s.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}) + if err != nil { + c.Fatalf("bucket create error: %v", err) + } + + // Generate web identity STS token by interacting with OpenID IDP. + token, err := mockTestUserInteraction(ctx, testProvider, "dillon@example.io", "dillon") + if err != nil { + c.Fatalf("mock user err: %v", err) + } + + webID := cr.STSWebIdentity{ + Client: s.TestSuiteCommon.client, + STSEndpoint: s.endPoint, + GetWebIDTokenExpiry: func() (*cr.WebIdentityToken, error) { + return &cr.WebIdentityToken{ + Token: token, + }, nil + }, + RoleARN: testRoleARN, + } + + value, err := webID.Retrieve() + if err != nil { + c.Fatalf("Expected to generate STS creds, got err: %#v", err) + } + // fmt.Printf("value: %#v\n", value) + + minioClient, err := minio.New(s.endpoint, &minio.Options{ + Creds: cr.NewStaticV4(value.AccessKeyID, value.SecretAccessKey, value.SessionToken), + Secure: s.secure, + Transport: s.TestSuiteCommon.client.Transport, + }) + if err != nil { + c.Fatalf("Error initializing client: %v", err) + } + + // Validate that the client from sts creds can access the bucket. + c.mustListObjects(ctx, minioClient, bucket) +} + +func (s *TestSuiteIAM) TestOpenIDServiceAccWithRolePolicy(c *check) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + bucket := getRandomBucketName() + err := s.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}) + if err != nil { + c.Fatalf("bucket create error: %v", err) + } + + // Generate web identity STS token by interacting with OpenID IDP. + token, err := mockTestUserInteraction(ctx, testProvider, "dillon@example.io", "dillon") + if err != nil { + c.Fatalf("mock user err: %v", err) + } + + webID := cr.STSWebIdentity{ + Client: s.TestSuiteCommon.client, + STSEndpoint: s.endPoint, + GetWebIDTokenExpiry: func() (*cr.WebIdentityToken, error) { + return &cr.WebIdentityToken{ + Token: token, + }, nil + }, + RoleARN: testRoleARN, + } + + value, err := webID.Retrieve() + if err != nil { + c.Fatalf("Expected to generate STS creds, got err: %#v", err) + } + + // Create an madmin client with user creds + userAdmClient, err := madmin.NewWithOptions(s.endpoint, &madmin.Options{ + Creds: cr.NewStaticV4(value.AccessKeyID, value.SecretAccessKey, value.SessionToken), + Secure: s.secure, + }) + if err != nil { + c.Fatalf("Err creating user admin client: %v", err) + } + userAdmClient.SetCustomTransport(s.TestSuiteCommon.client.Transport) + + // Create svc acc + cr := c.mustCreateSvcAccount(ctx, value.AccessKeyID, userAdmClient) + + // 1. Check that svc account appears in listing + c.assertSvcAccAppearsInListing(ctx, userAdmClient, value.AccessKeyID, cr.AccessKey) + + // 2. Check that svc account info can be queried + c.assertSvcAccInfoQueryable(ctx, userAdmClient, value.AccessKeyID, cr.AccessKey, true) + + // 3. Check S3 access + c.assertSvcAccS3Access(ctx, s, cr, bucket) + + // 4. Check that svc account can restrict the policy, and that the + // session policy can be updated. + c.assertSvcAccSessionPolicyUpdate(ctx, s, userAdmClient, value.AccessKeyID, bucket) + + // 4. Check that service account's secret key and account status can be + // updated. + c.assertSvcAccSecretKeyAndStatusUpdate(ctx, s, userAdmClient, value.AccessKeyID, bucket) + + // 5. Check that service account can be deleted. + c.assertSvcAccDeletion(ctx, s, userAdmClient, value.AccessKeyID, bucket) +} diff --git a/docs/sts/web-identity.md b/docs/sts/web-identity.md index 2d4079548..056de8c4b 100644 --- a/docs/sts/web-identity.md +++ b/docs/sts/web-identity.md @@ -6,6 +6,14 @@ Calling AssumeRoleWithWebIdentity does not require the use of MinIO default cred By default, the temporary security credentials created by AssumeRoleWithWebIdentity last for one hour. However, use the optional DurationSeconds parameter to specify the duration of the credentials. This value varies from 900 seconds (15 minutes) up to the maximum session duration of 365 days. +## Access Control Policies + +MinIO's AssumeRoleWithWebIdentity supports specifying access control policies in two ways: + +1. Role Policy (Recommended): When specified, all users authenticating via this API are authorized to (only) use the specified role policy. The policy to associate with such users is specified when configuring OpenID provider in the server, via the `role_policy` configuration parameter or the `MINIO_IDENTITY_OPENID_ROLE_POLICY` environment variable. The value is a comma-separated list of IAM access policy names already defined in the server. In this situation, the server prints a role ARN at startup that must be specified as a `RoleARN` API request parameter in the STS AssumeRoleWithWebIdentity API call. + +2. `id_token` claims: When the role policy is not configured, MinIO looks for a specific claim in the `id_token` (JWT) returned by the OpenID provider. The default claim is `policy` and can be overridden by the `claim_name` configuration parameter or the `MINIO_IDENTITY_OPENID_CLAIM_NAME` environment variable. The claim value can be a string (comma-separated list) or an array of IAM access policy names defined in the server. A `RoleARN` API request parameter *must not* be specified in the STS AssumeRoleWithWebIdentity API call. + ## API Request Parameters ### WebIdentityToken The OAuth 2.0 id_token that is provided by the web identity provider. Application must get this token by authenticating the user who is using your application with a web identity provider before the application makes an AssumeRoleWithWebIdentity call. @@ -24,6 +32,14 @@ There are situations when identity provider does not provide user claims in `id_ | *Type* | *String* | | *Required* | *No* | +### RoleARN +The role ARN to use. This must be specified if and only if the web identity provider is configured with a role policy. + +| Params | Value | +| :-- | :-- | +| *Type* | *String* | +| *Required* | *No* | + ### Version Indicates STS API version information, the only supported value is '2011-06-15'. This value is borrowed from AWS STS API documentation for compatibility reasons. diff --git a/go.mod b/go.mod index 362990e04..3f7898712 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,8 @@ require ( github.com/minio/highwayhash v1.0.2 github.com/minio/kes v0.14.0 github.com/minio/madmin-go v1.1.16 - github.com/minio/minio-go/v7 v7.0.16-0.20211108161804-a7a36ee131df + github.com/minio/mc v0.0.0-20211118223026-df75eed32e9e // indirect + github.com/minio/minio-go/v7 v7.0.16-0.20211117164632-e517704ccb36 github.com/minio/parquet-go v1.1.0 github.com/minio/pkg v1.1.9 github.com/minio/selfupdate v0.3.1 @@ -166,7 +167,6 @@ require ( github.com/minio/colorjson v1.0.1 // indirect github.com/minio/direct-csi v1.3.5-0.20210601185811-f7776f7961bf // indirect github.com/minio/filepath v1.0.0 // indirect - github.com/minio/mc v0.0.0-20211115052100-7fd441ec6c5b // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/operator v0.0.0-20211011212245-31460bbbc4b7 // indirect github.com/minio/operator/logsearchapi v0.0.0-20211011212245-31460bbbc4b7 // indirect diff --git a/go.sum b/go.sum index 3e9eeaf71..1974c987e 100644 --- a/go.sum +++ b/go.sum @@ -1086,12 +1086,12 @@ github.com/minio/kes v0.14.0/go.mod h1:OUensXz2BpgMfiogslKxv7Anyx/wj+6bFC6qA7BQc github.com/minio/madmin-go v1.0.12/go.mod h1:BK+z4XRx7Y1v8SFWXsuLNqQqnq5BO/axJ8IDJfgyvfs= github.com/minio/madmin-go v1.1.11-0.20211102182201-e51fd3d6b104/go.mod h1:Iu0OnrMWNBYx1lqJTW+BFjBMx0Hi0wjw8VmqhiOs2Jo= github.com/minio/madmin-go v1.1.12/go.mod h1:Iu0OnrMWNBYx1lqJTW+BFjBMx0Hi0wjw8VmqhiOs2Jo= -github.com/minio/madmin-go v1.1.13/go.mod h1:Iu0OnrMWNBYx1lqJTW+BFjBMx0Hi0wjw8VmqhiOs2Jo= +github.com/minio/madmin-go v1.1.15/go.mod h1:Iu0OnrMWNBYx1lqJTW+BFjBMx0Hi0wjw8VmqhiOs2Jo= github.com/minio/madmin-go v1.1.16 h1:c96vQBF3W9sPXiY04rjNa06FfOmWDjeFuChuqtOzLmE= github.com/minio/madmin-go v1.1.16/go.mod h1:Iu0OnrMWNBYx1lqJTW+BFjBMx0Hi0wjw8VmqhiOs2Jo= github.com/minio/mc v0.0.0-20211110003602-1461b652d920/go.mod h1:V8NmUfU0W3G/mrifeO6nm4CWFTiXY2nx7FJyMge/aHk= -github.com/minio/mc v0.0.0-20211115052100-7fd441ec6c5b h1:crCI2lSbzWzMuk/U6fMqSl5eF2V2VKDFNX+ILSD1sxU= -github.com/minio/mc v0.0.0-20211115052100-7fd441ec6c5b/go.mod h1:2fFAzMBmEYcN4mjcmQdlLuSabP+bvQC5UpqfLzRgrQQ= +github.com/minio/mc v0.0.0-20211118223026-df75eed32e9e h1:6EoG2tWc6y89CTX6h2jvbAaSSjd78zBKaL4U1wEJ3yA= +github.com/minio/mc v0.0.0-20211118223026-df75eed32e9e/go.mod h1:sXbvyABnNzmpnMEFT2aOexxnI8O0x802lZxbXo8aDgA= github.com/minio/md5-simd v1.1.0/go.mod h1:XpBqgZULrMYD3R+M28PcmP0CkI7PEMzB3U77ZrKZ0Gw= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= @@ -1099,8 +1099,9 @@ github.com/minio/minio-go/v7 v7.0.10/go.mod h1:td4gW1ldOsj1PbSNS+WYK43j+P1XVhX/8 github.com/minio/minio-go/v7 v7.0.11-0.20210302210017-6ae69c73ce78/go.mod h1:mTh2uJuAbEqdhMVl6CMIIZLUeiMiWtJR4JB8/5g2skw= github.com/minio/minio-go/v7 v7.0.15-0.20211004160302-3b57c1e369ca/go.mod h1:pUV0Pc+hPd1nccgmzQF/EXh48l/Z/yps6QPF1aaie4g= github.com/minio/minio-go/v7 v7.0.15/go.mod h1:pUV0Pc+hPd1nccgmzQF/EXh48l/Z/yps6QPF1aaie4g= -github.com/minio/minio-go/v7 v7.0.16-0.20211108161804-a7a36ee131df h1:7BfpVODGh5reCjIx2lUqE7CxRMjo58XJw7ZjKKNW/vc= github.com/minio/minio-go/v7 v7.0.16-0.20211108161804-a7a36ee131df/go.mod h1:pUV0Pc+hPd1nccgmzQF/EXh48l/Z/yps6QPF1aaie4g= +github.com/minio/minio-go/v7 v7.0.16-0.20211117164632-e517704ccb36 h1:amnEPz1PuZxUUSKQvQn7E4Pd+B7tIqmqiFeuc9yy2r4= +github.com/minio/minio-go/v7 v7.0.16-0.20211117164632-e517704ccb36/go.mod h1:pUV0Pc+hPd1nccgmzQF/EXh48l/Z/yps6QPF1aaie4g= github.com/minio/operator v0.0.0-20211011212245-31460bbbc4b7 h1:dkfuMNslMjGoJ4ArAMSoQhidYNdm3SgzLBP+f96O3/E= github.com/minio/operator v0.0.0-20211011212245-31460bbbc4b7/go.mod h1:lDpuz8nwsfhKlfiBaA3Z8AW019fWEAjO2gltfLbdorE= github.com/minio/operator/logsearchapi v0.0.0-20211011212245-31460bbbc4b7 h1:vFtQqCt67ETp0JAkOKRWTKkgwFv14Vc1jJSxmQ8wJE0= diff --git a/internal/arn/arn.go b/internal/arn/arn.go new file mode 100644 index 000000000..f32cea0c4 --- /dev/null +++ b/internal/arn/arn.go @@ -0,0 +1,147 @@ +// Copyright (c) 2015-2021 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package arn + +import ( + "fmt" + "regexp" + "strings" +) + +// ARN structure: +// +// arn:partition:service:region:account-id:resource-type/resource-id +// +// In this implementation, account-id is empty. +// +// Reference: https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html + +type arnPartition string + +const ( + arnPartitionMinio arnPartition = "minio" +) + +type arnService string + +const ( + arnServiceIAM arnService = "iam" +) + +type arnResourceType string + +const ( + arnResourceTypeRole arnResourceType = "role" +) + +// ARN - representation of resources based on AWS ARNs. +type ARN struct { + Partition arnPartition + Service arnService + Region string + ResourceType arnResourceType + ResourceID string +} + +var ( + // Allows lower-case chars, numbers, '.', '-', '_' and '/'. Starts with + // a letter or digit. At least 1 character long. + validResourceIDRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9_/\.-]*$`) +) + +// NewIAMRoleARN - returns an ARN for a role in MinIO. +func NewIAMRoleARN(resourceID, serverRegion string) (ARN, error) { + if !validResourceIDRegex.MatchString(resourceID) { + return ARN{}, fmt.Errorf("Invalid resource ID: %s", resourceID) + } + return ARN{ + Partition: arnPartitionMinio, + Service: arnServiceIAM, + Region: serverRegion, + ResourceType: arnResourceTypeRole, + ResourceID: resourceID, + }, nil +} + +// String - returns string representation of the ARN. +func (arn ARN) String() string { + return strings.Join( + []string{ + "arn", + string(arn.Partition), + string(arn.Service), + arn.Region, + "", // account-id is always empty in this implementation + string(arn.ResourceType) + "/" + arn.ResourceID, + }, + ":", + ) +} + +// Parse - parses an ARN string into a type. +func Parse(arnStr string) (arn ARN, err error) { + ps := strings.Split(arnStr, ":") + if len(ps) != 6 || + ps[0] != "arn" { + err = fmt.Errorf("Invalid ARN string format") + return + } + + if ps[1] != string(arnPartitionMinio) { + err = fmt.Errorf("Invalid ARN - bad partition field") + return + } + + if ps[2] != string(arnServiceIAM) { + err = fmt.Errorf("Invalid ARN - bad service field") + return + } + + // ps[3] is region and is not validated here. If the region is invalid, + // the ARN would not match any configured ARNs in the server. + + if ps[4] != "" { + err = fmt.Errorf("Invalid ARN - unsupported account-id field") + return + } + + res := strings.SplitN(ps[5], "/", 2) + if len(res) != 2 { + err = fmt.Errorf("Invalid ARN - resource does not contain a \"/\"") + return + } + + if res[0] != string(arnResourceTypeRole) { + err = fmt.Errorf("Invalid ARN: resource type is invalid.") + return + } + + if !validResourceIDRegex.MatchString(res[1]) { + err = fmt.Errorf("Invalid resource ID: %s", res[1]) + return + } + + arn = ARN{ + Partition: arnPartitionMinio, + Service: arnServiceIAM, + Region: ps[3], + ResourceType: arnResourceTypeRole, + ResourceID: res[1], + } + return +} diff --git a/internal/config/identity/openid/help.go b/internal/config/identity/openid/help.go index b8f7be9d2..75bf90346 100644 --- a/internal/config/identity/openid/help.go +++ b/internal/config/identity/openid/help.go @@ -50,6 +50,12 @@ var ( Optional: true, Type: "on|off", }, + config.HelpKV{ + Key: RolePolicy, + Description: `Set the IAM access policies applicable to this client application and IDP e.g. "app-bucket-write,app-bucket-list"`, + Optional: true, + Type: "string", + }, config.HelpKV{ Key: Scopes, Description: `Comma separated list of OpenID scopes for server, defaults to advertised scopes from discovery document e.g. "email,admin"`, @@ -98,5 +104,17 @@ var ( Optional: true, Type: "sentence", }, + config.HelpKV{ + Key: ClaimPrefix, + Description: `[DEPRECATED use 'claim_name'] JWT claim namespace prefix e.g. "customer1/"`, + Optional: true, + Type: "string", + }, + config.HelpKV{ + Key: RedirectURI, + Description: `[DEPRECATED use env 'MINIO_BROWSER_REDIRECT_URL'] Configure custom redirect_uri for OpenID login flow callback`, + Optional: true, + Type: "string", + }, } ) diff --git a/internal/config/identity/openid/jwt.go b/internal/config/identity/openid/jwt.go index a1c57cbd6..324a5ec0a 100644 --- a/internal/config/identity/openid/jwt.go +++ b/internal/config/identity/openid/jwt.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "strconv" "strings" @@ -30,6 +31,7 @@ import ( "time" jwtgo "github.com/golang-jwt/jwt/v4" + "github.com/minio/minio/internal/arn" "github.com/minio/minio/internal/auth" "github.com/minio/minio/internal/config" "github.com/minio/minio/internal/config/identity/openid/provider" @@ -56,7 +58,9 @@ type Config struct { DiscoveryDoc DiscoveryDoc ClientID string ClientSecret string + RolePolicy string + roleArn arn.ARN provider provider.Provider publicKeys map[string]crypto.PublicKey transport *http.Transport @@ -167,6 +171,12 @@ func (r Config) ProviderEnabled() bool { return r.Enabled && r.provider != nil } +// GetRoleInfo - returns role ARN and policy if present, otherwise returns false +// boolean. +func (r Config) GetRoleInfo() (arn.ARN, string, bool) { + return r.roleArn, r.RolePolicy, r.RolePolicy != "" +} + // InitializeKeycloakProvider - initializes keycloak provider func (r *Config) InitializeKeycloakProvider(adminURL, realm string) error { var err error @@ -366,6 +376,7 @@ const ( ClaimPrefix = "claim_prefix" ClientID = "client_id" ClientSecret = "client_secret" + RolePolicy = "role_policy" Vendor = "vendor" Scopes = "scopes" @@ -383,6 +394,7 @@ const ( EnvIdentityOpenIDClaimName = "MINIO_IDENTITY_OPENID_CLAIM_NAME" EnvIdentityOpenIDClaimUserInfo = "MINIO_IDENTITY_OPENID_CLAIM_USERINFO" EnvIdentityOpenIDClaimPrefix = "MINIO_IDENTITY_OPENID_CLAIM_PREFIX" + EnvIdentityOpenIDRolePolicy = "MINIO_IDENTITY_OPENID_ROLE_POLICY" EnvIdentityOpenIDRedirectURI = "MINIO_IDENTITY_OPENID_REDIRECT_URI" EnvIdentityOpenIDRedirectURIDynamic = "MINIO_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC" EnvIdentityOpenIDScopes = "MINIO_IDENTITY_OPENID_SCOPES" @@ -458,6 +470,10 @@ var ( Key: ClaimUserinfo, Value: "", }, + config.KV{ + Key: RolePolicy, + Value: "", + }, config.KV{ Key: ClaimPrefix, Value: "", @@ -483,7 +499,7 @@ func Enabled(kvs config.KVS) bool { } // LookupConfig lookup jwks from config, override with any ENVs. -func LookupConfig(kvs config.KVS, transport *http.Transport, closeRespFn func(io.ReadCloser)) (c Config, err error) { +func LookupConfig(kvs config.KVS, transport *http.Transport, closeRespFn func(io.ReadCloser), serverRegion string) (c Config, err error) { // remove this since we have removed this already. kvs.Delete(JwksURL) @@ -501,16 +517,19 @@ func LookupConfig(kvs config.KVS, transport *http.Transport, closeRespFn func(io publicKeys: make(map[string]crypto.PublicKey), ClientID: env.Get(EnvIdentityOpenIDClientID, kvs.Get(ClientID)), ClientSecret: env.Get(EnvIdentityOpenIDClientSecret, kvs.Get(ClientSecret)), + RolePolicy: env.Get(EnvIdentityOpenIDRolePolicy, kvs.Get(RolePolicy)), transport: transport, closeRespFn: closeRespFn, } configURL := env.Get(EnvIdentityOpenIDURL, kvs.Get(ConfigURL)) + var configURLDomain string if configURL != "" { c.URL, err = xnet.ParseHTTPURL(configURL) if err != nil { return c, err } + configURLDomain, _, _ = net.SplitHostPort(c.URL.Host) c.DiscoveryDoc, err = parseDiscoveryDoc(c.URL, transport, closeRespFn) if err != nil { return c, err @@ -534,8 +553,38 @@ func LookupConfig(kvs config.KVS, transport *http.Transport, closeRespFn func(io c.DiscoveryDoc.ScopesSupported = scopes } - if c.ClaimName == "" { - c.ClaimName = iampolicy.PolicyName + // Check if claim name is the non-default value and role policy is set. + if c.ClaimName != iampolicy.PolicyName && c.RolePolicy != "" { + // In the unlikely event that the user specifies + // `iampolicy.PolicyName` as the claim name explicitly and sets + // a role policy, this check is thwarted, but we will be using + // the role policy anyway. + return c, config.Errorf("Role Policy and Claim Name cannot both be set.") + } + + if c.RolePolicy != "" { + // RolePolicy is valided by IAM System during its + // initialization. + + // Generate role ARN as combination of provider domain and + // prefix of client ID. + domain := configURLDomain + if domain == "" { + // Attempt to parse the JWKs URI. + domain, _, _ = net.SplitHostPort(c.JWKS.URL.Host) + if domain == "" { + return c, config.Errorf("unable to generate a domain from the OpenID config.") + } + } + clientIDFragment := c.ClientID[:8] + if clientIDFragment == "" { + return c, config.Errorf("unable to get a non-empty clientID fragment from the OpenID config.") + } + resourceID := domain + "_" + clientIDFragment + c.roleArn, err = arn.NewIAMRoleARN(resourceID, serverRegion) + if err != nil { + return c, config.Errorf("unable to generate ARN from the OpenID config: %v", err) + } } jwksURL := c.DiscoveryDoc.JwksURI